byuckchon-frontend-cli 1.6.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
@@ -264,12 +264,20 @@ TTY 안에서 자동으로 ink 모드로 뜨고, 파이프/CI 같은 비-TTY 환
264
264
  이미지 첨부는 png / jpg / jpeg / gif / webp 만 지원하며,
265
265
  Claude / GPT 비전 모델에 멀티파트 메시지로 전달됩니다.
266
266
 
267
- **이미지 첨부 3가지 방법:**
267
+ **이미지 첨부 3가지 방법:** (ink·plain 모드 모두 지원 — v1.6.1+)
268
268
  1. `/image ./shot.png` — 경로 직접
269
269
  2. **드래그 & 드롭** — `/image ` 까지 입력 후, Finder 에서 파일을 터미널 위로 끌어다 놓으면 절대경로가 자동 입력됩니다. Enter.
270
- 3. `/paste` — **macOS 한정**, 클립보드의 이미지(예: `Cmd+Shift+4` 스크린샷)를 바로 첨부.
270
+ 3. `/paste` — **macOS 한정**, 클립보드의 이미지(예: `Cmd+Shift+4` 스크린샷 또는 Finder 에서 `Cmd+C` 한 이미지)를 바로 첨부.
271
271
  - 사전에 `brew install pngpaste` 한 번 필요.
272
272
 
273
+ > **터미널에서 `Cmd+V` 로 직접 붙이기는 왜 안 되나?** 터미널 앱은 클립보드의 "이미지 바이트" 를
274
+ > 앱에 전달하지 않고 텍스트만 줍니다 (OS/터미널 공통 제약). 그래서 클립보드 이미지를 붙이려면
275
+ > `/paste` 가 `pngpaste` 로 클립보드를 직접 읽어 첨부합니다 — `Cmd+C` → 입력창에 `/paste` → Enter.
276
+
277
+ > **썸네일 미리보기:** iTerm2 · kitty · WezTerm 에서는 plain 모드에서 첨부 직후 작은 썸네일이
278
+ > 인라인으로 표시됩니다. 그 외 터미널은 파일명 + 용량만 표시됩니다 (터미널이 이미지 렌더링을
279
+ > 지원하지 않기 때문).
280
+
273
281
  ### 한글 입력이 자꾸 씹힐 때 (v1.6+)
274
282
 
275
283
  `ink` 의 TextInput 은 macOS 한글 IME 의 조합 단계와 충돌해 글자가 한 박자 늦게 보이거나
@@ -342,6 +350,7 @@ bc config set-ui ink # 풀 TUI 복귀
342
350
  - [x] v1.4.1 — `deepMerge(null, obj)` TypeError 수정 (`bc adopt` 한 프로젝트에서 모든 명령이 터지던 버그)
343
351
  - [x] v1.5.0 — 에이전트 모드 (read/list/search/write/edit 툴) — AI 가 실제 파일을 만든다
344
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 인라인 썸네일
345
354
  - [ ] v1.7.0 — write/edit 승인 게이트 (`y/n/v/q`), diff 미리보기
346
355
  - [ ] Phase 3c-2: Figma 실 fetch (URL → 노드 트리 → 컴포넌트 인텐트)
