@callakrsos/my-ollama-cli 1.0.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/.env_example +19 -0
- package/FilesUtils.js +54 -0
- package/LICENSE.txt +201 -0
- package/README.md +201 -0
- package/cli-agent.js +275 -0
- package/commands/register.js +238 -0
- package/index.js +6 -0
- package/lib/history.js +30 -0
- package/lib/logger.js +46 -0
- package/lib/mcp.js +63 -0
- package/lib/model.js +52 -0
- package/package.json +32 -0
- package/search/search/LICENSE +22 -0
- package/search/search/README.md +213 -0
- package/search/search/dist/index.d.ts +33 -0
- package/search/search/dist/index.js +193 -0
- package/search/search/package.json +92 -0
- package/settings.example.json +46 -0
- package/skills/index.js +50 -0
- package/skills.example.json +15 -0
- package/tools/fileTools.js +65 -0
- package/tools/index.js +13 -0
- package/tools/shellTools.js +83 -0
- package/tools/utils.js +19 -0
- package/ui/fileSelector.js +44 -0
- package/ui/prompt.js +347 -0
package/cli-agent.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/*******************************************************************************/
|
|
2
|
+
//제작자 : 김영준
|
|
3
|
+
// 오픈소스를 활용한 CLI를 학습 목적으로 만들었습니다.
|
|
4
|
+
// AI는 틀릴 수 있습니다.
|
|
5
|
+
// 생성된 결과에 대한 책임은 본인에게 있습니다.
|
|
6
|
+
// 혹시 저를 본다면 커피라도 한잔~
|
|
7
|
+
/*******************************************************************************/
|
|
8
|
+
import { createAgent } from "langchain";
|
|
9
|
+
import { HumanMessage, ToolMessage } from "@langchain/core/messages";
|
|
10
|
+
import { MemorySaver } from "@langchain/langgraph";
|
|
11
|
+
import inquirer from "inquirer";
|
|
12
|
+
import fs from "fs/promises";
|
|
13
|
+
import 'dotenv/config';
|
|
14
|
+
import chalk from 'chalk';
|
|
15
|
+
import { Command } from 'commander';
|
|
16
|
+
|
|
17
|
+
import { tools } from './tools/index.js';
|
|
18
|
+
import { getSafePath } from './tools/utils.js';
|
|
19
|
+
import { loadHistory, saveHistory, commandHistory } from './lib/history.js';
|
|
20
|
+
import { logger, LOG_DIR } from './lib/logger.js';
|
|
21
|
+
import { loadSettings, loadMcpToolsFromSettings } from './lib/mcp.js';
|
|
22
|
+
import { getModel, getLlmProvider, normalizeOllamaBaseUrl, normalizeVllmBaseUrl, SYSTEM_PROMPT } from './lib/model.js';
|
|
23
|
+
import { cliPrompt, createSpinner } from './ui/prompt.js';
|
|
24
|
+
import { selectFile, selectMultipleFiles } from './ui/fileSelector.js';
|
|
25
|
+
import { registerCommands } from './commands/register.js';
|
|
26
|
+
|
|
27
|
+
// =========================================================
|
|
28
|
+
// [1] 에이전트 설정
|
|
29
|
+
// =========================================================
|
|
30
|
+
let currentThreadId = Date.now().toString();
|
|
31
|
+
|
|
32
|
+
async function createAgentExecutor() {
|
|
33
|
+
const settings = await loadSettings();
|
|
34
|
+
const { tools: mcpTools, mcpClient } = await loadMcpToolsFromSettings(settings);
|
|
35
|
+
const agent = createAgent({
|
|
36
|
+
model: getModel(),
|
|
37
|
+
tools: [...tools, ...mcpTools],
|
|
38
|
+
prompt: SYSTEM_PROMPT,
|
|
39
|
+
checkpointer: new MemorySaver(),
|
|
40
|
+
});
|
|
41
|
+
return { agent, mcpClient };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// =========================================================
|
|
45
|
+
// [2] 메인 CLI 루프
|
|
46
|
+
// =========================================================
|
|
47
|
+
async function startCLI() {
|
|
48
|
+
await loadHistory();
|
|
49
|
+
const { agent, mcpClient } = await createAgentExecutor();
|
|
50
|
+
const program = new Command();
|
|
51
|
+
program.exitOverride();
|
|
52
|
+
|
|
53
|
+
const closeMcp = async () => {
|
|
54
|
+
if (mcpClient && typeof mcpClient.close === 'function') {
|
|
55
|
+
try { await mcpClient.close(); } catch (_) {}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
process.on('beforeExit', () => { closeMcp(); });
|
|
59
|
+
process.on('SIGINT', () => { closeMcp(); });
|
|
60
|
+
|
|
61
|
+
// ── 시작 배너 ─────────────────────────────────────
|
|
62
|
+
console.log(chalk.blue.bold(`
|
|
63
|
+
_ __ __ __ _ ____ _ ___
|
|
64
|
+
| |/ / \\ \\ / / | | / ___| | |_ _|
|
|
65
|
+
| ' / \\ V / _ | || | | | | |
|
|
66
|
+
| . \\ | | | |_| || |___| |___ | |
|
|
67
|
+
|_|\\_\\ |_| \\___/ \\____|_____|___|
|
|
68
|
+
`));
|
|
69
|
+
const llmProvider = getLlmProvider();
|
|
70
|
+
const serverUrl = llmProvider === 'vllm'
|
|
71
|
+
? normalizeVllmBaseUrl(process.env.VLLM_BASE_URL || process.env.OLLAMA_BASE_URL)
|
|
72
|
+
: normalizeOllamaBaseUrl(process.env.OLLAMA_BASE_URL);
|
|
73
|
+
console.log(chalk.green("KYJ CLI에 오신 것을 환영합니다! '/help'를 입력해 명령어를 확인하세요."));
|
|
74
|
+
console.log(chalk.gray(` LLM: ${llmProvider} → ${serverUrl}`));
|
|
75
|
+
if (mcpClient) {
|
|
76
|
+
const settings = await loadSettings();
|
|
77
|
+
const mcpServers = Object.keys(settings?.mcp?.mcpServers ?? {});
|
|
78
|
+
console.log(chalk.gray(` MCP: ${mcpServers.length}개 서버 연결됨 (${mcpServers.join(', ')})`));
|
|
79
|
+
}
|
|
80
|
+
console.log(chalk.gray(' @ : 단일 파일 첨부 | @@ : 멀티 파일 첨부 | /skills : 스킬 목록'));
|
|
81
|
+
console.log(chalk.gray(` 로그: ${LOG_DIR}`));
|
|
82
|
+
logger.system('startup', { provider: llmProvider, serverUrl });
|
|
83
|
+
|
|
84
|
+
// ── AI 채팅 핸들러 ────────────────────────────────
|
|
85
|
+
const handleChat = async (userInput) => {
|
|
86
|
+
if (!userInput.trim()) { askQuestion(); return; }
|
|
87
|
+
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
const sigintHandler = () => { console.log(chalk.yellow('\n[명령어 실행 취소]')); controller.abort(); };
|
|
90
|
+
const spinner = createSpinner('AI가 처리 중...');
|
|
91
|
+
const startTime = Date.now();
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
process.once('SIGINT', sigintHandler);
|
|
95
|
+
const result = await agent.invoke(
|
|
96
|
+
{ messages: [new HumanMessage(userInput)] },
|
|
97
|
+
{ configurable: { thread_id: currentThreadId }, signal: controller.signal, recursionLimit: 20 },
|
|
98
|
+
);
|
|
99
|
+
spinner.stop();
|
|
100
|
+
const durationMs = Date.now() - startTime;
|
|
101
|
+
|
|
102
|
+
const lastMsg = result.messages.at(-1);
|
|
103
|
+
const output = typeof lastMsg.content === 'string'
|
|
104
|
+
? lastMsg.content
|
|
105
|
+
: Array.isArray(lastMsg.content)
|
|
106
|
+
? lastMsg.content.filter(c => c.type === 'text').map(c => c.text).join('')
|
|
107
|
+
: String(lastMsg.content);
|
|
108
|
+
|
|
109
|
+
const toolsCalled = result.messages
|
|
110
|
+
.filter(m => m instanceof ToolMessage)
|
|
111
|
+
.map(m => ({ tool: m.name, input: {} }));
|
|
112
|
+
|
|
113
|
+
const W = process.stdout.columns || 100;
|
|
114
|
+
process.stdout.write(chalk.gray('─'.repeat(W)) + '\n');
|
|
115
|
+
process.stdout.write(
|
|
116
|
+
chalk.blue.bold(' 🤖 AI') +
|
|
117
|
+
chalk.gray(` (${(durationMs / 1000).toFixed(1)}초`) +
|
|
118
|
+
(toolsCalled.length ? chalk.gray(` · 툴 ${toolsCalled.map(t => t.tool).join(', ')}`) : '') +
|
|
119
|
+
chalk.gray(')') + '\n\n'
|
|
120
|
+
);
|
|
121
|
+
output.split('\n').forEach(line => process.stdout.write(' ' + line + '\n'));
|
|
122
|
+
process.stdout.write('\n');
|
|
123
|
+
|
|
124
|
+
logger.chat(userInput, output, durationMs, toolsCalled);
|
|
125
|
+
|
|
126
|
+
} catch (error) {
|
|
127
|
+
spinner.stop();
|
|
128
|
+
if (error.name !== 'AbortError') {
|
|
129
|
+
console.error(chalk.red('❌ 오류 발생:'), error.message);
|
|
130
|
+
const body = error.body ?? error.error ?? error.response?.data ?? error.data;
|
|
131
|
+
if (body != null) {
|
|
132
|
+
console.error(chalk.yellow(' API 응답 본문:'), typeof body === 'string' ? body : JSON.stringify(body, null, 2));
|
|
133
|
+
}
|
|
134
|
+
if (error.message?.includes('404')) {
|
|
135
|
+
if (llmProvider === 'vllm') {
|
|
136
|
+
const base = normalizeVllmBaseUrl(process.env.VLLM_BASE_URL || process.env.OLLAMA_BASE_URL);
|
|
137
|
+
console.error(chalk.yellow(` vLLM 주소: ${base}`));
|
|
138
|
+
console.error(chalk.gray(' → .env의 VLLM_BASE_URL을 확인하세요.'));
|
|
139
|
+
} else {
|
|
140
|
+
const base = normalizeOllamaBaseUrl(process.env.OLLAMA_BASE_URL);
|
|
141
|
+
console.error(chalk.yellow(` Ollama 주소: ${base}`));
|
|
142
|
+
console.error(chalk.gray(' → .env의 OLLAMA_BASE_URL을 확인하세요.'));
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
console.error(chalk.red('❌ 상세:'), error.stack);
|
|
146
|
+
}
|
|
147
|
+
logger.error(error, { userInput: userInput.slice(0, 300), provider: llmProvider, durationMs: Date.now() - startTime });
|
|
148
|
+
}
|
|
149
|
+
} finally {
|
|
150
|
+
process.removeListener('SIGINT', sigintHandler);
|
|
151
|
+
askQuestion();
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// ── @ 단일 파일 첨부 ──────────────────────────────
|
|
156
|
+
const handleAttach = async (rawInput) => {
|
|
157
|
+
await saveHistory(rawInput);
|
|
158
|
+
const parts = rawInput.split(' ');
|
|
159
|
+
const fileCandidate = parts[0].startsWith('@') ? parts[0].slice(1) : '';
|
|
160
|
+
const inlineQuestion = parts.slice(1).join(' ').trim();
|
|
161
|
+
|
|
162
|
+
let selectedFile = null;
|
|
163
|
+
if (fileCandidate && (fileCandidate.includes('/') || fileCandidate.includes('\\') || fileCandidate.includes('.'))) {
|
|
164
|
+
try { await fs.access(getSafePath(fileCandidate)); selectedFile = fileCandidate; } catch (_) {}
|
|
165
|
+
}
|
|
166
|
+
if (!selectedFile) {
|
|
167
|
+
try { selectedFile = await selectFile(fileCandidate); }
|
|
168
|
+
catch (e) {
|
|
169
|
+
console.log(e?.name === 'ExitPromptError' ? chalk.yellow('\n[취소]') : chalk.red('파일 첨부 중 오류:') + ' ' + e.message);
|
|
170
|
+
askQuestion(); return;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!selectedFile) { console.log(chalk.yellow('파일이 선택되지 않았습니다.')); askQuestion(); return; }
|
|
174
|
+
|
|
175
|
+
let question = inlineQuestion;
|
|
176
|
+
if (!question) {
|
|
177
|
+
try {
|
|
178
|
+
const { q } = await inquirer.prompt([{ type: 'input', name: 'q', message: chalk.cyan(`'${selectedFile}' 파일에 대해 질문하세요:`) }]);
|
|
179
|
+
question = q;
|
|
180
|
+
} catch (e) {
|
|
181
|
+
if (e?.name === 'ExitPromptError') console.log(chalk.yellow('\n[취소]'));
|
|
182
|
+
askQuestion(); return;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (!question) { console.log(chalk.yellow('질문이 입력되지 않았습니다.')); askQuestion(); return; }
|
|
186
|
+
|
|
187
|
+
const normalizedPath = selectedFile.replace(/\\/g, '/');
|
|
188
|
+
await handleChat(`read_file 도구로 다음 파일을 읽고 질문에 답해주세요.\n\n[파일 경로]\n${normalizedPath}\n\n[질문]\n${question}`);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// ── @@ 멀티 파일 첨부 ────────────────────────────
|
|
192
|
+
const handleMultiAttach = async (rawInput) => {
|
|
193
|
+
await saveHistory(rawInput);
|
|
194
|
+
const firstWord = rawInput.split(' ')[0];
|
|
195
|
+
const initialSearch = firstWord.length > 2 ? firstWord.substring(2).trim() : '';
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const selectedFiles = await selectMultipleFiles(initialSearch);
|
|
199
|
+
if (!selectedFiles?.length) { console.log(chalk.yellow('파일이 선택되지 않았습니다.')); askQuestion(); return; }
|
|
200
|
+
|
|
201
|
+
const { question } = await inquirer.prompt([{ type: 'input', name: 'question', message: chalk.cyan(`${selectedFiles.length}개 파일에 대해 질문하세요:`) }]);
|
|
202
|
+
if (!question) { console.log(chalk.yellow('질문이 입력되지 않았습니다.')); askQuestion(); return; }
|
|
203
|
+
|
|
204
|
+
const filePaths = selectedFiles.map(f => f.replace(/\\/g, '/')).join('\n');
|
|
205
|
+
await handleChat(`read_file 도구로 다음 파일들을 순서대로 읽고 질문에 답해주세요.\n\n[파일 경로 목록]\n${filePaths}\n\n[질문]\n${question}`);
|
|
206
|
+
} catch (e) {
|
|
207
|
+
console.log(e?.name === 'ExitPromptError' ? chalk.yellow('\n[취소]') : chalk.red('파일 첨부 중 오류:') + ' ' + e.message);
|
|
208
|
+
askQuestion();
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// ── 슬래시 커맨드 등록 ────────────────────────────
|
|
213
|
+
registerCommands(program, {
|
|
214
|
+
agent,
|
|
215
|
+
getThreadId: () => currentThreadId,
|
|
216
|
+
resetThread: () => { currentThreadId = Date.now().toString(); },
|
|
217
|
+
handleChat,
|
|
218
|
+
askQuestion: () => askQuestion(),
|
|
219
|
+
closeMcp,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// ── 입력 루프 ─────────────────────────────────────
|
|
223
|
+
const askQuestion = async () => {
|
|
224
|
+
try {
|
|
225
|
+
const userInput = await cliPrompt(commandHistory);
|
|
226
|
+
const trimmed = (userInput || '').trim();
|
|
227
|
+
if (!trimmed) { askQuestion(); return; }
|
|
228
|
+
|
|
229
|
+
await saveHistory(trimmed);
|
|
230
|
+
const firstArg = trimmed.split(' ')[0];
|
|
231
|
+
|
|
232
|
+
if (firstArg.startsWith('/')) {
|
|
233
|
+
try {
|
|
234
|
+
await program.parseAsync(trimmed.split(' '), { from: 'user' });
|
|
235
|
+
} catch (e) {
|
|
236
|
+
if (e.code === 'commander.unknownCommand') {
|
|
237
|
+
console.log(chalk.yellow(`알 수 없는 명령어입니다. '/help'를 입력하면 사용 가능한 명령을 볼 수 있습니다.`));
|
|
238
|
+
} else if (e.code !== 'commander.executeSubCommandAsync') {
|
|
239
|
+
console.error(chalk.red(`명령어 처리 중 오류: ${e.message}`));
|
|
240
|
+
}
|
|
241
|
+
askQuestion();
|
|
242
|
+
}
|
|
243
|
+
} else if (firstArg.startsWith('@@')) {
|
|
244
|
+
await handleMultiAttach(trimmed);
|
|
245
|
+
} else if (firstArg.startsWith('@')) {
|
|
246
|
+
await handleAttach(trimmed);
|
|
247
|
+
} else {
|
|
248
|
+
await handleChat(trimmed);
|
|
249
|
+
}
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if (error?.name === 'ExitPromptError') {
|
|
252
|
+
try {
|
|
253
|
+
const { confirmExit } = await inquirer.prompt([{ type: 'confirm', name: 'confirmExit', message: '정말로 종료하시겠습니까?', default: true }]);
|
|
254
|
+
if (confirmExit) {
|
|
255
|
+
console.log(chalk.yellow('프로그램을 종료합니다. 안녕히 계세요!'));
|
|
256
|
+
await closeMcp();
|
|
257
|
+
process.exit(0);
|
|
258
|
+
}
|
|
259
|
+
} catch (_) {}
|
|
260
|
+
askQuestion();
|
|
261
|
+
} else {
|
|
262
|
+
console.error(chalk.red('오류 발생:'), error.message);
|
|
263
|
+
askQuestion();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
if (process.argv.slice(2).length > 0) {
|
|
269
|
+
await program.parseAsync(process.argv);
|
|
270
|
+
} else {
|
|
271
|
+
askQuestion();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
startCLI();
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import inquirer from "inquirer";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { loadSkills } from '../skills/index.js';
|
|
6
|
+
import { loadSettings } from '../lib/mcp.js';
|
|
7
|
+
import { saveHistory } from '../lib/history.js';
|
|
8
|
+
import { logger, LOG_DIR, logDate, ensureLogDir, getTimestamp } from '../lib/logger.js';
|
|
9
|
+
import { getSafePath } from '../tools/utils.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Commander program에 모든 슬래시 커맨드를 등록합니다.
|
|
13
|
+
*
|
|
14
|
+
* @param {import('commander').Command} program
|
|
15
|
+
* @param {{
|
|
16
|
+
* agent: any,
|
|
17
|
+
* getThreadId: () => string,
|
|
18
|
+
* resetThread: () => void,
|
|
19
|
+
* handleChat: (input: string) => Promise<void>,
|
|
20
|
+
* askQuestion: () => Promise<void>,
|
|
21
|
+
* closeMcp: () => Promise<void>,
|
|
22
|
+
* }} ctx
|
|
23
|
+
*/
|
|
24
|
+
export function registerCommands(program, ctx) {
|
|
25
|
+
const { agent, getThreadId, resetThread, handleChat, askQuestion, closeMcp } = ctx;
|
|
26
|
+
|
|
27
|
+
program
|
|
28
|
+
.command('/help')
|
|
29
|
+
.description('사용 가능한 명령어 목록을 출력합니다.')
|
|
30
|
+
.action(() => {
|
|
31
|
+
const cmds = [
|
|
32
|
+
['@<검색어>', '단일 파일 첨부 후 질문 (예: @index.js)'],
|
|
33
|
+
['@@<검색어>', '멀티 파일 첨부 (Space 선택, Enter 확인)'],
|
|
34
|
+
['/skills', '사용 가능한 스킬 목록 출력'],
|
|
35
|
+
['/skill <이름>', '스킬 실행 (예: /skill commit)'],
|
|
36
|
+
['/clear', '현재 대화 기록 초기화'],
|
|
37
|
+
['/list', '현재 대화 기록 콘솔 출력'],
|
|
38
|
+
['/save', '대화 기록을 Markdown 파일로 저장'],
|
|
39
|
+
['/log', '로그 파일 현황 및 최근 에러 미리보기'],
|
|
40
|
+
['/mcp', '연결된 MCP 서버 목록 출력'],
|
|
41
|
+
['/baseDir', '작업 디렉토리 변경'],
|
|
42
|
+
['/help', '이 도움말 출력'],
|
|
43
|
+
['/exit', '프로그램 종료'],
|
|
44
|
+
];
|
|
45
|
+
console.log(chalk.bold('\n━━━ KYJ CLI 명령어 ━━━'));
|
|
46
|
+
for (const [cmd, desc] of cmds) {
|
|
47
|
+
console.log(` ${chalk.cyan(cmd.padEnd(20))} ${desc}`);
|
|
48
|
+
}
|
|
49
|
+
console.log(chalk.gray('\n Ctrl+C 로도 종료할 수 있습니다.'));
|
|
50
|
+
console.log(chalk.bold('━━━━━━━━━━━━━━━━━━━━━\n'));
|
|
51
|
+
askQuestion();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
program
|
|
55
|
+
.command('/baseDir')
|
|
56
|
+
.description('디렉토리 위치를 변경')
|
|
57
|
+
.action(async () => {
|
|
58
|
+
const { userInput } = await inquirer.prompt([{
|
|
59
|
+
type: 'input',
|
|
60
|
+
name: 'userInput',
|
|
61
|
+
message: chalk.green.bold('디렉토리 위치'),
|
|
62
|
+
}]);
|
|
63
|
+
console.log(userInput);
|
|
64
|
+
askQuestion();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
program
|
|
68
|
+
.command('/mcp')
|
|
69
|
+
.description('현재 설정된 MCP 서버 리스트를 출력합니다.')
|
|
70
|
+
.action(async () => {
|
|
71
|
+
const settings = await loadSettings();
|
|
72
|
+
const mcp = settings?.mcp;
|
|
73
|
+
if (!mcp?.mcpServers || Object.keys(mcp.mcpServers).length === 0) {
|
|
74
|
+
console.log(chalk.yellow('설정된 MCP 서버가 없습니다. settings.json의 mcp.mcpServers를 확인하세요.'));
|
|
75
|
+
} else {
|
|
76
|
+
console.log(chalk.bold('\n--- 현재 설정된 MCP 서버 ---'));
|
|
77
|
+
for (const [name, cfg] of Object.entries(mcp.mcpServers)) {
|
|
78
|
+
const type = cfg?.url ? 'url' : (cfg?.command ? 'stdio' : '?');
|
|
79
|
+
const desc = cfg?.url ? cfg.url : (cfg?.command ? `${cfg.command} ${(cfg.args || []).join(' ')}` : '');
|
|
80
|
+
console.log(chalk.cyan(` ${name}`), chalk.gray(`(${type})`), desc);
|
|
81
|
+
}
|
|
82
|
+
console.log(chalk.bold('---\n'));
|
|
83
|
+
}
|
|
84
|
+
askQuestion();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
program
|
|
88
|
+
.command('/clear')
|
|
89
|
+
.description('터미널 화면과 대화 기록을 모두 지웁니다.')
|
|
90
|
+
.action(() => {
|
|
91
|
+
resetThread();
|
|
92
|
+
console.clear();
|
|
93
|
+
console.log(chalk.yellow('✅ 채팅 기록과 화면이 지워졌습니다.'));
|
|
94
|
+
askQuestion();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
program
|
|
98
|
+
.command('/save')
|
|
99
|
+
.description('현재까지의 대화 내용을 Markdown 파일로 저장합니다.')
|
|
100
|
+
.action(async () => {
|
|
101
|
+
const timestamp = getTimestamp();
|
|
102
|
+
const fileName = `chathistory_${timestamp}.md`;
|
|
103
|
+
const state = await agent.getState({ configurable: { thread_id: getThreadId() } });
|
|
104
|
+
const messages = (state.values?.messages ?? []).filter(m => m._getType() === 'human' || m._getType() === 'ai');
|
|
105
|
+
|
|
106
|
+
if (messages.length === 0) {
|
|
107
|
+
console.log(chalk.yellow('✅ 채팅 기록이 없습니다.'));
|
|
108
|
+
} else {
|
|
109
|
+
let md = `# 📝 채팅 기록 (${timestamp})\n\n`;
|
|
110
|
+
for (const msg of messages) {
|
|
111
|
+
const type = msg._getType();
|
|
112
|
+
const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content, null, 2);
|
|
113
|
+
if (type === 'human') md += `**🧑 Human:**\n${content}\n\n---\n\n`;
|
|
114
|
+
else if (type === 'ai') md += `**🤖 AI:**\n${content}\n\n---\n\n`;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const safePath = getSafePath(fileName);
|
|
118
|
+
await fs.writeFile(safePath, md, 'utf-8');
|
|
119
|
+
console.log(chalk.yellow(`✅ 채팅 기록이 '${safePath}' 파일로 저장되었습니다.`));
|
|
120
|
+
} catch (error) {
|
|
121
|
+
console.error(chalk.red('❌ 파일 저장 중 오류:'), error.message);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
await saveHistory('/save');
|
|
125
|
+
askQuestion();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
program
|
|
129
|
+
.command('/list')
|
|
130
|
+
.description('현재까지의 대화 내용을 콘솔에 출력합니다.')
|
|
131
|
+
.action(async () => {
|
|
132
|
+
const state = await agent.getState({ configurable: { thread_id: getThreadId() } });
|
|
133
|
+
const messages = (state.values?.messages ?? []).filter(m => m._getType() === 'human' || m._getType() === 'ai');
|
|
134
|
+
|
|
135
|
+
if (messages.length === 0) {
|
|
136
|
+
console.log(chalk.yellow('✅ 채팅 기록이 없습니다.'));
|
|
137
|
+
} else {
|
|
138
|
+
console.log(chalk.bold('\n--- 📝 채팅 기록 ---'));
|
|
139
|
+
for (const msg of messages) {
|
|
140
|
+
const type = msg._getType();
|
|
141
|
+
const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content, null, 2);
|
|
142
|
+
if (type === 'human') console.log(`\n🧑 Human:\n${content}`);
|
|
143
|
+
else if (type === 'ai') console.log(`\n${chalk.blue.bold('🤖 AI:')}\n${content}`);
|
|
144
|
+
}
|
|
145
|
+
console.log(chalk.bold('\n--- 기록 끝 ---\n'));
|
|
146
|
+
}
|
|
147
|
+
await saveHistory('/list');
|
|
148
|
+
askQuestion();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
program
|
|
152
|
+
.command('/skills')
|
|
153
|
+
.description('사용 가능한 스킬 목록을 출력합니다.')
|
|
154
|
+
.action(async () => {
|
|
155
|
+
const skills = await loadSkills();
|
|
156
|
+
console.log(chalk.bold('\n--- ✨ 사용 가능한 스킬 ---'));
|
|
157
|
+
for (const [name, skill] of Object.entries(skills)) {
|
|
158
|
+
console.log(` ${chalk.cyan('/skill ' + name.padEnd(12))} ${skill.description}`);
|
|
159
|
+
}
|
|
160
|
+
console.log(chalk.gray('\n사용법: /skill <이름> [추가 입력]'));
|
|
161
|
+
console.log(chalk.gray('커스텀 스킬: skills.json 파일을 만들어 스킬을 추가/수정하세요.'));
|
|
162
|
+
console.log(chalk.bold('---\n'));
|
|
163
|
+
askQuestion();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
program
|
|
167
|
+
.command('/skill')
|
|
168
|
+
.description('스킬을 실행합니다. 예: /skill commit')
|
|
169
|
+
.argument('<name>', '스킬 이름')
|
|
170
|
+
.argument('[extra...]', '추가 입력 (선택)')
|
|
171
|
+
.action(async (name, extra) => {
|
|
172
|
+
const skills = await loadSkills();
|
|
173
|
+
const skill = skills[name];
|
|
174
|
+
if (!skill) {
|
|
175
|
+
console.log(chalk.yellow(`스킬 '${name}'을 찾을 수 없습니다. '/skills'로 목록을 확인하세요.`));
|
|
176
|
+
askQuestion();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const extraText = extra?.length > 0 ? '\n\n추가 지시: ' + extra.join(' ') : '';
|
|
180
|
+
console.log(chalk.gray(`[스킬 실행] ${name}: ${skill.description}`));
|
|
181
|
+
await handleChat(skill.prompt + extraText);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
program
|
|
185
|
+
.command('/log')
|
|
186
|
+
.description('로그 파일 현황을 보여줍니다.')
|
|
187
|
+
.action(async () => {
|
|
188
|
+
try {
|
|
189
|
+
await ensureLogDir();
|
|
190
|
+
const files = await fs.readdir(LOG_DIR);
|
|
191
|
+
const logFiles = files.filter(f => f.endsWith('.log')).sort().reverse();
|
|
192
|
+
if (logFiles.length === 0) {
|
|
193
|
+
console.log(chalk.yellow('아직 생성된 로그 파일이 없습니다.'));
|
|
194
|
+
} else {
|
|
195
|
+
console.log(chalk.bold(`\n--- 📋 로그 파일 목록 (${LOG_DIR}) ---`));
|
|
196
|
+
for (const f of logFiles) {
|
|
197
|
+
const stat = await fs.stat(path.join(LOG_DIR, f));
|
|
198
|
+
const kb = (stat.size / 1024).toFixed(1);
|
|
199
|
+
const color = f.startsWith('error') ? chalk.red : f.startsWith('chat') ? chalk.cyan : chalk.gray;
|
|
200
|
+
console.log(` ${color(f.padEnd(35))} ${chalk.gray(kb + ' KB')}`);
|
|
201
|
+
}
|
|
202
|
+
const todayError = path.join(LOG_DIR, `error_${logDate()}.log`);
|
|
203
|
+
try {
|
|
204
|
+
const raw = await fs.readFile(todayError, 'utf-8');
|
|
205
|
+
const lines = raw.trim().split('\n').filter(Boolean).slice(-5);
|
|
206
|
+
if (lines.length > 0) {
|
|
207
|
+
console.log(chalk.bold('\n--- 오늘 최근 에러 (최대 5건) ---'));
|
|
208
|
+
for (const line of lines) {
|
|
209
|
+
try {
|
|
210
|
+
const entry = JSON.parse(line);
|
|
211
|
+
console.log(chalk.red(` [${entry.ts}]`), entry.message);
|
|
212
|
+
if (entry.userInput) console.log(chalk.gray(` 입력: ${entry.userInput}`));
|
|
213
|
+
} catch (_) {
|
|
214
|
+
console.log(chalk.gray(' ' + line));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} catch (_) {
|
|
219
|
+
console.log(chalk.gray('\n 오늘 에러 없음'));
|
|
220
|
+
}
|
|
221
|
+
console.log(chalk.bold('---\n'));
|
|
222
|
+
}
|
|
223
|
+
} catch (err) {
|
|
224
|
+
console.error(chalk.red('로그 조회 실패:'), err.message);
|
|
225
|
+
}
|
|
226
|
+
askQuestion();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
program
|
|
230
|
+
.command('/exit')
|
|
231
|
+
.description('CLI 에이전트를 종료합니다.')
|
|
232
|
+
.action(async () => {
|
|
233
|
+
logger.system('shutdown');
|
|
234
|
+
console.log(chalk.yellow('프로그램을 종료합니다. 안녕히 계세요!'));
|
|
235
|
+
await closeMcp();
|
|
236
|
+
process.exit(0);
|
|
237
|
+
});
|
|
238
|
+
}
|
package/index.js
ADDED
package/lib/history.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
|
|
6
|
+
export const HISTORY_FILE = path.join(os.homedir(), '.kyj_cli_history');
|
|
7
|
+
|
|
8
|
+
export let commandHistory = [];
|
|
9
|
+
|
|
10
|
+
export async function loadHistory() {
|
|
11
|
+
try {
|
|
12
|
+
const data = await fs.readFile(HISTORY_FILE, 'utf-8');
|
|
13
|
+
commandHistory = data.split('\n').filter(line => line.trim() !== '');
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (error.code !== 'ENOENT') {
|
|
16
|
+
console.error(chalk.red('명령어 히스토리 로딩 실패:'), error);
|
|
17
|
+
}
|
|
18
|
+
commandHistory = [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function saveHistory(command) {
|
|
23
|
+
commandHistory.push(command);
|
|
24
|
+
if (commandHistory.length > 100) commandHistory.shift();
|
|
25
|
+
try {
|
|
26
|
+
await fs.appendFile(HISTORY_FILE, command + '\n', 'utf-8');
|
|
27
|
+
} catch (error) {
|
|
28
|
+
console.error(chalk.red('명령어 히스토리 저장 실패:'), error);
|
|
29
|
+
}
|
|
30
|
+
}
|
package/lib/logger.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
const BASE_DIR = process.cwd();
|
|
5
|
+
export const LOG_DIR = path.join(BASE_DIR, 'logs');
|
|
6
|
+
|
|
7
|
+
export function logDate() {
|
|
8
|
+
return new Date().toISOString().slice(0, 10);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getTimestamp() {
|
|
12
|
+
return new Date().toISOString().replace(/[:.]/g, '-');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let _logDirReady = false;
|
|
16
|
+
export async function ensureLogDir() {
|
|
17
|
+
if (_logDirReady) return;
|
|
18
|
+
try {
|
|
19
|
+
await fs.mkdir(LOG_DIR, { recursive: true });
|
|
20
|
+
_logDirReady = true;
|
|
21
|
+
} catch (_) {}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function writeLog(type, data) {
|
|
25
|
+
try {
|
|
26
|
+
await ensureLogDir();
|
|
27
|
+
const file = path.join(LOG_DIR, `${type}_${logDate()}.log`);
|
|
28
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), ...data }) + '\n';
|
|
29
|
+
await fs.appendFile(file, line, 'utf-8');
|
|
30
|
+
} catch (_) {}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const logger = {
|
|
34
|
+
chat: (userInput, aiOutput, durationMs, tools = []) =>
|
|
35
|
+
writeLog('chat', { user: userInput, ai: aiOutput, durationMs, tools }),
|
|
36
|
+
|
|
37
|
+
error: (err, context = {}) =>
|
|
38
|
+
writeLog('error', {
|
|
39
|
+
message: err?.message ?? String(err),
|
|
40
|
+
stack: err?.stack ?? null,
|
|
41
|
+
...context,
|
|
42
|
+
}),
|
|
43
|
+
|
|
44
|
+
system: (event, data = {}) =>
|
|
45
|
+
writeLog('system', { event, ...data }),
|
|
46
|
+
};
|
package/lib/mcp.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
|
|
5
|
+
const BASE_DIR = process.cwd();
|
|
6
|
+
const SETTINGS_PATH = path.join(BASE_DIR, 'settings.json');
|
|
7
|
+
|
|
8
|
+
export async function loadSettings() {
|
|
9
|
+
try {
|
|
10
|
+
const raw = await fs.readFile(SETTINGS_PATH, 'utf-8');
|
|
11
|
+
return JSON.parse(raw);
|
|
12
|
+
} catch (e) {
|
|
13
|
+
if (e.code === 'ENOENT') return {};
|
|
14
|
+
throw e;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function loadMcpToolsFromSettings(settings) {
|
|
19
|
+
const mcp = settings?.mcp;
|
|
20
|
+
if (!mcp?.mcpServers || Object.keys(mcp.mcpServers).length === 0) {
|
|
21
|
+
return { tools: [], mcpClient: null };
|
|
22
|
+
}
|
|
23
|
+
if (mcp.enabled === false) return { tools: [], mcpClient: null };
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const { MultiServerMCPClient } = await import('@langchain/mcp-adapters');
|
|
27
|
+
const serverEntries = {};
|
|
28
|
+
for (const [name, cfg] of Object.entries(mcp.mcpServers)) {
|
|
29
|
+
if (!cfg) continue;
|
|
30
|
+
if (cfg.enabled === false) continue;
|
|
31
|
+
if (cfg.command && cfg.args) {
|
|
32
|
+
serverEntries[name] = {
|
|
33
|
+
transport: 'stdio',
|
|
34
|
+
command: cfg.command,
|
|
35
|
+
args: Array.isArray(cfg.args) ? cfg.args : [cfg.args],
|
|
36
|
+
...(cfg.env && { env: cfg.env }),
|
|
37
|
+
...(cfg.restart && { restart: cfg.restart }),
|
|
38
|
+
};
|
|
39
|
+
} else if (cfg.url) {
|
|
40
|
+
serverEntries[name] = {
|
|
41
|
+
transport: cfg.transport === 'sse' ? 'sse' : 'http',
|
|
42
|
+
url: cfg.url,
|
|
43
|
+
...(cfg.headers && { headers: cfg.headers }),
|
|
44
|
+
...(cfg.reconnect && { reconnect: cfg.reconnect }),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (Object.keys(serverEntries).length === 0) return { tools: [], mcpClient: null };
|
|
49
|
+
|
|
50
|
+
const client = new MultiServerMCPClient({
|
|
51
|
+
throwOnLoadError: false,
|
|
52
|
+
onConnectionError: mcp.onConnectionError ?? 'ignore',
|
|
53
|
+
prefixToolNameWithServerName: mcp.prefixToolNameWithServerName ?? false,
|
|
54
|
+
useStandardContentBlocks: true,
|
|
55
|
+
mcpServers: serverEntries,
|
|
56
|
+
});
|
|
57
|
+
const mcpTools = await client.getTools();
|
|
58
|
+
return { tools: mcpTools ?? [], mcpClient: client };
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.error(chalk.yellow('MCP 로드 실패 (기본 도구만 사용):'), err.message);
|
|
61
|
+
return { tools: [], mcpClient: null };
|
|
62
|
+
}
|
|
63
|
+
}
|