byuckchon-frontend-cli 1.5.0 → 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
@@ -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 시작할 때 알아서 준비됩니다.**
@@ -208,11 +270,25 @@ Claude / GPT 비전 모델에 멀티파트 메시지로 전달됩니다.
208
270
  3. `/paste` — **macOS 한정**, 클립보드의 이미지(예: `Cmd+Shift+4` 스크린샷)를 바로 첨부.
209
271
  - 사전에 `brew install pngpaste` 한 번 필요.
210
272
 
211
- ### 한글 입력이 보일
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
+ ```
212
290
 
213
- ink 터미널 커서를 숨겨버려서 macOS 한글 IME 의 조합 미리보기가 안 보이는 이슈가 있었습니다.
214
- v1.4 부터는 ink 시작 후 커서를 강제로 다시 켜고 가짜 커서를 끄는 방식으로 수정되어 정상 동작해야 합니다.
215
- 혹시 그래도 문제가 보이면 `bc chat --plain` 으로 readline 모드를 쓸 수 있습니다 (TUI 기능은 일부 제한).
291
+ 일회성으로 plain 쓰고 싶으면 `bc chat --plain`.
216
292
 
217
293
  ### `bc config` — 설정
218
294
 
@@ -224,6 +300,8 @@ bc config set-key anthropic # 키 안전 입력 (가려짐)
224
300
  bc config set-key anthropic sk-ant-... # 직접 지정
225
301
  bc config set-gateway https://ai.example.com # 사내 게이트웨이 모드
226
302
  bc config set-gateway # 게이트웨이 해제 (BYOK 모드)
303
+ bc config set-ui plain # 한글 IME 안정 모드
304
+ bc config set-ui ink # 풀 TUI 복귀
227
305
  ```
228
306
 
229
307
  ## 설정 위치
@@ -263,7 +341,8 @@ bc config set-gateway # 게이트웨이 해제 (BYOK 모
263
341
  - [x] Phase 3c-1: chat 시작 시 인덱스 자동 빌드, OpenAPI 자동 fetch+캐시+시스템 프롬프트 주입
264
342
  - [x] v1.4.1 — `deepMerge(null, obj)` TypeError 수정 (`bc adopt` 한 프로젝트에서 모든 명령이 터지던 버그)
265
343
  - [x] v1.5.0 — 에이전트 모드 (read/list/search/write/edit 툴) — AI 가 실제 파일을 만든다
266
- - [ ] v1.6.0 — write/edit 승인 게이트 (`y/n/v/q`), diff 미리보기
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 미리보기
267
346
  - [ ] Phase 3c-2: Figma 실 fetch (URL → 노드 트리 → 컴포넌트 인텐트)
268
347
  - [ ] Phase 4: `bc gen component/page` (AST 편집 + 검증 루프), `/apply` diff 미리보기
269
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.5.0');
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.5.0",
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": {
@@ -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
@@ -5,6 +5,13 @@ import fg from 'fast-glob';
5
5
  import { tool } 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,6 +171,91 @@ 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:
@@ -232,5 +324,51 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
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: {
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
+ }),
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
  }
@@ -133,8 +133,11 @@ export async function chatCommand(opts = {}) {
133
133
  await saveSession(session); // 빈 파일이라도 디스크에 만들어둠
134
134
 
135
135
  // ink 는 stdin/stdout 둘 다 TTY 이어야 정상 동작.
136
+ // - --plain 플래그가 명시되거나 비-TTY 면 readline 폴백.
137
+ // - 글로벌 ui.mode 가 "plain" 이면 한글 IME 가 깨지는 케이스를 자동 회피.
136
138
  const isTTY = process.stdin.isTTY && process.stdout.isTTY;
137
- if (!isTTY || opts.plain) {
139
+ const wantPlain = opts.plain || cfg.global?.ui?.mode === 'plain';
140
+ if (!isTTY || wantPlain) {
138
141
  return runReadlineFallback({ cfg, resolved, system, session, openapiInfo });
139
142
  }
140
143
 
@@ -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
+ }