347
356
  - [ ] Phase 4: `bc gen component/page` (AST 편집 + 검증 루프), `/apply` diff 미리보기
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.6.1');
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.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
+ }
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 {
@@ -260,31 +260,31 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
260
260
  read_file: tool({
261
261
  description:
262
262
  '프로젝트 안의 파일이나 디렉터리 내용을 읽는다. 코드 짜기 전에 반드시 기존 코드 컨벤션을 먼저 읽어볼 것.',
263
- inputSchema: {
263
+ inputSchema: jsonSchema({
264
264
  type: 'object',
265
265
  properties: { path: { type: 'string', description: '프로젝트 루트 기준 상대 경로' } },
266
266
  required: ['path'],
267
267
  additionalProperties: false,
268
- },
268
+ }),
269
269
  execute: readFile,
270
270
  }),
271
271
  list_files: tool({
272
272
  description:
273
273
  '글롭 패턴으로 파일을 나열. 폴더 구조 파악, 비슷한 모듈 위치 찾기에 사용. 예: "src/api/**/*.ts"',
274
- inputSchema: {
274
+ inputSchema: jsonSchema({
275
275
  type: 'object',
276
276
  properties: {
277
277
  pattern: { type: 'string', default: '**/*' },
278
278
  limit: { type: 'number', default: 100 },
279
279
  },
280
280
  additionalProperties: false,
281
- },
281
+ }),
282
282
  execute: listFiles,
283
283
  }),
284
284
  search_code: tool({
285
285
  description:
286
286
  '코드베이스를 의미 기반(임베딩)으로 검색. "fetch 래퍼 패턴", "useQuery hook 컨벤션" 같이 자연어로 찾기. 인덱스가 없으면 에러.',
287
- inputSchema: {
287
+ inputSchema: jsonSchema({
288
288
  type: 'object',
289
289
  properties: {
290
290
  query: { type: 'string' },
@@ -292,13 +292,13 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
292
292
  },
293
293
  required: ['query'],
294
294
  additionalProperties: false,
295
- },
295
+ }),
296
296
  execute: searchCode,
297
297
  }),
298
298
  write_file: tool({
299
299
  description:
300
300
  '새 파일을 만들거나 기존 파일을 통째로 덮어쓴다. 새 파일을 만들기 전에 반드시 1) 비슷한 기존 파일을 read_file 로 보고 2) 같은 폴더 컨벤션(barrel 파일, 네이밍, import 순서) 을 따른다.',
301
- inputSchema: {
301
+ inputSchema: jsonSchema({
302
302
  type: 'object',
303
303
  properties: {
304
304
  path: { type: 'string' },
@@ -306,13 +306,13 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
306
306
  },
307
307
  required: ['path', 'content'],
308
308
  additionalProperties: false,
309
- },
309
+ }),
310
310
  execute: writeFile,
311
311
  }),
312
312
  edit_file: tool({
313
313
  description:
314
314
  '기존 파일에서 old_string 을 찾아 new_string 으로 정확히 1번 치환. old_string 은 파일 안에서 유일해지도록 충분한 컨텍스트(앞뒤 줄) 를 포함시킬 것. 여러 번 등장하면 에러로 거부.',
315
- inputSchema: {
315
+ inputSchema: jsonSchema({
316
316
  type: 'object',
317
317
  properties: {
318
318
  path: { type: 'string' },
@@ -321,7 +321,7 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
321
321
  },
322
322
  required: ['path', 'old_string', 'new_string'],
323
323
  additionalProperties: false,
324
- },
324
+ }),
325
325
  execute: editFile,
326
326
  }),
327
327
  fetch_figma: tool({
@@ -330,7 +330,7 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
330
330
  '간소화된 디자인 정보(autoLayout, fills, text, size, children 등) 를 반환. ' +
331
331
  '없으면 파일 페이지 목록만. 컴포넌트/페이지 생성 요청을 받으면 이 툴을 먼저 호출해서 ' +
332
332
  '디자인 의도를 학습한 뒤 코드를 짠다.',
333
- inputSchema: {
333
+ inputSchema: jsonSchema({
334
334
  type: 'object',
335
335
  properties: {
336
336
  url: { type: 'string', description: 'Figma share/copy link' },
@@ -338,14 +338,14 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
338
338
  },
339
339
  required: ['url'],
340
340
  additionalProperties: false,
341
- },
341
+ }),
342
342
  execute: fetchFigma,
343
343
  }),
344
344
  fetch_figma_image: tool({
345
345
  description:
346
346
  'Figma 프레임을 PNG/JPG/SVG 이미지로 export. savePath 를 주면 프로젝트 폴더 안에 파일로 저장 ' +
347
347
  '(스토리북 배경, public asset 등). 안 주면 임시 URL 만 반환.',
348
- inputSchema: {
348
+ inputSchema: jsonSchema({
349
349
  type: 'object',
350
350
  properties: {
351
351
  url: { type: 'string' },
@@ -355,19 +355,19 @@ export function buildTools({ projectRoot, effective, onEvent = () => {} }) {
355
355
  },
356
356
  required: ['url'],
357
357
  additionalProperties: false,
358
- },
358
+ }),
359
359
  execute: fetchFigmaImage,
360
360
  }),
361
361
  fetch_figma_styles: tool({
362
362
  description:
363
363
  'Figma 파일의 로컬 스타일(컬러/타이포/이펙트 토큰) 목록을 가져온다. 디자인 토큰 추출 / ' +
364
364
  'Tailwind 테마 설정 / theme.ts 생성 시 사용.',
365
- inputSchema: {
365
+ inputSchema: jsonSchema({
366
366
  type: 'object',
367
367
  properties: { url: { type: 'string' } },
368
368
  required: ['url'],
369
369
  additionalProperties: false,
370
- },
370
+ }),
371
371
  execute: fetchFigmaStylesTool,
372
372
  }),
373
373
  };
@@ -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,
@@ -254,7 +257,46 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
254
257
  if (session?.messages?.length) {
255
258
  console.log(chalk.dim(` 세션: ${session.id} (${session.messages.length} turns 이어가기)`));
256
259
  }
257
- 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
+ };
258
300
 
259
301
  const rl = readline.createInterface({
260
302
  input: process.stdin,
@@ -284,7 +326,50 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
284
326
  rl.close();
285
327
  return;
286
328
  }
287
- 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
+ }
288
373
  rl.pause();
289
374
 
290
375
  const projectRoot = cfg.paths.projectFile
@@ -340,7 +425,14 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
340
425
 
341
426
  // 세션 자동 저장 — readline 모드에서도 끊김 대비.
342
427
  if (session) {
343
- 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
+ });
344
436
  try {
345
437
  await saveSession(session);
346
438
  } catch {