Files
dstalk/dstalk-cli/src/main.cpp
XiuChengWu e6f24f00f1 Refactor to plugin architecture with B3 CLI UX, C2 smoke tests, C3 CI scripts
Architecture overhaul (Wave 1-4 collaborative work):
- Migrated dstalk-core from monolithic api.cpp to plugin-based design with
  host/service_registry/event_bus/plugin_loader and topological initialization.
- Split public headers into dstalk_host.h / dstalk_services.h /
  dstalk_lsp.h / dstalk_types.h; deleted obsolete dstalk_api.h and inlined
  TLS/file/net code now provided by plugins.
- Added 9 plugins: deepseek, anthropic, network, session, context, tools,
  config, file-io, lsp; AI plugins register as "ai.<provider>" services.

B3 CLI interaction enhancement:
- Prompt now shows current model name (A1).
- /status command prints model/base_url/api_key (sanitized: shown only
  as set/unset)/services readiness (A2).
- SIGINT/Ctrl+C handled on POSIX (signal) and Windows (SetConsoleCtrlHandler);
  /quit no longer std::exit(0) but sets a quit flag so dstalk_shutdown runs
  exactly once via natural control flow (B1+B2).
- Cross-DLL free fixed: print_file uses dstalk_free instead of std::free (B4).
- --batch mode plus isatty auto-detection for piped stdin (C1).
- fgets truncation detection with friendly error and stdin draining (C3).
- Distinct exit codes (init/AI/service-unavailable) (C4).
- /model rejects empty model name (C5).

C2 smoke test extension:
- 4 new test blocks: null-safety (file_io/session/tools/config),
  escape-boundary round-trip, tools->execute call chain, session robustness
  (add(nullptr), clear -> token_count == 0).

C3 CI build scripts:
- scripts/ci-build.sh and scripts/ci-build.bat invoke cmake configure +
  parallel build + ctest, suitable for GitHub Actions.

Build verified: dstalk-cli compiles, smoke test passes via ctest.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-27 05:12:56 +08:00

