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,142 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import inquirer from 'inquirer';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
CONFIG_PATHS,
|
|
6
|
+
loadEffectiveConfig,
|
|
7
|
+
loadGlobalConfig,
|
|
8
|
+
saveGlobalConfig,
|
|
9
|
+
} from '../config/index.js';
|
|
10
|
+
import { MODEL_CATALOG, modelChoices, findModel } from '../ai/models.js';
|
|
11
|
+
|
|
12
|
+
function maskKey(key) {
|
|
13
|
+
if (!key) return chalk.dim('(없음)');
|
|
14
|
+
if (key.length <= 10) return chalk.green('********');
|
|
15
|
+
return chalk.green(`${key.slice(0, 6)}…${key.slice(-4)}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function configShowCommand() {
|
|
19
|
+
const eff = await loadEffectiveConfig();
|
|
20
|
+
const meta = findModel(eff.effective.model);
|
|
21
|
+
|
|
22
|
+
console.log(chalk.bold.cyan('\n bc 설정 상태\n'));
|
|
23
|
+
console.log(` ${chalk.dim('글로벌 설정 파일')} ${eff.paths.globalFile}`);
|
|
24
|
+
console.log(
|
|
25
|
+
` ${chalk.dim('프로젝트 설정 파일')} ${
|
|
26
|
+
eff.paths.projectFile ?? chalk.dim('(없음 — 프로젝트 외부에서 실행 중)')
|
|
27
|
+
}`,
|
|
28
|
+
);
|
|
29
|
+
console.log();
|
|
30
|
+
console.log(chalk.bold(' AI'));
|
|
31
|
+
console.log(
|
|
32
|
+
` ${chalk.dim('모델')} ${meta ? meta.label : eff.effective.model}`,
|
|
33
|
+
);
|
|
34
|
+
console.log(
|
|
35
|
+
` ${chalk.dim('Provider')} ${meta?.provider ?? chalk.red('?')}`,
|
|
36
|
+
);
|
|
37
|
+
console.log(
|
|
38
|
+
` ${chalk.dim('Anthropic 키')} ${maskKey(eff.effective.apiKeys.anthropic)}`,
|
|
39
|
+
);
|
|
40
|
+
console.log(
|
|
41
|
+
` ${chalk.dim('OpenAI 키')} ${maskKey(eff.effective.apiKeys.openai)}`,
|
|
42
|
+
);
|
|
43
|
+
console.log(
|
|
44
|
+
` ${chalk.dim('Gateway')} ${eff.effective.gateway ?? chalk.dim('(BYOK 모드)')}`,
|
|
45
|
+
);
|
|
46
|
+
console.log();
|
|
47
|
+
console.log(chalk.bold(' Limits'));
|
|
48
|
+
console.log(
|
|
49
|
+
` ${chalk.dim('세션 경고')} ${eff.effective.limits.warnAtTokens.toLocaleString()} tokens`,
|
|
50
|
+
);
|
|
51
|
+
console.log(
|
|
52
|
+
` ${chalk.dim('요청 확인')} ${eff.effective.limits.confirmAtTokens.toLocaleString()} tokens`,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (eff.paths.projectFile) {
|
|
56
|
+
console.log();
|
|
57
|
+
console.log(chalk.bold(' 프로젝트'));
|
|
58
|
+
console.log(
|
|
59
|
+
` ${chalk.dim('Figma')} ${eff.effective.design?.figma ?? chalk.dim('(미설정)')}`,
|
|
60
|
+
);
|
|
61
|
+
console.log(
|
|
62
|
+
` ${chalk.dim('OpenAPI')} ${eff.effective.api?.openapi ?? chalk.dim('(미설정)')}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
console.log();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function configSetModelCommand(modelId) {
|
|
69
|
+
const global = await loadGlobalConfig();
|
|
70
|
+
|
|
71
|
+
let chosen = modelId;
|
|
72
|
+
if (!chosen) {
|
|
73
|
+
const ans = await inquirer.prompt([
|
|
74
|
+
{
|
|
75
|
+
type: 'list',
|
|
76
|
+
name: 'model',
|
|
77
|
+
message: '기본 AI 모델을 선택하세요:',
|
|
78
|
+
choices: modelChoices(),
|
|
79
|
+
default: global.ai.model,
|
|
80
|
+
},
|
|
81
|
+
]);
|
|
82
|
+
chosen = ans.model;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!findModel(chosen)) {
|
|
86
|
+
console.error(chalk.red(`알 수 없는 모델 id: ${chosen}`));
|
|
87
|
+
console.error(
|
|
88
|
+
chalk.dim(
|
|
89
|
+
` 사용 가능: ${MODEL_CATALOG.map((m) => m.id).join(', ')}`,
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
global.ai.model = chosen;
|
|
96
|
+
await saveGlobalConfig(global);
|
|
97
|
+
console.log(chalk.green(`\n ✓ 기본 모델을 '${chosen}' 로 저장했습니다.`));
|
|
98
|
+
console.log(chalk.dim(` 저장 위치: ${CONFIG_PATHS.globalFile}\n`));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function configSetKeyCommand(provider, key) {
|
|
102
|
+
if (!provider || !['anthropic', 'openai'].includes(provider)) {
|
|
103
|
+
console.error(
|
|
104
|
+
chalk.red('사용법: bc config set-key <anthropic|openai> [key]'),
|
|
105
|
+
);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let value = key;
|
|
110
|
+
if (!value) {
|
|
111
|
+
const ans = await inquirer.prompt([
|
|
112
|
+
{
|
|
113
|
+
type: 'password',
|
|
114
|
+
name: 'key',
|
|
115
|
+
mask: '*',
|
|
116
|
+
message: `${provider} API 키를 입력하세요 (입력 가려짐):`,
|
|
117
|
+
validate: (v) => (v.trim().length > 10 ? true : '키가 너무 짧아요.'),
|
|
118
|
+
},
|
|
119
|
+
]);
|
|
120
|
+
value = ans.key.trim();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const global = await loadGlobalConfig();
|
|
124
|
+
global.ai.apiKeys = { ...(global.ai.apiKeys ?? {}), [provider]: value };
|
|
125
|
+
await saveGlobalConfig(global);
|
|
126
|
+
|
|
127
|
+
console.log(
|
|
128
|
+
chalk.green(`\n ✓ ${provider} API 키를 저장했습니다.`),
|
|
129
|
+
);
|
|
130
|
+
console.log(chalk.dim(` 파일: ${CONFIG_PATHS.globalFile} (chmod 600)\n`));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function configSetGatewayCommand(url) {
|
|
134
|
+
const global = await loadGlobalConfig();
|
|
135
|
+
global.ai.gateway = url && url.trim() ? url.trim() : null;
|
|
136
|
+
await saveGlobalConfig(global);
|
|
137
|
+
if (global.ai.gateway) {
|
|
138
|
+
console.log(chalk.green(`\n ✓ Gateway 를 ${global.ai.gateway} 로 설정했습니다.\n`));
|
|
139
|
+
} else {
|
|
140
|
+
console.log(chalk.green('\n ✓ Gateway 를 해제했습니다 (BYOK 모드).\n'));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
|
|
5
|
+
import { loadEffectiveConfig } from '../config/index.js';
|
|
6
|
+
import { generateApiTypes } from '../openapi/codegen.js';
|
|
7
|
+
import { loadOpenApi } from '../openapi/fetch.js';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_OUT = 'src/api/types.gen.ts';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `bc gen api-types`
|
|
13
|
+
*
|
|
14
|
+
* 인자 우선순위:
|
|
15
|
+
* 1) --source <url|path> 명시 입력
|
|
16
|
+
* 2) bc.config.json 의 api.openapi
|
|
17
|
+
*
|
|
18
|
+
* 출력 파일:
|
|
19
|
+
* --out <path> (기본 src/api/types.gen.ts)
|
|
20
|
+
*/
|
|
21
|
+
export async function genApiTypesCommand(opts = {}) {
|
|
22
|
+
const cfg = await loadEffectiveConfig();
|
|
23
|
+
const sourceInput = opts.source || cfg.effective.api?.openapi;
|
|
24
|
+
if (!sourceInput) {
|
|
25
|
+
console.error(chalk.red('\n OpenAPI 출처를 못 찾았습니다.'));
|
|
26
|
+
console.error(chalk.dim(' --source <url|path> 로 직접 주거나'));
|
|
27
|
+
console.error(chalk.dim(' bc.config.json 의 api.openapi 를 채우세요.\n'));
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
const outFile = path.resolve(opts.out ?? DEFAULT_OUT);
|
|
31
|
+
|
|
32
|
+
console.log(chalk.bold.cyan('\n bc gen api-types\n'));
|
|
33
|
+
console.log(chalk.dim(` source ${sourceInput}`));
|
|
34
|
+
console.log(chalk.dim(` out ${outFile}\n`));
|
|
35
|
+
|
|
36
|
+
// 먼저 fetch/load 단계로 입력이 유효한지 검증 (네트워크 에러 등 빠르게 잡음).
|
|
37
|
+
try {
|
|
38
|
+
await loadOpenApi(sourceInput);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error(chalk.red(' ' + (err?.message ?? err)) + '\n');
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const res = await generateApiTypes({
|
|
46
|
+
input: sourceInput,
|
|
47
|
+
outFile,
|
|
48
|
+
sourceLabel: sourceInput,
|
|
49
|
+
});
|
|
50
|
+
const kb = (res.bytes / 1024).toFixed(1);
|
|
51
|
+
console.log(chalk.green(` ✓ 타입 파일 생성 완료 (${kb}KB)`));
|
|
52
|
+
console.log(chalk.dim(` ${res.outFile}\n`));
|
|
53
|
+
console.log(chalk.dim(' 다음:'));
|
|
54
|
+
console.log(chalk.dim(' - 코드에서 import 해서 사용:'));
|
|
55
|
+
console.log(chalk.dim(` import type { paths, components } from "${path.relative(process.cwd(), outFile).replace(/\.[^.]+$/, '')}";`));
|
|
56
|
+
console.log(chalk.dim(' - bc index 를 다시 돌리면 RAG 컨텍스트에도 반영됨\n'));
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.error(chalk.red('\n 타입 생성 실패: ') + (err?.message ?? err) + '\n');
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
import { loadEffectiveConfig } from '../config/index.js';
|
|
4
|
+
import { buildIndex, loadIndex } from '../indexer/store.js';
|
|
5
|
+
import { searchIndex } from '../indexer/search.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `bc index` — 증분 빌드/갱신
|
|
9
|
+
* `bc index --rebuild` — 처음부터 다시
|
|
10
|
+
* `bc index status` — 현재 상태
|
|
11
|
+
* `bc index search Q` — 디버그용 검색
|
|
12
|
+
*/
|
|
13
|
+
export async function indexBuildCommand(opts = {}) {
|
|
14
|
+
const cfg = await loadEffectiveConfig();
|
|
15
|
+
if (!cfg.paths.projectFile) {
|
|
16
|
+
console.log(
|
|
17
|
+
chalk.yellow(' ⚠ bc.config.json 을 못 찾았습니다 — 먼저 `bc adopt` 또는 `bc init` 을 실행하세요.\n'),
|
|
18
|
+
);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
console.log(chalk.bold.cyan('\n bc index — 코드베이스 임베딩 인덱싱\n'));
|
|
23
|
+
try {
|
|
24
|
+
const res = await buildIndex({
|
|
25
|
+
effective: cfg.effective,
|
|
26
|
+
contextCfg: cfg.effective.context,
|
|
27
|
+
rebuild: !!opts.rebuild,
|
|
28
|
+
onProgress: (msg) => console.log(chalk.dim(msg)),
|
|
29
|
+
});
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
console.log(chalk.yellow('\n ' + res.reason + '\n'));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
console.log();
|
|
35
|
+
console.log(chalk.green(' ✓ 인덱스 빌드 완료'));
|
|
36
|
+
console.log(chalk.dim(` 파일 ${res.manifest.fileCount} · 청크 ${res.manifest.chunkCount}`));
|
|
37
|
+
console.log(chalk.dim(` 재사용 ${res.manifest.reused} · 신규 임베딩 ${res.manifest.newlyEmbedded}`));
|
|
38
|
+
console.log(chalk.dim(` 저장 위치: ${res.paths.dir}\n`));
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error('\n' + chalk.red(' ' + (err?.message ?? err)) + '\n');
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function indexStatusCommand() {
|
|
46
|
+
const idx = await loadIndex();
|
|
47
|
+
if (!idx) {
|
|
48
|
+
console.log(chalk.dim('\n 인덱스가 없습니다. `bc index` 로 빌드하세요.\n'));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
console.log(chalk.bold.cyan('\n 인덱스 상태'));
|
|
52
|
+
console.log(chalk.dim(' ─────────────────────────────────────'));
|
|
53
|
+
console.log(` ${chalk.dim('모델 ')} ${idx.embeddingModel}`);
|
|
54
|
+
console.log(` ${chalk.dim('파일 ')} ${idx.fileCount}`);
|
|
55
|
+
console.log(` ${chalk.dim('청크 ')} ${idx.chunkCount}`);
|
|
56
|
+
console.log(` ${chalk.dim('빌드일 ')} ${idx.builtAt}`);
|
|
57
|
+
console.log(` ${chalk.dim('저장 위치')} ${idx._paths.dir}\n`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function indexSearchCommand(query, opts = {}) {
|
|
61
|
+
if (!query) {
|
|
62
|
+
console.error(chalk.red(' 사용법: bc index search "검색어"'));
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
const cfg = await loadEffectiveConfig();
|
|
66
|
+
try {
|
|
67
|
+
const res = await searchIndex(query, cfg.effective, {
|
|
68
|
+
topK: Number(opts.topK ?? 5),
|
|
69
|
+
});
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
console.log(chalk.yellow('\n 인덱스가 없습니다. `bc index` 로 빌드하세요.\n'));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
console.log(chalk.bold.cyan(`\n '${query}' 검색 결과 — ${res.results.length}개 (전체 ${res.total} 청크)\n`));
|
|
75
|
+
for (const r of res.results) {
|
|
76
|
+
const score = r.score.toFixed(3);
|
|
77
|
+
console.log(
|
|
78
|
+
` ${chalk.cyan(r.chunk.file)}:${chalk.dim(r.chunk.startLine + '-' + r.chunk.endLine)} ` +
|
|
79
|
+
chalk.yellow(score),
|
|
80
|
+
);
|
|
81
|
+
const preview = r.chunk.text.split('\n').slice(0, 3).join(' ').slice(0, 110);
|
|
82
|
+
console.log(' ' + chalk.dim(preview) + chalk.dim('…'));
|
|
83
|
+
}
|
|
84
|
+
console.log();
|
|
85
|
+
} catch (err) {
|
|
86
|
+
console.error('\n' + chalk.red(' ' + (err?.message ?? err)) + '\n');
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/commands/init.js
CHANGED
|
@@ -27,6 +27,25 @@ export async function initCommand() {
|
|
|
27
27
|
console.log(chalk.yellow(" 다음 명령어로 시작하세요:\n"));
|
|
28
28
|
console.log(chalk.white(` cd ${answers.projectName}`));
|
|
29
29
|
console.log(chalk.white(" npm run dev\n"));
|
|
30
|
+
|
|
31
|
+
console.log(chalk.dim(" AI 어시스턴트:"));
|
|
32
|
+
if (answers.aiModel) {
|
|
33
|
+
console.log(
|
|
34
|
+
chalk.dim(
|
|
35
|
+
` 이 프로젝트의 기본 모델 = ${answers.aiModel} (bc.config.json 에 저장됨)`
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
} else {
|
|
39
|
+
console.log(
|
|
40
|
+
chalk.dim(" 모델 미설정 — `bc config set-model` 로 나중에 지정")
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
console.log(
|
|
44
|
+
chalk.dim(
|
|
45
|
+
" API 키: bc config set-key anthropic (또는 ANTHROPIC_API_KEY 환경변수)"
|
|
46
|
+
)
|
|
47
|
+
);
|
|
48
|
+
console.log(chalk.dim(" 실행: bc chat\n"));
|
|
30
49
|
} catch (error) {
|
|
31
50
|
if (error.code === "EEXIST") {
|
|
32
51
|
console.error(
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_MODEL_ID } from '../ai/models.js';
|
|
6
|
+
|
|
7
|
+
const GLOBAL_DIR = path.join(os.homedir(), '.bc');
|
|
8
|
+
const GLOBAL_FILE = path.join(GLOBAL_DIR, 'config.json');
|
|
9
|
+
const PROJECT_FILE_NAMES = ['bc.config.json'];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 글로벌 설정 (~/.bc/config.json) 의 기본 모양.
|
|
13
|
+
*
|
|
14
|
+
* - 사용자별·머신별. API 키 같은 비밀값은 여기에 둔다.
|
|
15
|
+
* - 외부 사용자도 깔자마자 동작하도록 model 만 기본값을 채워둔다.
|
|
16
|
+
*/
|
|
17
|
+
const DEFAULT_GLOBAL = {
|
|
18
|
+
ai: {
|
|
19
|
+
model: DEFAULT_MODEL_ID,
|
|
20
|
+
apiKeys: {
|
|
21
|
+
// anthropic: 'sk-ant-...',
|
|
22
|
+
// openai: 'sk-...',
|
|
23
|
+
},
|
|
24
|
+
/** 사내 게이트웨이를 쓰는 경우 base URL. 비우면 BYOK 모드. */
|
|
25
|
+
gateway: null,
|
|
26
|
+
},
|
|
27
|
+
limits: {
|
|
28
|
+
/** 한 세션 합계가 이 토큰을 넘으면 경고. */
|
|
29
|
+
warnAtTokens: 50_000,
|
|
30
|
+
/** 한 요청이 이 토큰을 넘으면 사용자에게 확인. */
|
|
31
|
+
confirmAtTokens: 12_000,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 프로젝트 설정 (bc.config.json) 기본 모양.
|
|
37
|
+
*
|
|
38
|
+
* - 팀원 모두가 공유. git 에 커밋된다.
|
|
39
|
+
* - 비밀값을 두지 말 것 (FIGMA_TOKEN 같은 건 환경변수명만 적고 값은 .env).
|
|
40
|
+
*/
|
|
41
|
+
const DEFAULT_PROJECT = {
|
|
42
|
+
ai: {
|
|
43
|
+
/** 프로젝트가 이 모델을 강제하고 싶을 때만 채움. 비우면 글로벌 설정 따름. */
|
|
44
|
+
model: null,
|
|
45
|
+
},
|
|
46
|
+
design: {
|
|
47
|
+
figma: null,
|
|
48
|
+
figmaTokenEnv: 'FIGMA_TOKEN',
|
|
49
|
+
},
|
|
50
|
+
api: {
|
|
51
|
+
openapi: null,
|
|
52
|
+
baseUrl: null,
|
|
53
|
+
},
|
|
54
|
+
context: {
|
|
55
|
+
include: ['src/**/*.{ts,tsx,js,jsx}', 'bc.config.json'],
|
|
56
|
+
exclude: ['**/*.test.*', '**/__mocks__/**', 'node_modules/**', 'dist/**'],
|
|
57
|
+
maxFiles: 20,
|
|
58
|
+
},
|
|
59
|
+
/** bc adopt 가 채워준다. systemPrompt 가 읽어 모델에 알린다. */
|
|
60
|
+
framework: null,
|
|
61
|
+
detected: null,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
async function readJson(file) {
|
|
65
|
+
try {
|
|
66
|
+
const raw = await fs.readFile(file, 'utf8');
|
|
67
|
+
return JSON.parse(raw);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (err.code === 'ENOENT') return null;
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function writeJson(file, data) {
|
|
75
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
76
|
+
await fs.writeFile(file, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function deepMerge(base, patch) {
|
|
80
|
+
if (patch == null) return base;
|
|
81
|
+
// 한쪽이 null/원시값이거나, 한쪽만 배열이면 patch 가 우선.
|
|
82
|
+
// 특히 base 가 null 인데 patch 가 객체일 때 `k in base` 가 TypeError 던지는 걸 방지.
|
|
83
|
+
if (base == null) return patch;
|
|
84
|
+
if (typeof base !== 'object' || typeof patch !== 'object') return patch;
|
|
85
|
+
if (Array.isArray(base) || Array.isArray(patch)) return patch;
|
|
86
|
+
const out = { ...base };
|
|
87
|
+
for (const k of Object.keys(patch)) {
|
|
88
|
+
out[k] = k in base ? deepMerge(base[k], patch[k]) : patch[k];
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function loadGlobalConfig() {
|
|
94
|
+
const found = await readJson(GLOBAL_FILE);
|
|
95
|
+
return deepMerge(DEFAULT_GLOBAL, found ?? {});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function saveGlobalConfig(next) {
|
|
99
|
+
await writeJson(GLOBAL_FILE, next);
|
|
100
|
+
// API 키가 들어갈 수 있으니 권한 좁힘. Windows 에서는 무시될 수 있음.
|
|
101
|
+
try {
|
|
102
|
+
await fs.chmod(GLOBAL_FILE, 0o600);
|
|
103
|
+
} catch {
|
|
104
|
+
/* noop */
|
|
105
|
+
}
|
|
106
|
+
return next;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function findProjectConfigPath(startDir = process.cwd()) {
|
|
110
|
+
let dir = path.resolve(startDir);
|
|
111
|
+
const root = path.parse(dir).root;
|
|
112
|
+
while (true) {
|
|
113
|
+
for (const name of PROJECT_FILE_NAMES) {
|
|
114
|
+
const candidate = path.join(dir, name);
|
|
115
|
+
try {
|
|
116
|
+
await fs.access(candidate);
|
|
117
|
+
return candidate;
|
|
118
|
+
} catch {
|
|
119
|
+
/* keep walking */
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (dir === root) return null;
|
|
123
|
+
dir = path.dirname(dir);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function loadProjectConfig(startDir = process.cwd()) {
|
|
128
|
+
const file = await findProjectConfigPath(startDir);
|
|
129
|
+
const found = file ? await readJson(file) : null;
|
|
130
|
+
return {
|
|
131
|
+
file,
|
|
132
|
+
config: deepMerge(DEFAULT_PROJECT, found ?? {}),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function saveProjectConfig(file, next) {
|
|
137
|
+
await writeJson(file, next);
|
|
138
|
+
return next;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 글로벌 + 프로젝트 + 환경변수를 머지한 "유효 설정" 을 돌려준다.
|
|
143
|
+
* - 모델은 프로젝트 우선 → 글로벌
|
|
144
|
+
* - API 키는 환경변수 우선 → 글로벌 저장값
|
|
145
|
+
*/
|
|
146
|
+
export async function loadEffectiveConfig(startDir = process.cwd()) {
|
|
147
|
+
const [global, projectInfo] = await Promise.all([
|
|
148
|
+
loadGlobalConfig(),
|
|
149
|
+
loadProjectConfig(startDir),
|
|
150
|
+
]);
|
|
151
|
+
const project = projectInfo.config;
|
|
152
|
+
|
|
153
|
+
const model = project.ai?.model || global.ai?.model || DEFAULT_MODEL_ID;
|
|
154
|
+
const apiKeys = {
|
|
155
|
+
anthropic:
|
|
156
|
+
process.env.ANTHROPIC_API_KEY || global.ai?.apiKeys?.anthropic || null,
|
|
157
|
+
openai: process.env.OPENAI_API_KEY || global.ai?.apiKeys?.openai || null,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
paths: {
|
|
162
|
+
globalFile: GLOBAL_FILE,
|
|
163
|
+
projectFile: projectInfo.file,
|
|
164
|
+
},
|
|
165
|
+
global,
|
|
166
|
+
project,
|
|
167
|
+
effective: {
|
|
168
|
+
model,
|
|
169
|
+
apiKeys,
|
|
170
|
+
gateway: global.ai?.gateway ?? null,
|
|
171
|
+
limits: global.limits,
|
|
172
|
+
design: project.design,
|
|
173
|
+
api: project.api,
|
|
174
|
+
context: project.context,
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export const CONFIG_PATHS = {
|
|
180
|
+
globalDir: GLOBAL_DIR,
|
|
181
|
+
globalFile: GLOBAL_FILE,
|
|
182
|
+
projectFileName: PROJECT_FILE_NAMES[0],
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export const CONFIG_DEFAULTS = {
|
|
186
|
+
global: DEFAULT_GLOBAL,
|
|
187
|
+
project: DEFAULT_PROJECT,
|
|
188
|
+
};
|