byuckchon-frontend-cli 1.6.0 → 1.7.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
@@ -74,8 +74,11 @@ bc init
74
74
  | `read_file` | 프로젝트 내 파일/디렉터리 내용 읽기 |
75
75
  | `list_files` | 글롭 패턴으로 파일 나열 |
76
76
  | `search_code` | RAG 인덱스 의미 기반 검색 (인덱스 있어야 함) |
77
+ | `search_openapi` | OpenAPI 스펙에서 엔드포인트 검색 (path/summary/tag) — 큰 스펙도 OK |
78
+ | `get_openapi_endpoint` | 특정 엔드포인트 상세 (params/requestBody/responses, `$ref` 인라인) |
77
79
  | `write_file` | 새 파일 생성 또는 통째 덮어쓰기 |
78
80
  | `edit_file` | 유일한 `old_string → new_string` 으로 부분 수정 (안전) |
81
+ | `fetch_figma` / `fetch_figma_image` / `fetch_figma_styles` | Figma 디자인/이미지/토큰 |
79
82
 
80
83
  모델은 한 턴 안에서 **최대 12 step** 까지 툴을 자유롭게 호출합니다. 일반적인 흐름:
81
84
  1. `list_files` 로 `src/api/` 구조 파악
@@ -264,12 +267,20 @@ TTY 안에서 자동으로 ink 모드로 뜨고, 파이프/CI 같은 비-TTY 환
264
267
  이미지 첨부는 png / jpg / jpeg / gif / webp 만 지원하며,
265
268
  Claude / GPT 비전 모델에 멀티파트 메시지로 전달됩니다.
266
269
 
267
- **이미지 첨부 3가지 방법:**
270
+ **이미지 첨부 3가지 방법:** (ink·plain 모드 모두 지원 — v1.6.1+)
268
271
  1. `/image ./shot.png` — 경로 직접
269
272
  2. **드래그 & 드롭** — `/image ` 까지 입력 후, Finder 에서 파일을 터미널 위로 끌어다 놓으면 절대경로가 자동 입력됩니다. Enter.
270
- 3. `/paste` — **macOS 한정**, 클립보드의 이미지(예: `Cmd+Shift+4` 스크린샷)를 바로 첨부.
273
+ 3. `/paste` — **macOS 한정**, 클립보드의 이미지(예: `Cmd+Shift+4` 스크린샷 또는 Finder 에서 `Cmd+C` 한 이미지)를 바로 첨부.
271
274
  - 사전에 `brew install pngpaste` 한 번 필요.
272
275
 
276
+ > **터미널에서 `Cmd+V` 로 직접 붙이기는 왜 안 되나?** 터미널 앱은 클립보드의 "이미지 바이트" 를
277
+ > 앱에 전달하지 않고 텍스트만 줍니다 (OS/터미널 공통 제약). 그래서 클립보드 이미지를 붙이려면
278
+ > `/paste` 가 `pngpaste` 로 클립보드를 직접 읽어 첨부합니다 — `Cmd+C` → 입력창에 `/paste` → Enter.
279
+
280
+ > **썸네일 미리보기:** iTerm2 · kitty · WezTerm 에서는 plain 모드에서 첨부 직후 작은 썸네일이
281
+ > 인라인으로 표시됩니다. 그 외 터미널은 파일명 + 용량만 표시됩니다 (터미널이 이미지 렌더링을
282
+ > 지원하지 않기 때문).
283
+
273
284
  ### 한글 입력이 자꾸 씹힐 때 (v1.6+)
274
285
 
275
286
  `ink` 의 TextInput 은 macOS 한글 IME 의 조합 단계와 충돌해 글자가 한 박자 늦게 보이거나
@@ -342,7 +353,9 @@ bc config set-ui ink # 풀 TUI 복귀
342
353
  - [x] v1.4.1 — `deepMerge(null, obj)` TypeError 수정 (`bc adopt` 한 프로젝트에서 모든 명령이 터지던 버그)
343
354
  - [x] v1.5.0 — 에이전트 모드 (read/list/search/write/edit 툴) — AI 가 실제 파일을 만든다
344
355
  - [x] v1.6.0 — Figma 툴 (fetch_figma / image / styles), 한글 IME 안정 plain 모드 (`bc config set-ui plain`)
