byuckchon-frontend-cli 1.5.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -91,6 +91,68 @@ bc init
91
91
  > 안전망은 git diff. 매 작업 후 `git status` / `git diff` 로 확인하고, 마음에 안 들면 `git checkout .` 으로 되돌리세요.
92
92
  > 다음 버전에서 per-file 승인(`y/n/v`) 옵션 추가 예정.
93
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
+
94
156
  ### OpenAPI / 코드 컨텍스트 — 자동 주입 (v1.4+)
95
157
 
96
158
  `bc.config.json` 의 `api.openapi` 와 코드 인덱스는 **chat 시작할 때 알아서 준비됩니다.**
@@ -202,17 +264,39 @@ TTY 안에서 자동으로 ink 모드로 뜨고, 파이프/CI 같은 비-TTY 환
202
264
  이미지 첨부는 png / jpg / jpeg / gif / webp 만 지원하며,
203
265
  Claude / GPT 비전 모델에 멀티파트 메시지로 전달됩니다.
204
266
 
205
- **이미지 첨부 3가지 방법:**
267
+ **이미지 첨부 3가지 방법:** (ink·plain 모드 모두 지원 — v1.6.1+)
206
268
  1. `/image ./shot.png` — 경로 직접
207
269
  2. **드래그 & 드롭** — `/image ` 까지 입력 후, Finder 에서 파일을 터미널 위로 끌어다 놓으면 절대경로가 자동 입력됩니다. Enter.
208
- 3. `/paste` — **macOS 한정**, 클립보드의 이미지(예: `Cmd+Shift+4` 스크린샷)를 바로 첨부.
270
+ 3. `/paste` — **macOS 한정**, 클립보드의 이미지(예: `Cmd+Shift+4` 스크린샷 또는 Finder 에서 `Cmd+C` 한 이미지)를 바로 첨부.
209
271
  - 사전에 `brew install pngpaste` 한 번 필요.
210
272
 
211
- ### 한글 입력이보일
273
+ > **터미널에서 `Cmd+V` 로 직접 붙이기는 왜 되나?** 터미널 앱은 클립보드의 "이미지 바이트" 를
274
+ > 앱에 전달하지 않고 텍스트만 줍니다 (OS/터미널 공통 제약). 그래서 클립보드 이미지를 붙이려면
275
+ > `/paste` 가 `pngpaste` 로 클립보드를 직접 읽어 첨부합니다 — `Cmd+C` → 입력창에 `/paste` → Enter.
276
+
277
+ > **썸네일 미리보기:** iTerm2 · kitty · WezTerm 에서는 plain 모드에서 첨부 직후 작은 썸네일이
278
+ > 인라인으로 표시됩니다. 그 외 터미널은 파일명 + 용량만 표시됩니다 (터미널이 이미지 렌더링을
279
+ > 지원하지 않기 때문).
280
+
281
+ ### 한글 입력이 자꾸 씹힐 때 (v1.6+)
282
+
283
+ `ink` 의 TextInput 은 macOS 한글 IME 의 조합 단계와 충돌해 글자가 한 박자 늦게 보이거나
284
+ 빠뜨려지는 경우가 있습니다 — ink-text-input 의 알려진 한계입니다.
285
+
286
+ **가장 확실한 해결**: 입력 모드를 plain(readline) 으로 영구 전환
287
+
288
+ ```bash
289
+ bc config set-ui plain # 글로벌로 plain 모드 고정
290
+ # 한글 입력 안정, 모든 기본 기능 동작 (RAG, OpenAPI, Figma 툴 호출까지)
291
+ # 단, ink 전용 기능 일부 미지원: 슬래시 자동완성 메뉴, 인라인 이미지 첨부
292
+ ```
293
+
294
+ ink 로 다시 돌아오려면:
295
+ ```bash
296
+ bc config set-ui ink
297
+ ```
212
298
 
213
- ink 터미널 커서를 숨겨버려서 macOS 한글 IME 의 조합 미리보기가 안 보이는 이슈가 있었습니다.
214
- v1.4 부터는 ink 시작 후 커서를 강제로 다시 켜고 가짜 커서를 끄는 방식으로 수정되어 정상 동작해야 합니다.
215
- 혹시 그래도 문제가 보이면 `bc chat --plain` 으로 readline 모드를 쓸 수 있습니다 (TUI 기능은 일부 제한).
299
+ 일회성으로 plain 쓰고 싶으면 `bc chat --plain`.
216
300
 
