byuckchon-frontend-cli 1.1.0 → 1.4.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 +223 -8
- package/bin/index.js +157 -1
- package/package.json +32 -6
- package/src/ai/messageContent.js +84 -0
- package/src/ai/models.js +66 -0
- package/src/ai/provider.js +54 -0
- package/src/ai/systemPrompt.js +75 -0
- package/src/ai/tokenMeter.js +65 -0
- package/src/commands/adopt.js +146 -0
- package/src/commands/chat.js +300 -0
- package/src/commands/config.js +142 -0
- package/src/commands/genCmd.js +61 -0
- package/src/commands/indexCmd.js +89 -0
- package/src/commands/init.js +19 -0
- package/src/config/index.js +188 -0
- package/src/context/detect.js +228 -0
- package/src/generators/createBcConfig.js +58 -0
- package/src/generators/createProject.js +2 -0
- package/src/history/store.js +117 -0
- package/src/indexer/chunker.js +42 -0
- package/src/indexer/embed.js +57 -0
- package/src/indexer/search.js +41 -0
- package/src/indexer/store.js +137 -0
- package/src/indexer/walker.js +45 -0
- package/src/openapi/cache.js +60 -0
- package/src/openapi/codegen.js +45 -0
- package/src/openapi/fetch.js +69 -0
- package/src/openapi/summary.js +75 -0
- package/src/prompts/initPrompts.js +24 -0
- package/src/ui/ChatApp.js +812 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { findProjectConfigPath } from '../config/index.js';
|
|
5
|
+
import { walkProjectFiles, relPath } from './walker.js';
|
|
6
|
+
import { chunkFile } from './chunker.js';
|
|
7
|
+
import { embedChunks, DEFAULT_EMBED_MODEL } from './embed.js';
|
|
8
|
+
|
|
9
|
+
const INDEX_VERSION = 1;
|
|
10
|
+
|
|
11
|
+
export async function getIndexPaths(cwd = process.cwd()) {
|
|
12
|
+
const projectConfig = await findProjectConfigPath(cwd);
|
|
13
|
+
const projectRoot = projectConfig ? path.dirname(projectConfig) : cwd;
|
|
14
|
+
const dir = path.join(projectRoot, '.bc', 'index');
|
|
15
|
+
return {
|
|
16
|
+
projectRoot,
|
|
17
|
+
dir,
|
|
18
|
+
manifest: path.join(dir, 'manifest.json'),
|
|
19
|
+
chunks: path.join(dir, 'chunks.json'),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function loadIndex(cwd = process.cwd()) {
|
|
24
|
+
const p = await getIndexPaths(cwd);
|
|
25
|
+
try {
|
|
26
|
+
const [manifestRaw, chunksRaw] = await Promise.all([
|
|
27
|
+
fs.readFile(p.manifest, 'utf8'),
|
|
28
|
+
fs.readFile(p.chunks, 'utf8'),
|
|
29
|
+
]);
|
|
30
|
+
return {
|
|
31
|
+
...JSON.parse(manifestRaw),
|
|
32
|
+
chunks: JSON.parse(chunksRaw),
|
|
33
|
+
_paths: p,
|
|
34
|
+
};
|
|
35
|
+
} catch (e) {
|
|
36
|
+
if (e.code === 'ENOENT') return null;
|
|
37
|
+
throw e;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function saveIndex(p, manifest, chunks) {
|
|
42
|
+
await fs.mkdir(p.dir, { recursive: true });
|
|
43
|
+
await fs.writeFile(p.chunks, JSON.stringify(chunks), 'utf8');
|
|
44
|
+
await fs.writeFile(
|
|
45
|
+
p.manifest,
|
|
46
|
+
JSON.stringify({ ...manifest, chunkCount: chunks.length }, null, 2) + '\n',
|
|
47
|
+
'utf8',
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 인덱스 빌드 — 파일 스캔, 청크 분할, 변경된 청크만 임베딩 후 저장.
|
|
53
|
+
*
|
|
54
|
+
* @param {object} opts
|
|
55
|
+
* @param {object} opts.effective loadEffectiveConfig().effective
|
|
56
|
+
* @param {object} opts.contextCfg bc.config.json 의 context (include/exclude)
|
|
57
|
+
* @param {boolean} opts.rebuild true 면 기존 임베딩 무시하고 전부 재계산
|
|
58
|
+
* @param {(s:string)=>void} opts.onProgress 진행 메시지 콜백
|
|
59
|
+
*/
|
|
60
|
+
export async function buildIndex({
|
|
61
|
+
effective,
|
|
62
|
+
contextCfg = {},
|
|
63
|
+
rebuild = false,
|
|
64
|
+
onProgress = () => {},
|
|
65
|
+
} = {}) {
|
|
66
|
+
const p = await getIndexPaths();
|
|
67
|
+
const { projectRoot } = p;
|
|
68
|
+
|
|
69
|
+
onProgress(' 파일 목록 수집 중…');
|
|
70
|
+
const files = await walkProjectFiles(projectRoot, contextCfg);
|
|
71
|
+
if (files.length === 0) {
|
|
72
|
+
return { ok: false, reason: '인덱싱할 파일이 없습니다. bc.config.json 의 context.include 를 확인하세요.' };
|
|
73
|
+
}
|
|
74
|
+
onProgress(` 파일 ${files.length}개 발견. 청크 분할 중…`);
|
|
75
|
+
|
|
76
|
+
const fileMaxBytes = 100 * 1024;
|
|
77
|
+
const allChunks = [];
|
|
78
|
+
for (const abs of files) {
|
|
79
|
+
let content;
|
|
80
|
+
try {
|
|
81
|
+
const stat = await fs.stat(abs);
|
|
82
|
+
if (stat.size > fileMaxBytes) continue; // 너무 큰 파일은 스킵
|
|
83
|
+
content = await fs.readFile(abs, 'utf8');
|
|
84
|
+
} catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const r = relPath(projectRoot, abs);
|
|
88
|
+
for (const c of chunkFile({ relPath: r, content })) allChunks.push(c);
|
|
89
|
+
}
|
|
90
|
+
onProgress(` 청크 ${allChunks.length}개 생성.`);
|
|
91
|
+
|
|
92
|
+
// 기존 인덱스에서 hash 일치하는 청크의 임베딩을 재사용 (증분 갱신).
|
|
93
|
+
let reused = 0;
|
|
94
|
+
let toEmbed = allChunks;
|
|
95
|
+
if (!rebuild) {
|
|
96
|
+
const existing = await loadIndex(projectRoot);
|
|
97
|
+
if (existing && existing.embeddingModel === DEFAULT_EMBED_MODEL) {
|
|
98
|
+
const byHash = new Map(existing.chunks.map((c) => [c.hash, c.embedding]));
|
|
99
|
+
toEmbed = [];
|
|
100
|
+
for (const c of allChunks) {
|
|
101
|
+
const cached = byHash.get(c.hash);
|
|
102
|
+
if (cached) {
|
|
103
|
+
c.embedding = cached;
|
|
104
|
+
reused++;
|
|
105
|
+
} else {
|
|
106
|
+
toEmbed.push(c);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
onProgress(` ${reused}개 재사용, ${toEmbed.length}개 신규 임베딩 필요.`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (toEmbed.length > 0) {
|
|
114
|
+
onProgress(` OpenAI 임베딩 중… (model=${DEFAULT_EMBED_MODEL})`);
|
|
115
|
+
const { makeEmbeddingModel } = await import('./embed.js');
|
|
116
|
+
const model = makeEmbeddingModel(effective);
|
|
117
|
+
const embedded = await embedChunks(model, toEmbed, {
|
|
118
|
+
onBatch: ({ done, total }) => onProgress(` 배치 진행: ${done}/${total}`),
|
|
119
|
+
});
|
|
120
|
+
// 새로 임베딩된 청크를 allChunks 에 반영 (hash 일치하는 자리로 머지).
|
|
121
|
+
const newByHash = new Map(embedded.map((e) => [e.hash, e.embedding]));
|
|
122
|
+
for (const c of allChunks) {
|
|
123
|
+
if (!c.embedding) c.embedding = newByHash.get(c.hash);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const manifest = {
|
|
128
|
+
version: INDEX_VERSION,
|
|
129
|
+
embeddingModel: DEFAULT_EMBED_MODEL,
|
|
130
|
+
builtAt: new Date().toISOString(),
|
|
131
|
+
fileCount: files.length,
|
|
132
|
+
reused,
|
|
133
|
+
newlyEmbedded: toEmbed.length - reused < 0 ? toEmbed.length : toEmbed.length,
|
|
134
|
+
};
|
|
135
|
+
await saveIndex(p, manifest, allChunks);
|
|
136
|
+
return { ok: true, manifest, paths: p };
|
|
137
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import fg from 'fast-glob';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* bc.config.json 의 context.include / context.exclude 글롭을 따라
|
|
7
|
+
* 인덱싱 대상 파일 절대경로 목록을 돌려준다.
|
|
8
|
+
*
|
|
9
|
+
* - 항상 node_modules / .bc / .git / dist / build / .next 는 제외
|
|
10
|
+
* - 100KB 넘는 파일은 인덱싱하지 않는다 (대부분 minified/generated)
|
|
11
|
+
*/
|
|
12
|
+
export async function walkProjectFiles(projectRoot, contextCfg = {}) {
|
|
13
|
+
const include =
|
|
14
|
+
contextCfg.include?.length
|
|
15
|
+
? contextCfg.include
|
|
16
|
+
: ['src/**/*.{ts,tsx,js,jsx}', 'app/**/*.{ts,tsx,js,jsx}', 'bc.config.json'];
|
|
17
|
+
|
|
18
|
+
const baseExclude = [
|
|
19
|
+
'**/node_modules/**',
|
|
20
|
+
'**/.bc/**',
|
|
21
|
+
'**/.git/**',
|
|
22
|
+
'**/dist/**',
|
|
23
|
+
'**/build/**',
|
|
24
|
+
'**/.next/**',
|
|
25
|
+
'**/.expo/**',
|
|
26
|
+
'**/coverage/**',
|
|
27
|
+
];
|
|
28
|
+
const userExclude = contextCfg.exclude ?? [];
|
|
29
|
+
|
|
30
|
+
const matches = await fg(include, {
|
|
31
|
+
cwd: projectRoot,
|
|
32
|
+
absolute: true,
|
|
33
|
+
onlyFiles: true,
|
|
34
|
+
ignore: [...baseExclude, ...userExclude],
|
|
35
|
+
dot: false,
|
|
36
|
+
suppressErrors: true,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// 중복 제거 (include 글롭이 겹칠 수 있음).
|
|
40
|
+
return Array.from(new Set(matches));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function relPath(projectRoot, abs) {
|
|
44
|
+
return path.relative(projectRoot, abs).split(path.sep).join('/');
|
|
45
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
import { findProjectConfigPath } from '../config/index.js';
|
|
6
|
+
import { loadOpenApi } from './fetch.js';
|
|
7
|
+
|
|
8
|
+
const TTL_MS = 60 * 60 * 1000; // 1시간
|
|
9
|
+
|
|
10
|
+
async function getCacheDir(cwd = process.cwd()) {
|
|
11
|
+
const projectConfig = await findProjectConfigPath(cwd);
|
|
12
|
+
const root = projectConfig ? path.dirname(projectConfig) : cwd;
|
|
13
|
+
return path.join(root, '.bc', 'cache');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function keyFor(input) {
|
|
17
|
+
return crypto.createHash('sha1').update(String(input)).digest('hex').slice(0, 12);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* URL 캐시가 있으면 그걸로, 없거나 만료되었으면 fetch 후 갱신.
|
|
22
|
+
* 결과는 { doc, cached, source } 형태.
|
|
23
|
+
*
|
|
24
|
+
* - 네트워크 실패 시: 만료된 캐시라도 있으면 그걸로 폴백 (offline-friendly).
|
|
25
|
+
* - 캐시는 .bc/cache/openapi-<hash>.json 에 저장.
|
|
26
|
+
*/
|
|
27
|
+
export async function getCachedOpenApi(input) {
|
|
28
|
+
const dir = await getCacheDir();
|
|
29
|
+
const file = path.join(dir, `openapi-${keyFor(input)}.json`);
|
|
30
|
+
|
|
31
|
+
let cached = null;
|
|
32
|
+
let cachedFresh = false;
|
|
33
|
+
try {
|
|
34
|
+
const stat = await fs.stat(file);
|
|
35
|
+
const raw = await fs.readFile(file, 'utf8');
|
|
36
|
+
cached = JSON.parse(raw);
|
|
37
|
+
cachedFresh = Date.now() - stat.mtimeMs < TTL_MS;
|
|
38
|
+
} catch {
|
|
39
|
+
/* miss */
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (cachedFresh && cached) {
|
|
43
|
+
return { doc: cached, cached: true, source: input };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const { doc } = await loadOpenApi(input);
|
|
48
|
+
if (doc) {
|
|
49
|
+
await fs.mkdir(dir, { recursive: true });
|
|
50
|
+
await fs.writeFile(file, JSON.stringify(doc), 'utf8');
|
|
51
|
+
return { doc, cached: false, source: input };
|
|
52
|
+
}
|
|
53
|
+
// doc 가 null (YAML) — 캐시 못 쓰지만 fetch 자체는 성공한 케이스
|
|
54
|
+
return { doc: null, cached: false, source: input };
|
|
55
|
+
} catch (err) {
|
|
56
|
+
// 네트워크/파싱 실패 — 만료된 캐시라도 있으면 그걸 돌려준다.
|
|
57
|
+
if (cached) return { doc: cached, cached: true, source: input, stale: true };
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import openapiTS, { astToString } from 'openapi-typescript';
|
|
5
|
+
|
|
6
|
+
const FILE_HEADER = `// AUTO-GENERATED by \`bc gen api-types\`
|
|
7
|
+
// Do not edit manually. Re-run \`bc gen api-types\` to refresh.
|
|
8
|
+
//
|
|
9
|
+
// Source: __SOURCE__
|
|
10
|
+
// Generated: __DATE__
|
|
11
|
+
|
|
12
|
+
`;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* OpenAPI 입력으로부터 TypeScript 타입 파일을 생성해 디스크에 쓴다.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} args
|
|
18
|
+
* @param {string|object} args.input URL / 파일 경로 / 이미 파싱된 OpenAPI 객체
|
|
19
|
+
* @param {string} args.outFile 절대/상대 경로
|
|
20
|
+
* @param {string} args.sourceLabel 파일 헤더 주석에 표기할 출처 문자열
|
|
21
|
+
* @returns {{ outFile: string, bytes: number }}
|
|
22
|
+
*/
|
|
23
|
+
export async function generateApiTypes({ input, outFile, sourceLabel }) {
|
|
24
|
+
// openapi-typescript v7: 입력은 URL 객체, 파싱된 객체, 또는 path 문자열을 받음.
|
|
25
|
+
// 우리가 받은 input 이 URL 문자열이면 URL 인스턴스로, 파일 경로면 그대로 넘김.
|
|
26
|
+
let sdkInput = input;
|
|
27
|
+
if (typeof input === 'string' && /^https?:\/\//.test(input)) {
|
|
28
|
+
sdkInput = new URL(input);
|
|
29
|
+
} else if (typeof input === 'string') {
|
|
30
|
+
sdkInput = path.resolve(input);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ast = await openapiTS(sdkInput);
|
|
34
|
+
const code = astToString(ast);
|
|
35
|
+
|
|
36
|
+
const header = FILE_HEADER
|
|
37
|
+
.replace('__SOURCE__', sourceLabel ?? String(input))
|
|
38
|
+
.replace('__DATE__', new Date().toISOString());
|
|
39
|
+
|
|
40
|
+
const final = header + code + (code.endsWith('\n') ? '' : '\n');
|
|
41
|
+
|
|
42
|
+
await fs.mkdir(path.dirname(outFile), { recursive: true });
|
|
43
|
+
await fs.writeFile(outFile, final, 'utf8');
|
|
44
|
+
return { outFile, bytes: Buffer.byteLength(final, 'utf8') };
|
|
45
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* OpenAPI 스펙을 URL/파일경로/JSON객체 중 무엇으로 받든 정상화해서
|
|
6
|
+
* `{ source, doc }` 로 돌려준다.
|
|
7
|
+
*
|
|
8
|
+
* - URL ('http://' / 'https://') → fetch 후 파싱
|
|
9
|
+
* - 파일 경로 (.json / .yaml / .yml) → 디스크에서 읽기
|
|
10
|
+
* - YAML 은 openapi-typescript 에 그대로 넘겨도 되지만, 우리가 endpoint 요약을
|
|
11
|
+
* 뽑을 땐 JSON 객체가 필요해서 yaml 파서가 필요함. 의존성 키우지 않도록
|
|
12
|
+
* YAML 인 경우 doc 필드는 null 로 두고 source(원본 문자열)만 반환한다.
|
|
13
|
+
* - 그 외 → 에러
|
|
14
|
+
*
|
|
15
|
+
* 반환된 source 는 openapi-typescript 의 입력으로 그대로 쓸 수 있다.
|
|
16
|
+
*/
|
|
17
|
+
export async function loadOpenApi(input) {
|
|
18
|
+
if (!input) {
|
|
19
|
+
const e = new Error('OpenAPI 입력이 비어있습니다 (URL 또는 파일 경로 필요).');
|
|
20
|
+
e.code = 'BC_NO_OPENAPI_INPUT';
|
|
21
|
+
throw e;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// URL?
|
|
25
|
+
if (/^https?:\/\//.test(input)) {
|
|
26
|
+
const res = await fetch(input);
|
|
27
|
+
if (!res.ok) {
|
|
28
|
+
const e = new Error(`OpenAPI fetch 실패: ${res.status} ${res.statusText} (${input})`);
|
|
29
|
+
e.code = 'BC_OPENAPI_FETCH_FAILED';
|
|
30
|
+
throw e;
|
|
31
|
+
}
|
|
32
|
+
const text = await res.text();
|
|
33
|
+
let doc = null;
|
|
34
|
+
try {
|
|
35
|
+
doc = JSON.parse(text);
|
|
36
|
+
} catch {
|
|
37
|
+
// 서버가 YAML 을 줄 수도 있다 — 객체 추출은 포기하지만 source(URL) 는 그대로.
|
|
38
|
+
}
|
|
39
|
+
return { source: input, text, doc };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 로컬 파일?
|
|
43
|
+
const abs = path.resolve(input);
|
|
44
|
+
let stat;
|
|
45
|
+
try {
|
|
46
|
+
stat = await fs.stat(abs);
|
|
47
|
+
} catch {
|
|
48
|
+
const e = new Error(`OpenAPI 파일을 찾을 수 없습니다: ${input}`);
|
|
49
|
+
e.code = 'BC_OPENAPI_FILE_NOT_FOUND';
|
|
50
|
+
throw e;
|
|
51
|
+
}
|
|
52
|
+
if (!stat.isFile()) {
|
|
53
|
+
const e = new Error(`OpenAPI 경로가 파일이 아닙니다: ${input}`);
|
|
54
|
+
e.code = 'BC_OPENAPI_NOT_A_FILE';
|
|
55
|
+
throw e;
|
|
56
|
+
}
|
|
57
|
+
const text = await fs.readFile(abs, 'utf8');
|
|
58
|
+
let doc = null;
|
|
59
|
+
if (abs.endsWith('.json')) {
|
|
60
|
+
try {
|
|
61
|
+
doc = JSON.parse(text);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
const e = new Error(`JSON 파싱 실패: ${err.message}`);
|
|
64
|
+
e.code = 'BC_OPENAPI_PARSE_FAILED';
|
|
65
|
+
throw e;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { source: abs, text, doc };
|
|
69
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 파싱된 OpenAPI 객체를 → 모델에 주입할 "압축 요약" 텍스트로 변환.
|
|
3
|
+
*
|
|
4
|
+
* 목표: 토큰 폭발 방지하면서도 모델이 "이 API 스펙이 있구나, 어떤 엔드포인트가
|
|
5
|
+
* 있구나" 를 알게 하는 정도. 자세한 스키마는 RAG 가 types.gen.ts 통해서 별도로
|
|
6
|
+
* 가져오게 함.
|
|
7
|
+
*
|
|
8
|
+
* 결과 형태 (예):
|
|
9
|
+
* API: Petstore v3.0
|
|
10
|
+
* base: /api/v3
|
|
11
|
+
* endpoints:
|
|
12
|
+
* GET /pet/{petId} Find pet by ID
|
|
13
|
+
* POST /pet Add a new pet to the store
|
|
14
|
+
* ...
|
|
15
|
+
*/
|
|
16
|
+
const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
|
17
|
+
const MAX_ENDPOINTS = 200;
|
|
18
|
+
const MAX_BYTES = 8 * 1024;
|
|
19
|
+
|
|
20
|
+
export function summarizeOpenApi(doc) {
|
|
21
|
+
if (!doc || typeof doc !== 'object') return null;
|
|
22
|
+
|
|
23
|
+
const lines = [];
|
|
24
|
+
const title = doc.info?.title ?? '(untitled)';
|
|
25
|
+
const version = doc.info?.version ?? '';
|
|
26
|
+
lines.push(`API: ${title}${version ? ' v' + version : ''}`);
|
|
27
|
+
|
|
28
|
+
const servers = (doc.servers ?? []).map((s) => s.url).filter(Boolean);
|
|
29
|
+
if (servers.length) {
|
|
30
|
+
lines.push(`base: ${servers.slice(0, 3).join(', ')}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const endpoints = [];
|
|
34
|
+
for (const [pathStr, methods] of Object.entries(doc.paths ?? {})) {
|
|
35
|
+
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
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 너무 많으면 자른다 — 모델이 알아야 하는 건 "어떤 종류의 엔드포인트가 있다" 정도면 충분.
|
|
49
|
+
const total = endpoints.length;
|
|
50
|
+
const shown = endpoints.slice(0, MAX_ENDPOINTS);
|
|
51
|
+
|
|
52
|
+
lines.push(`endpoints: (${shown.length}/${total})`);
|
|
53
|
+
for (const e of shown) {
|
|
54
|
+
lines.push(` ${e.method.padEnd(6)} ${e.path}${e.desc ? ' -- ' + e.desc : ''}`);
|
|
55
|
+
}
|
|
56
|
+
if (shown.length < total) {
|
|
57
|
+
lines.push(` ... and ${total - shown.length} more`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let result = lines.join('\n');
|
|
61
|
+
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;
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import inquirer from 'inquirer';
|
|
2
2
|
|
|
3
|
+
import { modelChoices, DEFAULT_MODEL_ID } from '../ai/models.js';
|
|
4
|
+
|
|
3
5
|
export async function askInitQuestions() {
|
|
4
6
|
return inquirer.prompt([
|
|
5
7
|
{
|
|
@@ -22,5 +24,27 @@ export async function askInitQuestions() {
|
|
|
22
24
|
{ name: 'Next.js (App Router + TypeScript)', value: 'next' },
|
|
23
25
|
],
|
|
24
26
|
},
|
|
27
|
+
{
|
|
28
|
+
type: 'list',
|
|
29
|
+
name: 'aiModel',
|
|
30
|
+
message: 'bc chat 에서 기본으로 쓸 AI 모델은?',
|
|
31
|
+
choices: [
|
|
32
|
+
...modelChoices(),
|
|
33
|
+
{ name: '나중에 설정 (bc config set-model)', value: null },
|
|
34
|
+
],
|
|
35
|
+
default: DEFAULT_MODEL_ID,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
type: 'input',
|
|
39
|
+
name: 'figmaUrl',
|
|
40
|
+
message: 'Figma 파일 URL (선택, 엔터로 건너뛰기):',
|
|
41
|
+
default: '',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
type: 'input',
|
|
45
|
+
name: 'openapiUrl',
|
|
46
|
+
message: '백엔드 OpenAPI(Swagger) URL (선택, 엔터로 건너뛰기):',
|
|
47
|
+
default: '',
|
|
48
|
+
},
|
|
25
49
|
]);
|
|
26
50
|
}
|