345
- - [ ] v1.7.0write/edit 승인 게이트 (`y/n/v/q`), diff 미리보기
356
+ - [x] v1.6.1 스키마 `jsonSchema()` 래핑 (`schema is not a function` 수정), plain 모드 이미지 첨부(`/image`·`/paste`) + iTerm2/kitty 인라인 썸네일
357
+ - [x] v1.7.0 — OpenAPI 검색 툴 (`search_openapi` / `get_openapi_endpoint`) — 큰 스펙(수백 엔드포인트)에서도 정확한 경로/스키마 조회. 요약도 path 당 1줄로 압축 + 한도 상향
358
+ - [ ] v1.8.0 — write/edit 승인 게이트 (`y/n/v/q`), diff 미리보기
346
359
  - [ ] Phase 3c-2: Figma 실 fetch (URL → 노드 트리 → 컴포넌트 인텐트)
347
360
  - [ ] Phase 4: `bc gen component/page` (AST 편집 + 검증 루프), `/apply` diff 미리보기
348
361
 
package/bin/index.js CHANGED
@@ -28,7 +28,7 @@ const program = new Command();
28
28
  program
29
29
  .name('bc')
30
30
  .description('Byuckchon Frontend Workbench — 프로젝트 스타터 + AI 어시스턴트')
31
- .version('1.6.0');
31
+ .version('1.7.0');
32
32
 
33
33
  program