217
301
  ### `bc config` — 설정
218
302
 
@@ -224,6 +308,8 @@ bc config set-key anthropic # 키 안전 입력 (가려짐)
224
308
  bc config set-key anthropic sk-ant-... # 직접 지정
225
309
  bc config set-gateway https://ai.example.com # 사내 게이트웨이 모드
226
310
  bc config set-gateway # 게이트웨이 해제 (BYOK 모드)
311
+ bc config set-ui plain # 한글 IME 안정 모드
312
+ bc config set-ui ink # 풀 TUI 복귀
227
313
  ```
228
314
 
229
315
  ## 설정 위치
@@ -263,7 +349,9 @@ bc config set-gateway # 게이트웨이 해제 (BYOK 모
263
349
  - [x] Phase 3c-1: chat 시작 시 인덱스 자동 빌드, OpenAPI 자동 fetch+캐시+시스템 프롬프트 주입
264
350
  - [x] v1.4.1 — `deepMerge(null, obj)` TypeError 수정 (`bc adopt` 한 프로젝트에서 모든 명령이 터지던 버그)
265
351
  - [x] v1.5.0 — 에이전트 모드 (read/list/search/write/edit 툴) — AI 가 실제 파일을 만든다
266
- - [ ] v1.6.0 — write/edit 승인 게이트 (`y/n/v/q`), diff 미리보기
352
+ - [x] v1.6.0 — Figma (fetch_figma / image / styles), 한글 IME 안정 plain 모드 (`bc config set-ui plain`)
353
+ - [x] v1.6.1 — 툴 스키마 `jsonSchema()` 래핑 (`schema is not a function` 수정), plain 모드 이미지 첨부(`/image`·`/paste`) + iTerm2/kitty 인라인 썸네일
354
+ - [ ] v1.7.0 — write/edit 승인 게이트 (`y/n/v/q`), diff 미리보기
267
355
  - [ ] Phase 3c-2: Figma 실 fetch (URL → 노드 트리 → 컴포넌트 인텐트)
268
356
  - [ ] Phase 4: `bc gen component/page` (AST 편집 + 검증 루프), `/apply` diff 미리보기
269
357
 
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.5.0');
31
+ .version('1.6.1');
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.5.0",
3
+ "version": "1.6.1",
4
4
  "description": "Byuckchon Frontend Workbench — project starter + AI chat + codebase RAG + OpenAPI codegen",
5
5
  "type": "module",
6
6
  "engines": {
@@ -0,0 +1,75 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ /**
7
+ * macOS 클립보드의 이미지(스크린샷 등)를 임시 PNG 로 떨궈 절대경로를 돌려준다.
8
+ *
9
+ * 터미널 앱은 Cmd+V 로 "이미지 바이트" 를 받지 못한다 (텍스트만 받음). 그래서
10
+ * 클립보드 이미지를 붙이려면 OS 클립보드를 직접 읽는 외부 도구가 필요하다.
11
+ * - macOS: `pngpaste` (brew install pngpaste)
12
+ *
13
+ * 다른 OS 거나 도구가 없으면 도움이 되는 에러로 throw.
14
+ */
15
+ export async function pasteClipboardImage() {
16
+ if (process.platform !== 'darwin') {
17
+ throw new Error(
18
+ '클립보드 이미지 붙여넣기는 현재 macOS 만 지원합니다. 다른 OS 에서는 파일로 저장하고 /image <경로> 를 쓰세요.',
19
+ );
20
+ }
21
+
22
+ const checkOk = await new Promise((resolve) => {
23
+ const p = spawn('which', ['pngpaste']);
24
+ p.on('close', (code) => resolve(code === 0));
25
+ p.on('error', () => resolve(false));
26
+ });
27
+ if (!checkOk) {
28
+ throw new Error(
29
+ 'pngpaste 가 필요합니다. 설치: `brew install pngpaste` (Homebrew 가 없으면 https://brew.sh)',
30
+ );
31
+ }
32
+
33
+ const tmp = path.join(os.tmpdir(), `bc-paste-${Date.now()}.png`);
34
+ await new Promise((resolve, reject) => {
35
+ const p = spawn('pngpaste', [tmp]);
36
+ let stderr = '';
37
+ p.stderr.on('data', (d) => (stderr += d.toString()));
38
+ p.on('close', (code) => {
39
+ if (code === 0) resolve();
40
+ else
41
+ reject(
42
+ new Error(
43
+ '클립보드에 이미지가 없거나 읽을 수 없습니다.' +
44
+ (stderr ? ' (' + stderr.trim() + ')' : ''),
45
+ ),
46
+ );
47
+ });
48
+ p.on('error', (e) => reject(e));
49
+ });
50
+
51
+ const stat = await fs.stat(tmp);
52
+ if (!stat.isFile() || stat.size === 0) {
53
+ throw new Error('클립보드 이미지 저장 실패 (파일이 비어있음).');
54
+ }
55
+ return tmp;
56
+ }
57
+
58
+ /**
59
+ * 드래그&드롭 / 붙여넣기로 들어온 경로 문자열을 정리한다.
60
+ * - 앞뒤 따옴표 제거
61
+ * - 백슬래시 이스케이프(`\ `) 제거
62
+ * - 양끝 공백 제거
63
+ */
64
+ export function cleanDroppedPath(raw) {
65
+ if (!raw) return '';
66
+ let s = raw.trim();
67
+ if (
68
+ (s.startsWith("'") && s.endsWith("'")) ||
69
+ (s.startsWith('"') && s.endsWith('"'))
70
+ ) {
71
+ s = s.slice(1, -1);
72
+ }
73
+ s = s.replace(/\\ /g, ' ');
74
+ return s.trim();
75
+ }
@@ -0,0 +1,79 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * 터미널 인라인 이미지 프리뷰.
6
+ *
7
+ * 일반 터미널은 이미지를 못 그린다. 하지만 일부는 자체 프로토콜이 있다:
8
+ * - iTerm2 : OSC 1337 ; File=... (base64) BEL
9
+ * - kitty : APC _G ... ST (kitty graphics protocol)
10
+ * - WezTerm : iTerm2 프로토콜 호환
11
+ *
12
+ * 지원되면 작은 썸네일을 직접 stdout 에 그려준다. 안 되면 false 반환 → 호출측에서
13
+ * 파일명/용량만 텍스트로 표시.
14
+ *
15
+ * ⚠️ ink TUI 안에서는 re-render 가 escape 시퀀스를 덮어쓰므로 쓰지 않는다.
16
+ * readline(plain) 모드처럼 stdout 을 직접 쓰는 곳에서만 사용.
17
+ */
18
+ export function detectInlineImageSupport() {
19
+ const term = process.env.TERM ?? '';
20
+ const prog = process.env.TERM_PROGRAM ?? '';
21
+ if (prog === 'iTerm.app' || prog === 'WezTerm') return 'iterm2';
22
+ if (term.includes('kitty') || process.env.KITTY_WINDOW_ID) return 'kitty';
23
+ return null;
24
+ }
25
+
26
+ const ESC = '\u001B';
27
+ const BEL = '\u0007';
28
+
29
+ /** iTerm2 inline image. heightCells 만큼만 차지하도록 제한해 작은 썸네일로. */
30
+ function itermInline(base64, { heightCells = 6 } = {}) {
31
+ const args = [
32
+ 'inline=1',
33
+ `height=${heightCells}`,
34
+ 'preserveAspectRatio=1',
35
+ ].join(';');
36
+ return `${ESC}]1337;File=${args}:${base64}${BEL}`;
37
+ }
38
+
39
+ /** kitty graphics — 한 번에 전송(a=T), 직접 표시. */
40
+ function kittyInline(base64) {
41
+ // kitty 는 4096 바이트 청크로 쪼개야 한다.
42
+ const CHUNK = 4096;
43
+ let out = '';
44
+ let first = true;
45
+ for (let i = 0; i < base64.length; i += CHUNK) {
46
+ const piece = base64.slice(i, i + CHUNK);
47
+ const more = i + CHUNK < base64.length ? 1 : 0;
48
+ if (first) {
49
+ out += `${ESC}_Ga=T,f=100,m=${more};${piece}${ESC}\\`;
50
+ first = false;
51
+ } else {
52
+ out += `${ESC}_Gm=${more};${piece}${ESC}\\`;
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /**
59
+ * 이미지 파일을 작은 썸네일로 터미널에 그린다.
60
+ * @returns {Promise<boolean>} 그렸으면 true, 미지원이면 false
61
+ */
62
+ export async function printInlineThumbnail(filePath, { heightCells = 6 } = {}) {
63
+ const kind = detectInlineImageSupport();
64
+ if (!kind) return false;
65
+ try {
66
+ const buf = await fs.readFile(path.resolve(filePath));
67
+ // 너무 크면 프리뷰 스킵 (전송 비용). 2MB 초과면 패스.
68
+ if (buf.byteLength > 2 * 1024 * 1024) return false;
69
+ const base64 = buf.toString('base64');
70
+ if (kind === 'iterm2') {
71
+ process.stdout.write(' ' + itermInline(base64, { heightCells }) + '\n');
72
+ } else if (kind === 'kitty') {
73
+ process.stdout.write(' ' + kittyInline(base64) + '\n');
74
+ }
75
+ return true;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
@@ -28,6 +28,18 @@ export function buildSystemPrompt({ effective, paths, project }) {
28
28
  ' - 자동 생성된 `*.gen.ts` 가 있다면 거기서 타입을 import 해서 재정의를 피한다.',
29
29
  ' 3) 마지막으로 **만든 파일 목록과 다음 액션(어디서 import 하면 되는지 등)** 을 한국어로 짧게 요약.',
30
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
+ '',
31
43
  '"코드 짜줘" 라는 표현은 채팅창에 코드 블록을 출력하라는 의미가 **아니다**.',
32
44
  '항상 툴을 사용해 실제 파일을 만들어라. 채팅에는 진행 상황과 결과 요약만 짧게 적는다.',
33
45
  '',
@@ -57,7 +69,13 @@ export function buildSystemPrompt({ effective, paths, project }) {
57
69
  }\``,