469 lines
16 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================================
// dstalk-cli — 命令行前端 (使用插件化架构)
// ============================================================================
// 通过 dstalk_host.h API 初始化核心,然后查询插件服务 vtable 调用功能。
// ============================================================================
#include <algorithm>
#include <atomic>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <string>
#include <system_error>
#include <vector>
#ifdef _WIN32
#include <windows.h>
#include <io.h>
#else
#include <signal.h>
#include <termios.h>
#include <unistd.h>
#endif
#include "dstalk/dstalk_host.h"
// ---- ANSI 简写 ----
#define CLR_RESET "\033[0m"
#define CLR_CYAN "\033[36m"
#define CLR_YELLOW "\033[33m"
#define CLR_GREEN "\033[32m"
#define CLR_RED "\033[31m"
#define CLR_DIM "\033[2m"
#define CLR_BOLD "\033[1m"
// ---- 退出码 ----
#define EXIT_OK 0
#define EXIT_INIT_FAIL 1
#define EXIT_AI_ERROR 2
#define EXIT_SVC_UNAVAIL 3
// ---- 服务 vtable 指针 ----
static const dstalk_ai_service_t* g_ai = nullptr;
static const dstalk_session_service_t* g_session = nullptr;
static const dstalk_file_io_service_t* g_file_io = nullptr;
// ---- 运行时状态 ----
static std::string g_current_model;
static std::atomic<bool> g_quit_requested{false};
// ---- Ctrl+C 信号处理 ----
#ifdef _WIN32
static BOOL WINAPI on_console_event(DWORD event)
{
if (event == CTRL_C_EVENT || event == CTRL_BREAK_EVENT) {
g_quit_requested = true;
return TRUE;
}
return FALSE;
}
#else
static void on_signal(int /*sig*/)
{
g_quit_requested = true;
}
#endif
// ---- 工具函数 ----
static void print_banner()
{
std::printf("%sdstalk v0.1.0%s | %sdstalk AI%s | "
"%s/help%s 查看帮助 | %s/quit%s 退出\n",
CLR_CYAN CLR_BOLD, CLR_RESET,
CLR_GREEN, CLR_RESET,
CLR_DIM, CLR_RESET,
CLR_DIM, CLR_RESET);
}
static void print_help()
{
std::printf("\n%s命令列表:%s\n", CLR_BOLD, CLR_RESET);
std::printf(" %s/help%s 显示此帮助\n", CLR_YELLOW, CLR_RESET);
std::printf(" %s/quit%s 退出程序\n", CLR_YELLOW, CLR_RESET);
std::printf(" %s/clear%s 清空当前会话上下文\n", CLR_YELLOW, CLR_RESET);
std::printf(" %s/context%s 显示当前 Token 数和消息条数\n", CLR_YELLOW, CLR_RESET);
std::printf(" %s/status%s 显示当前运行状态(脱敏)\n", CLR_YELLOW, CLR_RESET);
std::printf(" %s/model <name>%s 切换模型\n", CLR_YELLOW, CLR_RESET);
std::printf(" %s/file list [path]%s 列出目录内容\n", CLR_YELLOW, CLR_RESET);
std::printf(" %s/file show <path>%s 查看文件内容\n", CLR_YELLOW, CLR_RESET);
std::printf(" %s/file read <path>%s 读取文件内容\n", CLR_YELLOW, CLR_RESET);
std::printf(" %s/file write <p> <c>%s 写入文件\n", CLR_YELLOW, CLR_RESET);
std::printf(" %s/save <path>%s 保存会话\n", CLR_YELLOW, CLR_RESET);
std::printf(" %s/load <path>%s 恢复会话\n", CLR_YELLOW, CLR_RESET);
std::printf("\n直接输入问题即可与 AI 对话。\n\n");
}
static void print_file(const char* path)
{
while (*path == ' ') path++;
if (!g_file_io) {
std::printf(CLR_RED "[ERROR] file_io 服务不可用\n" CLR_RESET);
return;
}
char* content = nullptr;
if (g_file_io->read(path, &content) == 0 && content) {
std::printf("%s--- %s ---%s\n", CLR_DIM, path, CLR_RESET);
std::printf("%s\n", content);
std::printf(CLR_DIM "--- EOF ---\n" CLR_RESET);
dstalk_free(content);
} else {
std::printf(CLR_RED "[ERROR] 无法读取: %s\n" CLR_RESET, path);
}
}
static void list_files(const char* path)
{
while (*path == ' ') path++;
std::filesystem::path dir = *path ? std::filesystem::path(path) : std::filesystem::current_path();
std::error_code ec;
if (!std::filesystem::exists(dir, ec) || !std::filesystem::is_directory(dir, ec)) {
std::printf(CLR_RED "[ERROR] 不是有效目录: %s\n" CLR_RESET, dir.string().c_str());
return;
}
std::vector<std::filesystem::directory_entry> entries;
for (const auto& entry : std::filesystem::directory_iterator(dir, ec)) {
if (ec) break;
entries.push_back(entry);
}
if (ec) {
std::printf(CLR_RED "[ERROR] 无法列出目录: %s\n" CLR_RESET, dir.string().c_str());
return;
}
std::sort(entries.begin(), entries.end(), [](const auto& a, const auto& b) {
return a.path().filename().string() < b.path().filename().string();
});
std::printf("%s--- %s ---%s\n", CLR_DIM, dir.string().c_str(), CLR_RESET);
for (const auto& entry : entries) {
std::error_code status_ec;
const bool is_dir = entry.is_directory(status_ec);
std::printf(" %s%s%s\n", is_dir ? CLR_CYAN : "", entry.path().filename().string().c_str(), is_dir ? "/" CLR_RESET : "");
}
}
static void handle_command(const char* line)
{
if (!line || line[0] != '/') return;
// /quit —— 设置退出标志,让控制流自然回到 main 末尾
if (std::strcmp(line, "/quit") == 0 || std::strcmp(line, "/q") == 0) {
g_quit_requested = true;
std::printf("再见!\n");
return;
}
// /help
if (std::strcmp(line, "/help") == 0 || std::strcmp(line, "/h") == 0) {
print_help();
return;
}
// /clear
if (std::strcmp(line, "/clear") == 0) {
if (g_session) g_session->clear();
std::printf(CLR_GREEN "[OK] 会话已清空\n" CLR_RESET);
return;
}
// /context
if (std::strcmp(line, "/context") == 0) {
if (g_session) {
int count = 0;
g_session->history(&count);
int tokens = g_session->token_count();
std::printf(CLR_DIM "消息条数: " CLR_RESET "%d | "
CLR_DIM "Token 估算: " CLR_RESET "%d\n",
count, tokens);
}
return;
}
// /status —— 脱敏显示当前运行状态
if (std::strcmp(line, "/status") == 0) {
const char* provider = dstalk_config_get("ai.provider");
if (!provider) provider = "ai.deepseek";
const char* base_url = dstalk_config_get("api.base_url");
if (!base_url) base_url = "https://api.deepseek.com/v1";
const char* api_key = dstalk_config_get("api.api_key");
std::printf(" 模型: %s\n", g_current_model.empty() ? "(未设置)" : g_current_model.c_str());
std::printf(" base_url: %s\n", base_url ? base_url : "(未设置)");
std::printf(" api_key: %s\n", (api_key && api_key[0]) ? "已设置" : "未设置");
std::printf(" provider: %s\n", provider);
std::printf(" AI 服务: %s\n", g_ai ? "就绪" : "不可用");
std::printf(" Session 服务: %s\n", g_session ? "就绪" : "不可用");
std::printf(" File IO 服务: %s\n", g_file_io ? "就绪" : "不可用");
const dstalk_tools_service_t* tools = static_cast<const dstalk_tools_service_t*>(
dstalk_service_query("tools", 1));
std::printf(" Tools 服务: %s\n", tools ? "就绪" : "不可用");
return;
}
// /model <name>
if (std::strncmp(line, "/model ", 7) == 0) {
const char* model = line + 7;
while (*model == ' ') model++;
if (*model == '\0') {
std::printf(CLR_RED "[ERROR] /model 需要模型名\n" CLR_RESET);
return;
}
if (g_ai) {
g_ai->configure(nullptr, nullptr, nullptr, model, 0, 0.0);
g_current_model = model;
std::printf(CLR_GREEN "[OK] 模型已切换: %s\n" CLR_RESET, model);
} else {
std::printf(CLR_RED "[ERROR] AI 服务不可用\n" CLR_RESET);
}
return;
}
// /file list [path]
if (std::strcmp(line, "/file list") == 0 || std::strncmp(line, "/file list ", 11) == 0) {
const char* path = line + 10;
list_files(path);
return;
}
// /file show <path>
if (std::strncmp(line, "/file show ", 11) == 0) {
print_file(line + 11);
return;
}
// /file read <path>
if (std::strncmp(line, "/file read ", 11) == 0) {
print_file(line + 11);
return;
}
// /file write <path> <content...>
if (std::strncmp(line, "/file write ", 12) == 0) {
const char* rest = line + 12;
while (*rest == ' ') rest++;
const char* space = std::strchr(rest, ' ');
if (!space) {
std::printf(CLR_RED "[ERROR] 用法: /file write <path> <content>\n" CLR_RESET);
return;
}
std::string path(rest, space - rest);
const char* content = space + 1;
while (*content == ' ') content++;
if (g_file_io && g_file_io->write(path.c_str(), content) == 0) {
std::printf(CLR_GREEN "[OK] 已写入: %s\n" CLR_RESET, path.c_str());
} else {
std::printf(CLR_RED "[ERROR] 写入失败: %s\n" CLR_RESET, path.c_str());
}
return;
}
// /save <path>
if (std::strncmp(line, "/save ", 6) == 0) {
const char* path = line + 6;
while (*path == ' ') path++;
if (g_session && g_session->save(path) == 0) {
std::printf(CLR_GREEN "[OK] 会话已保存: %s\n" CLR_RESET, path);
} else {
std::printf(CLR_RED "[ERROR] 保存失败: %s\n" CLR_RESET, path);
}
return;
}
// /load <path>
if (std::strncmp(line, "/load ", 6) == 0) {
const char* path = line + 6;
while (*path == ' ') path++;
if (g_session && g_session->load(path) == 0) {
std::printf(CLR_GREEN "[OK] 会话已恢复: %s\n" CLR_RESET, path);
} else {
std::printf(CLR_RED "[ERROR] 恢复失败: %s\n" CLR_RESET, path);
}
return;
}
std::printf(CLR_RED "未知命令: %s (输入 /help 查看帮助)\n" CLR_RESET, line);
}
// ---- 流式回调 ----
static int on_stream_token(const char* token, void* userdata)
{
bool* first = static_cast<bool*>(userdata);
if (*first) {
std::printf(CLR_GREEN);
*first = false;
}
std::printf("%s", token);
std::fflush(stdout);
return 0;
}
// ---- 主程序 ----
int main(int argc, char* argv[])
{
// Windows: 启用 ANSI 转义码支持
#ifdef _WIN32
HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);
DWORD mode = 0;
GetConsoleMode(hOut, &mode);
SetConsoleMode(hOut, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
#endif
// ---- C1: batch 模式检测 ----
bool batch_mode = false;
for (int i = 1; i < argc; ++i) {
if (std::strcmp(argv[i], "--batch") == 0) {
batch_mode = true;
break;
}
}
#ifdef _WIN32
if (!batch_mode && _isatty(_fileno(stdin)) == 0) batch_mode = true;
#else
if (!batch_mode && isatty(fileno(stdin)) == 0) batch_mode = true;
#endif
// ---- B1: 安装 Ctrl+C 处理 ----
#ifdef _WIN32
SetConsoleCtrlHandler(on_console_event, TRUE);
#else
signal(SIGINT, on_signal);
#endif
// 查找配置文件
const char* config_path = nullptr;
if (argc >= 2) {
// 跳过 --batch 标志
for (int i = 1; i < argc; ++i) {
if (std::strcmp(argv[i], "--batch") != 0) {
config_path = argv[i];
break;
}
}
}
if (!config_path) {
const char* default_configs[] = {"config.toml", nullptr};
for (int i = 0; default_configs[i]; i++) {
FILE* f = nullptr;
#ifdef _WIN32
fopen_s(&f, default_configs[i], "r");
#else
f = fopen(default_configs[i], "r");
#endif
if (f) {
fclose(f);
config_path = default_configs[i];
break;
}
}
}
// 初始化主机(加载配置 + 自动扫描 plugins/ 目录加载插件)
if (dstalk_init(config_path) != 0) {
std::fprintf(stderr, CLR_RED "[dstalk] 初始化失败\n" CLR_RESET);
return EXIT_INIT_FAIL;
}
// 查询插件服务
const char* ai_provider = dstalk_config_get("ai.provider");
if (!ai_provider) ai_provider = "ai.deepseek";
g_ai = static_cast<const dstalk_ai_service_t*>(dstalk_service_query(ai_provider, 1));
g_session = static_cast<const dstalk_session_service_t*>(dstalk_service_query("session", 1));
g_file_io = static_cast<const dstalk_file_io_service_t*>(dstalk_service_query("file_io", 1));
if (!g_ai) {
std::fprintf(stderr, CLR_RED "[dstalk] AI 服务未找到(请检查插件目录)\n" CLR_RESET);
}
if (!g_session) {
std::fprintf(stderr, CLR_RED "[dstalk] Session 服务未找到\n" CLR_RESET);
}
// 自动从配置加载 AI 设置
if (g_ai) {
const char* base_url = dstalk_config_get("api.base_url");
const char* api_key = dstalk_config_get("api.api_key");
const char* model = dstalk_config_get("api.model");
if (!base_url) base_url = "https://api.deepseek.com/v1";
if (!model) model = "deepseek-v4-pro";
g_ai->configure(ai_provider, base_url, api_key ? api_key : "", model, 4096, 0.7);
g_current_model = model; // A1: 记录当前模型名
}
if (!batch_mode) {
std::printf("\n");
print_banner();
std::printf("\n");
}
char buffer[8192];
while (true) {
// B1: 检查退出标志
if (g_quit_requested) break;
// A1: 提示符带模型名batch 模式不打印)
if (!batch_mode) {
std::printf(CLR_CYAN "[%s] " CLR_RESET CLR_YELLOW "> " CLR_RESET,
g_current_model.empty() ? "?" : g_current_model.c_str());
std::fflush(stdout);
}
if (!std::fgets(buffer, sizeof(buffer), stdin)) break;
// C3: fgets 截断检测
if (!std::strchr(buffer, '\n') && !feof(stdin)) {
std::fprintf(stderr, CLR_RED "[ERROR] 输入超过 8KB已截断。建议用文件方式dstalk --batch < file.txt\n" CLR_RESET);
int c;
while ((c = std::fgetc(stdin)) != '\n' && c != EOF) {}
}
// 去除末尾换行
size_t len = std::strlen(buffer);
while (len > 0 && (buffer[len-1] == '\n' || buffer[len-1] == '\r')) {
buffer[--len] = '\0';
}
if (len == 0) continue;
// 命令处理
if (buffer[0] == '/') {
handle_command(buffer);
continue;
}
// AI 对话(通过插件服务 vtable
if (!g_ai || !g_session) {
std::printf(CLR_RED "[ERROR] AI 或 Session 服务不可用\n" CLR_RESET);
continue;
}
// 获取会话历史
int history_count = 0;
const dstalk_message_t* history = g_session->history(&history_count);
bool first = true;
dstalk_chat_result_t result = g_ai->chat_stream(
history, history_count, buffer, on_stream_token, &first);
if (result.ok) {
std::printf(CLR_RESET "\n\n");
// 将用户消息和 AI 回复添加到会话
dstalk_message_t user_msg = {"user", buffer, nullptr, nullptr};
g_session->add(&user_msg);
dstalk_message_t ai_msg = {"assistant", result.content, nullptr, result.tool_calls_json};
g_session->add(&ai_msg);
} else {
// A3: error 路径下需 NULL 保护;当前只取 result.errorcontent 未涉及
std::printf(CLR_RESET "\n" CLR_RED "[ERROR] AI 调用失败: %s\n" CLR_RESET,
result.error ? result.error : "unknown error");
}
g_ai->free_result(&result);
}
// B2: 单一退出点dstalk_shutdown 只在此调用
dstalk_shutdown();
return EXIT_OK;
}