34
34
  .command('init')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "byuckchon-frontend-cli",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
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
+ }
package/src/ai/tools.js CHANGED
@@ -2,7 +2,7 @@ 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
8
  import {
@@ -12,6 +12,8 @@ import {
12
12
  } from '../figma/api.js';
13
13
  import { simplifyFetchNodes } from '../figma/simplify.js';
14
14
  import { parseFigmaUrl } from '../figma/url.js';
15
+ import { getCachedOpenApi } from '../openapi/cache.js';
16
+ import { searchEndpoints, getEndpoint } from '../openapi/lookup.js';
15
17
 
16
18
  /**
17
19
  * Agentic chat 용 툴 정의.
@@ -27,9 +29,21 @@ import { parseFigmaUrl } from '../figma/url.js';
27
29
  * const tools = buildTools({ projectRoot, effective, onEvent });
28
30
  * streamText({ tools, stopWhen: stepCountIs(12), ... });
29
31
  */
30
- export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
32
+ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapiSource = null }) {
31
33
  const root = path.resolve(projectRoot);
32
34
 
35
+ // OpenAPI 스펙은 한 번만 로드해서 캐시 (큰 파일이라 반복 파싱 비쌈).
36
+ let _openapiDocPromise = null;
37
+ function loadOpenApiDoc() {
38
+ if (!openapiSource) return Promise.resolve(null);
39
+ if (!_openapiDocPromise) {
40
+ _openapiDocPromise = getCachedOpenApi(openapiSource)
41
+ .then((res) => res.doc ?? null)
42
+ .catch(() => null);
43
+ }
44
+ return _openapiDocPromise;
45
+ }
46
+
33
47
  function safePath(p) {
34
48
  if (!p || typeof p !== 'string') {
35
49
  throw new Error('path 가 비어있습니다');
@@ -171,6 +185,44 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
171
185
  return { ok: true, path: rel, action: 'edited' };
172
186
  }
173
187
 
188
+ // ─────────── OpenAPI 툴 ───────────
189
+
190
+ async function searchOpenApi({ query, limit = 40 }) {
191
+ const doc = await loadOpenApiDoc();
192
+ if (!doc) {
193
+ return {
194
+ ok: false,
195
+ error:
196
+ 'OpenAPI 스펙을 불러올 수 없습니다. bc.config.json 의 api.openapi 가 올바른 JSON 스펙 URL 인지 확인하세요 ' +
197
+ '(NestJS 는 보통 /api/docs 가 아니라 /api/docs-json).',
198
+ };
199
+ }
200
+ const hits = searchEndpoints(doc, query, { limit });
201
+ return {
202
+ ok: true,
203
+ query,
204
+ count: hits.length,
205
+ endpoints: hits.map((e) => ({
206
+ method: e.method,
207
+ path: e.path,
208
+ summary: e.summary,
209
+ tags: e.tags,
210
+ })),
211
+ hint:
212
+ hits.length === 0
213
+ ? '매치 없음. 다른 키워드로 재시도하거나, query 를 비워 전체 목록을 받아 path 를 직접 고르세요.'
214
+ : '상세 스키마가 필요하면 get_openapi_endpoint(path, method) 를 호출하세요.',
215
+ };
216
+ }
217
+
218
+ async function getOpenApiEndpoint({ path: epPath, method }) {
219
+ const doc = await loadOpenApiDoc();
220
+ if (!doc) {
221
+ return { ok: false, error: 'OpenAPI 스펙을 불러올 수 없습니다.' };
222
+ }
223
+ return getEndpoint(doc, epPath, method);
224
+ }
225
+
174
226
  // ─────────── Figma 툴 ───────────
175
227
 
176
228
  async function fetchFigma({ url, depth = 4 }) {
@@ -260,31 +312,31 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
260
312
  read_file: tool({
261
313
  description:
262
314
  '프로젝트 안의 파일이나 디렉터리 내용을 읽는다. 코드 짜기 전에 반드시 기존 코드 컨벤션을 먼저 읽어볼 것.',
263
- inputSchema: {
315
+ inputSchema: jsonSchema({
264
316
  type: 'object',
265
317
  properties: { path: { type: 'string', description: '프로젝트 루트 기준 상대 경로' } },
266
318
  required: ['path'],
267
319
  additionalProperties: false,
268
- },
320
+ }),
269
321
  execute: readFile,
270
322
  }),
271
323
  list_files: tool({
272
324
  description:
273
325
  '글롭 패턴으로 파일을 나열. 폴더 구조 파악, 비슷한 모듈 위치 찾기에 사용. 예: "src/api/**/*.ts"',
274
- inputSchema: {
326
+ inputSchema: jsonSchema({
275
327
  type: 'object',
276
328
  properties: {
277
329
  pattern: { type: 'string', default: '**/*' },
278
330
  limit: { type: 'number', default: 100 },
279
331
  },
280
332
  additionalProperties: false,
281
- },
333
+ }),
282
334
  execute: listFiles,
283
335
  }),
284
336
  search_code: tool({
285
337
  description:
286
338
  '코드베이스를 의미 기반(임베딩)으로 검색. "fetch 래퍼 패턴", "useQuery hook 컨벤션" 같이 자연어로 찾기. 인덱스가 없으면 에러.',
287
- inputSchema: {
339
+ inputSchema: jsonSchema({
288
340
  type: 'object',
289
341
  properties: {
290
342
  query: { type: 'string' },
@@ -292,13 +344,47 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
292
344
  },
293
345
  required: ['query'],
294
346
  additionalProperties: false,
295
- },
347
+ }),
296
348
  execute: searchCode,
297
349
  }),
350
+ search_openapi: tool({
351
+ description:
352
+ '연결된 OpenAPI(Swagger) 스펙에서 엔드포인트를 검색한다. path 일부("admin/inquiries"), ' +
353
+ '한글 summary("문의"), tag, operationId 어느 걸로도 검색 가능. ' +
354
+ 'API 코드를 짜기 전에 반드시 이 툴로 정확한 경로/메서드를 먼저 확인할 것. ' +
355
+ '시스템 프롬프트의 요약은 잘려 있을 수 있으므로 "엔드포인트가 안 보인다" 싶으면 이 툴로 찾는다.',
356
+ inputSchema: jsonSchema({
357
+ type: 'object',
358
+ properties: {
359
+ query: { type: 'string', description: '검색어 (path 일부/summary/tag). 비우면 전체 목록' },
360
+ limit: { type: 'number', default: 40 },
361
+ },
362
+ additionalProperties: false,
363
+ }),
364
+ execute: searchOpenApi,
365
+ }),
366
+ get_openapi_endpoint: tool({
367
+ description:
368
+ '특정 엔드포인트의 상세(parameters / requestBody / responses 스키마, $ref 인라인됨) 를 가져온다. ' +
369
+ 'search_openapi 로 찾은 정확한 path 와 method 를 넘긴다. 이 결과로 zod 스키마/타입/요청 함수를 정확히 생성.',
370
+ inputSchema: jsonSchema({
371
+ type: 'object',
372
+ properties: {
373
+ path: { type: 'string', description: '정확한 경로. 예: /api/admin/inquiries' },
374
+ method: {
375
+ type: 'string',
376
+ description: 'GET/POST/... 생략 시 해당 path 의 모든 메서드',
377
+ },
378
+ },
379
+ required: ['path'],
380
+ additionalProperties: false,
381
+ }),
382
+ execute: getOpenApiEndpoint,
383
+ }),
298
384
  write_file: tool({
299
385
  description:
300
386
  '새 파일을 만들거나 기존 파일을 통째로 덮어쓴다. 새 파일을 만들기 전에 반드시 1) 비슷한 기존 파일을 read_file 로 보고 2) 같은 폴더 컨벤션(barrel 파일, 네이밍, import 순서) 을 따른다.',
301
- inputSchema: {
387
+ inputSchema: jsonSchema({
302
388
  type: 'object',
303
389
  properties: {
304
390
  path: { type: 'string' },
@@ -306,13 +392,13 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
306
392
  },
307
393
  required: ['path', 'content'],
308
394
  additionalProperties: false,
309
- },
395
+ }),
310
396
  execute: writeFile,
311
397
  }),
312
398
  edit_file: tool({
313
399
  description:
314
400
  '기존 파일에서 old_string 을 찾아 new_string 으로 정확히 1번 치환. old_string 은 파일 안에서 유일해지도록 충분한 컨텍스트(앞뒤 줄) 를 포함시킬 것. 여러 번 등장하면 에러로 거부.',
315
- inputSchema: {
401
+ inputSchema: jsonSchema({
316
402
  type: 'object',
317
403
  properties: {
318
404
  path: { type: 'string' },
@@ -321,7 +407,7 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
321
407
  },
322
408
  required: ['path', 'old_string', 'new_string'],
323
409
  additionalProperties: false,
324
- },
410
+ }),
325
411
  execute: editFile,
326
412
  }),
327
413
  fetch_figma: tool({
@@ -330,7 +416,7 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
330
416
  '간소화된 디자인 정보(autoLayout, fills, text, size, children 등) 를 반환. ' +
331
417
  '없으면 파일 페이지 목록만. 컴포넌트/페이지 생성 요청을 받으면 이 툴을 먼저 호출해서 ' +
332
418
  '디자인 의도를 학습한 뒤 코드를 짠다.',
333
- inputSchema: {
419
+ inputSchema: jsonSchema({
334
420
  type: 'object',
335
421
  properties: {
336
422
  url: { type: 'string', description: 'Figma share/copy link' },
@@ -338,14 +424,14 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
338
424
  },
339
425
  required: ['url'],
340
426
  additionalProperties: false,
341
- },
427
+ }),
342
428
  execute: fetchFigma,
343
429
  }),
344
430
  fetch_figma_image: tool({
345
431
  description:
346
432
  'Figma 프레임을 PNG/JPG/SVG 이미지로 export. savePath 를 주면 프로젝트 폴더 안에 파일로 저장 ' +
347
433
  '(스토리북 배경, public asset 등). 안 주면 임시 URL 만 반환.',
348
- inputSchema: {
434
+ inputSchema: jsonSchema({
349
435
  type: 'object',
350
436
  properties: {
351
437
  url: { type: 'string' },
@@ -355,19 +441,19 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
355
441
  },
356
442
  required: ['url'],
357
443
  additionalProperties: false,
358
- },
444
+ }),
359
445
  execute: fetchFigmaImage,
360
446
  }),
361
447
  fetch_figma_styles: tool({
362
448
  description:
363
449
  'Figma 파일의 로컬 스타일(컬러/타이포/이펙트 토큰) 목록을 가져온다. 디자인 토큰 추출 / ' +
364
450
  'Tailwind 테마 설정 / theme.ts 생성 시 사용.',
365
- inputSchema: {
451
+ inputSchema: jsonSchema({
366
452
  type: 'object',
367
453
  properties: { url: { type: 'string' } },
368
454
  required: ['url'],
369
455
  additionalProperties: false,
370
- },
456
+ }),
371
457
  execute: fetchFigmaStylesTool,
372
458
  }),
373
459
  };
@@ -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,
@@ -83,11 +86,15 @@ export async function chatCommand(opts = {}) {
83
86
  };
84
87
  system =
85
88
  baseSystem +
86
- '\n\n---\nOpenAPI 스펙 (자동 주입됨, 사용자가 별도로 명령 없이 알아서 사용 가능):\n' +
89
+ '\n\n---\nOpenAPI 스펙 개요 (아래 목록은 길면 잘려 있을 있음):\n' +
87
90
  summary +
88
- '\n\n사용자가 API 관련 코드를 요청하면 스펙을 우선 참조하라. ' +
89
- '응답 타입이 필요하면 \`bc gen api-types\` 실행을 권하거나, ' +
90
- '이미 \`*.gen.ts\` 있으면 그걸 import 해서 쓰라고 안내한다.';
91
+ '\n\n**중요**: 목록은 일부만 보일 있다. 사용자가 특정 엔드포인트를 말하거나 ' +
92
+ ' 목록에서 보이면, 추측하지 말고 반드시 `search_openapi(query)` 로 정확한 ' +
93
+ 'path/method 검색한 `get_openapi_endpoint(path, method)` 상세 스키마를 ' +
94
+ '가져와서 zod/타입/요청 함수를 만든다. ' +
95
+ '예: 사용자가 "inquiries" 라고 하면 search_openapi("inquiries") 로 ' +
96
+ '`/api/admin/inquiries` 같은 실제 경로를 찾아낸다. ' +
97
+ '이미 `*.gen.ts` 가 있으면 그걸 import 해서 쓰는 것도 좋다.';
91
98
  }
92
99
  } catch {
93
100
  /* 비정상 URL/네트워크 실패 — 무시하고 계속. */
@@ -201,6 +208,7 @@ async function runOnce({ cfg, resolved, system, prompt }) {
201
208
  const tools = buildTools({
202
209
  projectRoot,
203
210
  effective: cfg.effective,
211
+ openapiSource: cfg.effective.api?.openapi ?? null,
204
212
  onEvent: (ev) => {
205
213
  const label =
206
214
  ev.kind === 'write_created'
@@ -254,7 +262,46 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
254
262
  if (session?.messages?.length) {
255
263
  console.log(chalk.dim(` 세션: ${session.id} (${session.messages.length} turns 이어가기)`));
256
264
  }
257
- console.log(chalk.dim(' /exit 또는 Ctrl+D 로 종료\n'));
265
+ console.log(
266
+ chalk.dim(' /image <경로> · /paste(클립보드 이미지) · /clear-attach · /exit\n'),
267
+ );
268
+
269
+ // 다음 메시지에 함께 보낼 이미지 첨부 목록.
270
+ let pendingAttachments = [];
271
+
272
+ const printAttachments = () => {
273
+ if (!pendingAttachments.length) {
274
+ console.log(chalk.dim(' 첨부 없음.'));
275
+ return;
276
+ }
277
+ console.log(chalk.dim(' 현재 첨부:'));
278
+ for (const a of pendingAttachments) {
279
+ console.log(chalk.dim(` - ${path.basename(a.path)} (${a.sizeKb}KB)`));
280
+ }
281
+ };
282
+
283
+ const addImage = async (rawPath) => {
284
+ const p = cleanDroppedPath(rawPath);
285
+ if (!p) {
286
+ console.log(chalk.red(' 사용법: /image <파일 경로>'));
287
+ return;
288
+ }
289
+ if (!isImagePath(p)) {
290
+ console.log(chalk.red(' 지원 확장자: png, jpg, jpeg, gif, webp'));
291
+ return;
292
+ }
293
+ try {
294
+ const { default: fsp } = await import('node:fs/promises');
295
+ const stat = await fsp.stat(path.resolve(p));
296
+ const sizeKb = Math.max(1, Math.round(stat.size / 1024));
297
+ pendingAttachments.push({ kind: 'image', path: path.resolve(p), sizeKb });
298
+ console.log(chalk.green(` 📎 첨부: ${path.basename(p)} (${sizeKb}KB)`));
299
+ // iTerm2 / kitty / WezTerm 이면 작은 썸네일을 바로 보여준다.
300
+ await printInlineThumbnail(path.resolve(p), { heightCells: 6 });
301
+ } catch (e) {
302
+ console.log(chalk.red(' 이미지 읽기 실패: ' + (e?.message ?? e)));
303
+ }
304
+ };
258
305
 
259
306
  const rl = readline.createInterface({
260
307
  input: process.stdin,
@@ -284,7 +331,50 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
284
331
  rl.close();
285
332
  return;
286
333
  }
287
- history.push({ role: 'user', content: line });
334
+ // 이미지 첨부 관련 슬래시 명령 — plain 모드에서도 지원.
335
+ if (line.startsWith('/image')) {
336
+ await addImage(line.slice('/image'.length).trim());
337
+ ask();
338
+ continue;
339
+ }
340
+ if (line === '/paste') {
341
+ try {
342
+ const file = await pasteClipboardImage();
343
+ await addImage(file);
344
+ } catch (e) {
345
+ console.log(chalk.red(' ' + (e?.message ?? e)));
346
+ }
347
+ ask();
348
+ continue;
349
+ }
350
+ if (line === '/attachments') {
351
+ printAttachments();
352
+ ask();
353
+ continue;
354
+ }
355
+ if (line === '/clear-attach') {
356
+ pendingAttachments = [];
357
+ console.log(chalk.dim(' 첨부를 비웠습니다.'));
358
+ ask();
359
+ continue;
360
+ }
361
+
362
+ // 일반 메시지 — 첨부가 있으면 멀티모달 content 로 구성.
363
+ if (pendingAttachments.length) {
364
+ const parts = [{ type: 'text', text: line }];
365
+ try {
366
+ for (const att of pendingAttachments) {
367
+ parts.push(await imagePartFromFile(att.path));
368
+ }
369
+ history.push({ role: 'user', content: parts });
370
+ } catch (e) {
371
+ console.log(chalk.red(' 첨부 처리 실패: ' + (e?.message ?? e)));
372
+ history.push({ role: 'user', content: line });
373
+ }
374
+ pendingAttachments = [];
375
+ } else {
376
+ history.push({ role: 'user', content: line });
377
+ }
288
378
  rl.pause();
289
379
 
290
380
  const projectRoot = cfg.paths.projectFile
@@ -293,6 +383,7 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
293
383
  const tools = buildTools({
294
384
  projectRoot,
295
385
  effective: cfg.effective,
386
+ openapiSource: cfg.effective.api?.openapi ?? null,
296
387
  onEvent: (ev) => {
297
388
  const label =
298
389
  ev.kind === 'write_created'
@@ -340,7 +431,14 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
340
431
 
341
432
  // 세션 자동 저장 — readline 모드에서도 끊김 대비.
342
433
  if (session) {
343
- session.messages = history.map((h) => ({ role: h.role, text: h.content }));
434
+ session.messages = history.map((h) => {
435
+ // 멀티모달(content 가 배열) 인 경우 text 파트만 추출해 저장 (이미지 바이트는 세션에 안 남김).
436
+ if (Array.isArray(h.content)) {
437
+ const textPart = h.content.find((p) => p.type === 'text');
438
+ return { role: h.role, text: textPart?.text ?? '[이미지 첨부]' };
439
+ }
440
+ return { role: h.role, text: h.content };
441
+ });
344
442
  try {
345
443
  await saveSession(session);
346
444
  } catch {
@@ -0,0 +1,135 @@
1
+ /**
2
+ * OpenAPI 스펙을 "쿼리" 하는 헬퍼.
3
+ *
4
+ * 큰 스펙(수백 개 엔드포인트, 수백 KB) 은 시스템 프롬프트 요약에 다 담을 수도 없고,
5
+ * 캐시 파일을 통째로 모델에 읽힐 수도 없다(토큰 폭발). 그래서 모델이 필요한 부분만
6
+ * 골라 가져갈 수 있게 검색/상세조회 함수를 제공한다.
7
+ *
8
+ * - searchEndpoints: path/summary/tag/operationId 부분일치로 후보 나열
9
+ * - getEndpoint: 특정 path(+method) 의 상세를 $ref 해석해서 반환
10
+ */
11
+ const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
12
+
13
+ /** "#/components/schemas/Foo" → doc.components.schemas.Foo */
14
+ function resolveRef(doc, ref, seen = new Set()) {
15
+ if (typeof ref !== 'string' || !ref.startsWith('#/')) return null;
16
+ const parts = ref.slice(2).split('/');
17
+ let cur = doc;
18
+ for (const p of parts) {
19
+ if (cur == null) return null;
20
+ cur = cur[p];
21
+ }
22
+ return cur ?? null;
23
+ }
24
+
25
+ /**
26
+ * 객체 안의 $ref 를 재귀적으로 해석해 인라인한다.
27
+ * 깊이/순환 방지: maxDepth, seen(ref 경로) 로 컷.
28
+ */
29
+ function deref(doc, node, { maxDepth = 6, depth = 0, seen = new Set() } = {}) {
30
+ if (node == null || typeof node !== 'object') return node;
31
+ if (depth > maxDepth) return node;
32
+
33
+ if (node.$ref) {
34
+ if (seen.has(node.$ref)) return { $ref: node.$ref, note: '(순환 참조 생략)' };
35
+ const target = resolveRef(doc, node.$ref);
36
+ if (!target) return node;
37
+ const nextSeen = new Set(seen);
38
+ nextSeen.add(node.$ref);
39
+ return deref(doc, target, { maxDepth, depth: depth + 1, seen: nextSeen });
40
+ }
41
+
42
+ if (Array.isArray(node)) {
43
+ return node.map((n) => deref(doc, n, { maxDepth, depth: depth + 1, seen }));
44
+ }
45
+
46
+ const out = {};
47
+ for (const [k, v] of Object.entries(node)) {
48
+ out[k] = deref(doc, v, { maxDepth, depth: depth + 1, seen });
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /** 모든 엔드포인트의 가벼운 인덱스 (path, method, summary, tags, operationId). */
54
+ export function listEndpoints(doc) {
55
+ const out = [];
56
+ for (const [pathStr, methods] of Object.entries(doc?.paths ?? {})) {
57
+ if (!methods || typeof methods !== 'object') continue;
58
+ for (const m of METHODS) {
59
+ const op = methods[m];
60
+ if (!op || typeof op !== 'object') continue;
61
+ out.push({
62
+ method: m.toUpperCase(),
63
+ path: pathStr,
64
+ summary: op.summary ?? '',
65
+ operationId: op.operationId ?? '',
66
+ tags: op.tags ?? [],
67
+ });
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /**
74
+ * 부분일치 검색. query 의 각 토큰이 path/summary/operationId/tag 어딘가에
75
+ * (대소문자 무시) 들어가면 매치. 점수 = 매치한 필드 수 + path 정확 포함 가산점.
76
+ */
77
+ export function searchEndpoints(doc, query, { limit = 40 } = {}) {
78
+ const all = listEndpoints(doc);
79
+ if (!query || !query.trim()) return all.slice(0, limit);
80
+
81
+ const tokens = query.toLowerCase().split(/[\s/]+/).filter(Boolean);
82
+ const scored = [];
83
+ for (const e of all) {
84
+ const hay = [
85
+ e.path.toLowerCase(),
86
+ e.summary.toLowerCase(),
87
+ e.operationId.toLowerCase(),
88
+ e.tags.join(' ').toLowerCase(),
89
+ ];
90
+ let score = 0;
91
+ for (const tok of tokens) {
92
+ if (hay[0].includes(tok)) score += 3; // path 매치 가중
93
+ else if (hay.some((h) => h.includes(tok))) score += 1;
94
+ }
95
+ if (e.path.toLowerCase().includes(query.toLowerCase())) score += 2;
96
+ if (score > 0) scored.push({ ...e, score });
97
+ }
98
+ scored.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
99
+ return scored.slice(0, limit);
100
+ }
101
+
102
+ /**
103
+ * 특정 path(+method) 의 상세. $ref 를 인라인해서 request/response 스키마까지 본다.
104
+ * method 생략 시 그 path 의 모든 메서드 반환.
105
+ */
106
+ export function getEndpoint(doc, targetPath, method) {
107
+ const methods = doc?.paths?.[targetPath];
108
+ if (!methods) {
109
+ // 끝 슬래시/대소문자 보정 한 번 시도
110
+ const found = Object.keys(doc?.paths ?? {}).find(
111
+ (p) => p.toLowerCase() === String(targetPath).toLowerCase(),
112
+ );
113
+ if (!found) return { ok: false, error: `해당 path 없음: ${targetPath}` };
114
+ targetPath = found;
115
+ }
116
+ const pathItem = doc.paths[targetPath];
117
+ const wanted = method ? [method.toLowerCase()] : METHODS;
118
+ const operations = {};
119
+ for (const m of wanted) {
120
+ const op = pathItem[m];
121
+ if (!op) continue;
122
+ operations[m.toUpperCase()] = {
123
+ summary: op.summary,
124
+ operationId: op.operationId,
125
+ tags: op.tags,
126
+ parameters: deref(doc, op.parameters),
127
+ requestBody: deref(doc, op.requestBody),
128
+ responses: deref(doc, op.responses),
129
+ };
130
+ }
131
+ if (Object.keys(operations).length === 0) {
132
+ return { ok: false, error: `${targetPath} 에 ${method ?? ''} 메서드 없음` };
133
+ }
134
+ return { ok: true, path: targetPath, operations };
135
+ }
@@ -14,8 +14,8 @@
14
14
  * ...
15
15
  */
16
16
  const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
17
- const MAX_ENDPOINTS = 200;
18
- const MAX_BYTES = 8 * 1024;
17
+ const MAX_PATHS = 400;
18
+ const MAX_BYTES = 16 * 1024;
19
19
 
20
20
  export function summarizeOpenApi(doc) {
21
21
  if (!doc || typeof doc !== 'object') return null;
@@ -30,46 +30,42 @@ export function summarizeOpenApi(doc) {
30
30
  lines.push(`base: ${servers.slice(0, 3).join(', ')}`);
31
31
  }
32
32
 
33
- const endpoints = [];
33
+ // path 하나당 한 줄로 메서드를 합친다 (줄 수 절반 + 모델이 path 단위로 보기 쉬움).
34
+ const byPath = [];
34
35
  for (const [pathStr, methods] of Object.entries(doc.paths ?? {})) {
35
36
  if (!methods || typeof methods !== 'object') continue;
36
- for (const m of METHODS) {
37
- const op = methods[m];
38
- if (!op || typeof op !== 'object') continue;
39
- const desc = op.summary || op.operationId || op.description || '';
40
- endpoints.push({
41
- method: m.toUpperCase(),
42
- path: pathStr,
43
- desc: desc.split('\n')[0].slice(0, 90),
44
- });
45
- }
37
+ const verbs = METHODS.filter((m) => methods[m] && typeof methods[m] === 'object').map((m) =>
38
+ m.toUpperCase(),
39
+ );
40
+ if (verbs.length === 0) continue;
41
+ byPath.push({ path: pathStr, verbs });
46
42
  }
43
+ byPath.sort((a, b) => a.path.localeCompare(b.path));
47
44
 
48
- // 너무 많으면 자른다 — 모델이 알아야 하는 건 "어떤 종류의 엔드포인트가 있다" 정도면 충분.
49
- const total = endpoints.length;
50
- const shown = endpoints.slice(0, MAX_ENDPOINTS);
45
+ const total = byPath.length;
46
+ const shown = byPath.slice(0, MAX_PATHS);
51
47
 
52
- lines.push(`endpoints: (${shown.length}/${total})`);
48
+ lines.push(`paths: (${shown.length}/${total})`);
53
49
  for (const e of shown) {
54
- lines.push(` ${e.method.padEnd(6)} ${e.path}${e.desc ? ' -- ' + e.desc : ''}`);
50
+ lines.push(` ${e.verbs.join(',').padEnd(20)} ${e.path}`);
55
51
  }
56
52
  if (shown.length < total) {
57
- lines.push(` ... and ${total - shown.length} more`);
53
+ lines.push(` ... and ${total - shown.length} more (search_openapi 로 검색하세요)`);
58
54
  }
59
55
 
60
56
  let result = lines.join('\n');
57
+ // 바이트 초과 시 뒤에서부터 잘라낸다 (search_openapi 가 있으니 전부 못 담아도 안전).
61
58
  if (Buffer.byteLength(result, 'utf8') > MAX_BYTES) {
62
- // 안전 장치 desc 자르고 다시.
63
- const compact = [
64
- lines[0],
65
- servers.length ? lines[1] : null,
66
- `endpoints: (${shown.length}/${total})`,
67
- ...shown.map((e) => ` ${e.method.padEnd(6)} ${e.path}`),
68
- shown.length < total ? ` ... and ${total - shown.length} more` : null,
69
- ]
70
- .filter(Boolean)
71
- .join('\n');
72
- result = compact;
59
+ const header = [lines[0], servers.length ? lines[1] : null].filter(Boolean);
60
+ const out = [...header, `paths: (truncated/${total}) — 전체는 search_openapi 로 검색`];
61
+ let bytes = Buffer.byteLength(out.join('\n'), 'utf8');
62
+ for (const e of shown) {
63
+ const line = ` ${e.verbs.join(',').padEnd(20)} ${e.path}`;
64
+ bytes += Buffer.byteLength(line + '\n', 'utf8');
65
+ if (bytes > MAX_BYTES) break;
66
+ out.push(line);
67
+ }
68
+ result = out.join('\n');
73
69
  }
74
70
  return result;
75
71
  }
package/src/ui/ChatApp.js CHANGED
@@ -731,6 +731,7 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
731
731
  const tools = buildTools({
732
732
  projectRoot,
733
733
  effective: cfg.effective,
734
+ openapiSource: cfg.effective.api?.openapi ?? null,
734
735
  onEvent: onToolEvent,
735
736
  });
736
737