58
70
  );
59
71
  }
60
- 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
+ }
61
79
  if (project?.api?.openapi) meta.push(`- OpenAPI: ${project.api.openapi}`);
62
80
  if (project?.api?.baseUrl) meta.push(`- API base URL: ${project.api.baseUrl}`);
63
81
  if (effective?.model) meta.push(`- 사용 모델: ${effective.model}`);
package/src/ai/tools.js CHANGED
@@ -2,9 +2,16 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
4
  import fg from 'fast-glob';
5
- import { tool } from 'ai';
5
+ import { tool, jsonSchema } from 'ai';
6
6
 
7
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';
8
15
 
9
16
  /**
10
17
  * Agentic chat 용 툴 정의.
@@ -164,35 +171,120 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
164
171
  return { ok: true, path: rel, action: 'edited' };
165
172
  }
166
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
+
167
259
  return {
168
260
  read_file: tool({
169
261
  description:
170
262
  '프로젝트 안의 파일이나 디렉터리 내용을 읽는다. 코드 짜기 전에 반드시 기존 코드 컨벤션을 먼저 읽어볼 것.',
171
- inputSchema: {
263
+ inputSchema: jsonSchema({
172
264
  type: 'object',
173
265
  properties: { path: { type: 'string', description: '프로젝트 루트 기준 상대 경로' } },
174
266
  required: ['path'],
175
267
  additionalProperties: false,
176
- },
268
+ }),
177
269
  execute: readFile,
178
270
  }),
179
271
  list_files: tool({
180
272
  description:
181
273
  '글롭 패턴으로 파일을 나열. 폴더 구조 파악, 비슷한 모듈 위치 찾기에 사용. 예: "src/api/**/*.ts"',
182
- inputSchema: {
274
+ inputSchema: jsonSchema({
183
275
  type: 'object',
184
276
  properties: {
185
277
  pattern: { type: 'string', default: '**/*' },
186
278
  limit: { type: 'number', default: 100 },
187
279
  },
188
280
  additionalProperties: false,
189
- },
281
+ }),
190
282
  execute: listFiles,
191
283
  }),
192
284
  search_code: tool({
193
285
  description:
194
286
  '코드베이스를 의미 기반(임베딩)으로 검색. "fetch 래퍼 패턴", "useQuery hook 컨벤션" 같이 자연어로 찾기. 인덱스가 없으면 에러.',
195
- inputSchema: {
287
+ inputSchema: jsonSchema({
196
288
  type: 'object',
197
289
  properties: {
198
290
  query: { type: 'string' },
@@ -200,13 +292,13 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
200
292
  },
201
293
  required: ['query'],
202
294
  additionalProperties: false,
203
- },
295
+ }),
204
296
  execute: searchCode,
205
297
  }),
206
298
  write_file: tool({
207
299
  description:
208
300
  '새 파일을 만들거나 기존 파일을 통째로 덮어쓴다. 새 파일을 만들기 전에 반드시 1) 비슷한 기존 파일을 read_file 로 보고 2) 같은 폴더 컨벤션(barrel 파일, 네이밍, import 순서) 을 따른다.',
209
- inputSchema: {
301
+ inputSchema: jsonSchema({
210
302
  type: 'object',
211
303
  properties: {
212
304
  path: { type: 'string' },
@@ -214,13 +306,13 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
214
306
  },
215
307
  required: ['path', 'content'],
216
308
  additionalProperties: false,
217
- },
309
+ }),
218
310
  execute: writeFile,
219
311
  }),
220
312
  edit_file: tool({
221
313
  description:
222
314
  '기존 파일에서 old_string 을 찾아 new_string 으로 정확히 1번 치환. old_string 은 파일 안에서 유일해지도록 충분한 컨텍스트(앞뒤 줄) 를 포함시킬 것. 여러 번 등장하면 에러로 거부.',
223
- inputSchema: {
315
+ inputSchema: jsonSchema({
224
316
  type: 'object',
225
317
  properties: {
226
318
  path: { type: 'string' },
@@ -229,8 +321,54 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
229
321
  },
230
322
  required: ['path', 'old_string', 'new_string'],
231
323
  additionalProperties: false,
232
- },
324
+ }),
233
325
  execute: editFile,
234
326
  }),
327
+ fetch_figma: tool({
328
+ description:
329
+ 'Figma 노드 트리(컴포넌트/프레임/페이지) 를 읽는다. URL 에 node-id 가 있으면 그 frame 의 ' +
330
+ '간소화된 디자인 정보(autoLayout, fills, text, size, children 등) 를 반환. ' +
331
+ '없으면 파일 페이지 목록만. 컴포넌트/페이지 생성 요청을 받으면 이 툴을 먼저 호출해서 ' +
332
+ '디자인 의도를 학습한 뒤 코드를 짠다.',
333
+ inputSchema: jsonSchema({
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: jsonSchema({
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: jsonSchema({
366
+ type: 'object',
367
+ properties: { url: { type: 'string' } },
368
+ required: ['url'],
369
+ additionalProperties: false,
370
+ }),
371
+ execute: fetchFigmaStylesTool,
372
+ }),
235
373
  };
236
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
  }
@@ -11,6 +11,9 @@ import { TokenMeter } from '../ai/tokenMeter.js';
11
11
  import { buildSystemPrompt } from '../ai/systemPrompt.js';
12
12
  import { findModel } from '../ai/models.js';
13
13
  import { buildTools } from '../ai/tools.js';
14
+ import { toSdkMessages, isImagePath, imagePartFromFile } from '../ai/messageContent.js';
15
+ import { pasteClipboardImage, cleanDroppedPath } from '../ai/clipboard.js';
16
+ import { printInlineThumbnail } from '../ai/imagePreview.js';
14
17
  import {
15
18
  createSession,
16
19
  saveSession,
@@ -133,8 +136,11 @@ export async function chatCommand(opts = {}) {
133
136
  await saveSession(session); // 빈 파일이라도 디스크에 만들어둠
134
137
 
135
138
  // ink 는 stdin/stdout 둘 다 TTY 이어야 정상 동작.
139
+ // - --plain 플래그가 명시되거나 비-TTY 면 readline 폴백.
140
+ // - 글로벌 ui.mode 가 "plain" 이면 한글 IME 가 깨지는 케이스를 자동 회피.
136
141
  const isTTY = process.stdin.isTTY && process.stdout.isTTY;
137
- if (!isTTY || opts.plain) {
142
+ const wantPlain = opts.plain || cfg.global?.ui?.mode === 'plain';
143
+ if (!isTTY || wantPlain) {
138
144
  return runReadlineFallback({ cfg, resolved, system, session, openapiInfo });
139
145
  }
140
146
 
@@ -251,7 +257,46 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
251
257
  if (session?.messages?.length) {
252
258
  console.log(chalk.dim(` 세션: ${session.id} (${session.messages.length} turns 이어가기)`));
253
259
  }
254
- console.log(chalk.dim(' /exit 또는 Ctrl+D 로 종료\n'));
260
+ console.log(
261
+ chalk.dim(' /image <경로> · /paste(클립보드 이미지) · /clear-attach · /exit\n'),
262
+ );
263
+
264
+ // 다음 메시지에 함께 보낼 이미지 첨부 목록.
265
+ let pendingAttachments = [];
266
+
267
+ const printAttachments = () => {
268
+ if (!pendingAttachments.length) {
269
+ console.log(chalk.dim(' 첨부 없음.'));
270
+ return;
271
+ }
272
+ console.log(chalk.dim(' 현재 첨부:'));
273
+ for (const a of pendingAttachments) {
274
+ console.log(chalk.dim(` - ${path.basename(a.path)} (${a.sizeKb}KB)`));
275
+ }
276
+ };
277
+
278
+ const addImage = async (rawPath) => {
279
+ const p = cleanDroppedPath(rawPath);
280
+ if (!p) {
281
+ console.log(chalk.red(' 사용법: /image <파일 경로>'));
282
+ return;
283
+ }
284
+ if (!isImagePath(p)) {
285
+ console.log(chalk.red(' 지원 확장자: png, jpg, jpeg, gif, webp'));
286
+ return;
287
+ }
288
+ try {
289
+ const { default: fsp } = await import('node:fs/promises');
290
+ const stat = await fsp.stat(path.resolve(p));
291
+ const sizeKb = Math.max(1, Math.round(stat.size / 1024));
292
+ pendingAttachments.push({ kind: 'image', path: path.resolve(p), sizeKb });
293
+ console.log(chalk.green(` 📎 첨부: ${path.basename(p)} (${sizeKb}KB)`));
294
+ // iTerm2 / kitty / WezTerm 이면 작은 썸네일을 바로 보여준다.
295
+ await printInlineThumbnail(path.resolve(p), { heightCells: 6 });
296
+ } catch (e) {
297
+ console.log(chalk.red(' 이미지 읽기 실패: ' + (e?.message ?? e)));
298
+ }
299
+ };
255
300
 
256
301
  const rl = readline.createInterface({
257
302
  input: process.stdin,
@@ -281,7 +326,50 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
281
326
  rl.close();
282
327
  return;
283
328
  }
284
- history.push({ role: 'user', content: line });
329
+ // 이미지 첨부 관련 슬래시 명령 — plain 모드에서도 지원.
330
+ if (line.startsWith('/image')) {
331
+ await addImage(line.slice('/image'.length).trim());
332
+ ask();
333
+ continue;
334
+ }
335
+ if (line === '/paste') {
336
+ try {
337
+ const file = await pasteClipboardImage();
338
+ await addImage(file);
339
+ } catch (e) {
340
+ console.log(chalk.red(' ' + (e?.message ?? e)));
341
+ }
342
+ ask();
343
+ continue;
344
+ }
345
+ if (line === '/attachments') {
346
+ printAttachments();
347
+ ask();
348
+ continue;
349
+ }
350
+ if (line === '/clear-attach') {
351
+ pendingAttachments = [];
352
+ console.log(chalk.dim(' 첨부를 비웠습니다.'));
353
+ ask();
354
+ continue;
355
+ }
356
+
357
+ // 일반 메시지 — 첨부가 있으면 멀티모달 content 로 구성.
358
+ if (pendingAttachments.length) {
359
+ const parts = [{ type: 'text', text: line }];
360
+ try {
361
+ for (const att of pendingAttachments) {
362
+ parts.push(await imagePartFromFile(att.path));
363
+ }
364
+ history.push({ role: 'user', content: parts });
365
+ } catch (e) {
366
+ console.log(chalk.red(' 첨부 처리 실패: ' + (e?.message ?? e)));
367
+ history.push({ role: 'user', content: line });
368
+ }
369
+ pendingAttachments = [];
370
+ } else {
371
+ history.push({ role: 'user', content: line });
372
+ }
285
373
  rl.pause();
286
374
 
287
375
  const projectRoot = cfg.paths.projectFile
@@ -337,7 +425,14 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
337
425
 
338
426
  // 세션 자동 저장 — readline 모드에서도 끊김 대비.
339
427
  if (session) {
340
- session.messages = history.map((h) => ({ role: h.role, text: h.content }));
428
+ session.messages = history.map((h) => {
429
+ // 멀티모달(content 가 배열) 인 경우 text 파트만 추출해 저장 (이미지 바이트는 세션에 안 남김).
430
+ if (Array.isArray(h.content)) {
431
+ const textPart = h.content.find((p) => p.type === 'text');
432
+ return { role: h.role, text: textPart?.text ?? '[이미지 첨부]' };
433
+ }
434
+ return { role: h.role, text: h.content };
435
+ });
341
436
  try {
342
437
  await saveSession(session);
343
438
  } catch {
@@ -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
+ }