From b0bbc57880271f6f3703ad1265cffcc4f63dfee6 Mon Sep 17 00:00:00 2001 From: subochev Date: Wed, 16 Sep 2026 13:42:12 +0300 Subject: [PATCH] =?UTF-8?q?feat(agentik-cli):=20REPL-=D0=BA=D0=BB=D0=B8?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=20=D0=BD=D0=B0=20=D0=B1=D0=B0=D0=B7=D0=B5=20?= =?UTF-8?q?:client=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D1=81=D0=B5=D1=85=20KMP-?= =?UTF-8?q?=D1=86=D0=B5=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый KMP-модуль :agentik-cli — REPL поверх HTTP-фасада :server. commonMain (~900 строк): - Main.kt — точка входа + парсинг --server/--id/--no-history/--help - CliPlatform.kt — expect-фабрика Agent + CliTerminal + SessionIo + env() - SlashCommand.kt — 11 slash-команд: /help /new /list /sw /rename /rm /interrupt /history /pwd /exit /quit - AgentikCli.kt (~350 строк) — главный REPL-цикл: readLine → parse → send → render events. Сохраняет lastEventAt в ~/.agentik/cli-state.json. - EventRenderer.kt — печатает SSE-события (StartResponse/AppendText/AppendImage/ End/Interrupted/Error) с правильным разделением text/image и переводом строки на end. - SessionRepository.kt — JSON-state (conversationId, lastEventAt) + IO-интерфейс SessionIo. jvmMain — JLine-терминал (LineReader+history, стрелки, Ctrl-D/E, автосейв истории), java.io-based atomic-IO для state-файла, real System.getenv. nativeMain — stub actuals (kotlin.Result-error с подсказкой куда копать): подключение native ktor-движков (darwin/curl/okhttp) и termios через kotlinx.cinterop — отдельная задача. Все 8 KMP-целей (jvm/macosX64/macosArm64/ iosX64/iosArm64/iosSimulatorArm64/linuxX64/linuxArm64/mingwX64) компилируются. shadowJar собирает self-contained fatjar (~8.5 MB). 23 unit-теста зелёные. Заодно фикс бага в :client — InstantSerializer.descriptor имел имя 'kotlin.time.Instant', которое kotlinx-serialization 1.6+ резервирует за встроенным сериализатором, из-за чего client падал на старте с 'there already exists InstantSerializer'. Переименовано в 'pw.binom.agentik.Instant' — зеркально с :server. Smoke-тест на 192.168.76.166: /new + 'привет'/'2+2' отвечает корректно, state-файл создаётся в /root/.agentik/cli-state.json, /list возвращает 125 диалогов. --- agentik-cli/build.gradle.kts | 102 ++++++ .../kotlin/pw/binom/agentik/cli/AgentikCli.kt | 323 ++++++++++++++++++ .../pw/binom/agentik/cli/CliPlatform.kt | 52 +++ .../pw/binom/agentik/cli/EventRenderer.kt | 82 +++++ .../kotlin/pw/binom/agentik/cli/Main.kt | 105 ++++++ .../pw/binom/agentik/cli/SessionRepository.kt | 81 +++++ .../pw/binom/agentik/cli/SlashCommand.kt | 90 +++++ .../pw/binom/agentik/cli/EventRendererTest.kt | 81 +++++ .../agentik/cli/SessionRepositoryTest.kt | 108 ++++++ .../pw/binom/agentik/cli/SlashCommandTest.kt | 101 ++++++ .../pw/binom/agentik/cli/Platform.jvm.kt | 151 ++++++++ .../pw/binom/agentik/cli/Platform.native.kt | 36 ++ .../pw/binom/agentik/client/Serialization.kt | 5 +- gradle/libs.versions.toml | 4 + settings.gradle.kts | 3 + 15 files changed, 1323 insertions(+), 1 deletion(-) create mode 100644 agentik-cli/build.gradle.kts create mode 100644 agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/AgentikCli.kt create mode 100644 agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/CliPlatform.kt create mode 100644 agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/EventRenderer.kt create mode 100644 agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/Main.kt create mode 100644 agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/SessionRepository.kt create mode 100644 agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/SlashCommand.kt create mode 100644 agentik-cli/src/commonTest/kotlin/pw/binom/agentik/cli/EventRendererTest.kt create mode 100644 agentik-cli/src/commonTest/kotlin/pw/binom/agentik/cli/SessionRepositoryTest.kt create mode 100644 agentik-cli/src/commonTest/kotlin/pw/binom/agentik/cli/SlashCommandTest.kt create mode 100644 agentik-cli/src/jvmMain/kotlin/pw/binom/agentik/cli/Platform.jvm.kt create mode 100644 agentik-cli/src/nativeMain/kotlin/pw/binom/agentik/cli/Platform.native.kt diff --git a/agentik-cli/build.gradle.kts b/agentik-cli/build.gradle.kts new file mode 100644 index 0000000..489f0f4 --- /dev/null +++ b/agentik-cli/build.gradle.kts @@ -0,0 +1,102 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi + +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import org.gradle.api.artifacts.ConfigurationContainer + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.shadow) +} + +kotlin { + jvmToolchain(21) + + // Suppress Beta-предупреждения от expect/actual объектов — фича стабильна с Kotlin 1.9, + // но компилятор всё ещё требует -Xexpect-actual-classes, чтобы не ныть. + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + + // "Все возможные цели сборки": jvm + весь натив. Зеркалит набор :server/:proto. + // commonMain зависит только от :proto (KMP). jvmMain подключает :client (JVM-only) + // и JLine — там же и `:client`'s AgentClient. nativeMain пока получает stub actual, + // расширять будем через ktor-client-* {curl,darwin,winhttp} когда дойдёт очередь. + jvm() + macosX64() + macosArm64() + iosX64() + iosArm64() + iosSimulatorArm64() + linuxX64() + linuxArm64() + mingwX64() + + sourceSets { + commonMain.dependencies { + implementation(project(":proto")) + + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.core) + implementation(libs.kotlinx.serialization.json) + } + jvmMain.dependencies { + // :client JVM-only (ktor-cio). Подключаем только в jvmMain. + implementation(project(":client")) + // JLine для readline с историей и completion. + implementation(libs.jline) + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.core) + // runTest { } — suspend test runner для commonTest. + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0") + } + jvmTest.dependencies { + // JUnit нужен в jvmTest — kotlin-test на JVM = JUnit4. + implementation("junit:junit:4.13.2") + } + } + + @OptIn(ExperimentalKotlinGradlePluginApi::class) + jvm { + binaries { + executable { + mainClass.set("pw.binom.agentik.cli.MainKt") + } + } + } +} + +// --- Fatjar (uberjar) --- +// +// По аналогии с :standalone: shadowJar берёт `jvmJar` + `jvmRuntimeClasspath`. +// Shadow 8.x не авторегистрирует shadowJar в KMP-проектах — нужно явно register. + +val shadowJarTask = tasks.register("shadowJar") { + archiveBaseName.set("agentik-cli") + archiveClassifier.set("all") + description = "Self-contained fatjar with all runtime dependencies bundled." + group = "build" + + from(tasks.named("jvmJar")) + val cc = try { + @Suppress("UNCHECKED_CAST") + configurations as org.gradle.api.artifacts.ConfigurationContainer + } catch (_: ClassCastException) { + @Suppress("UNCHECKED_CAST") + (project as org.gradle.api.Project).configurations as org.gradle.api.artifacts.ConfigurationContainer + } + from(cc.getByName("jvmRuntimeClasspath")) + + mergeServiceFiles() + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + + manifest { + attributes["Main-Class"] = "pw.binom.agentik.cli.MainKt" + attributes["Implementation-Title"] = "agentik-cli" + attributes["Implementation-Version"] = project.version.toString() + } + + includeEmptyDirs = false +} diff --git a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/AgentikCli.kt b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/AgentikCli.kt new file mode 100644 index 0000000..5aa7298 --- /dev/null +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/AgentikCli.kt @@ -0,0 +1,323 @@ +package pw.binom.agentik.cli + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import pw.binom.agentik.proto.Agent +import pw.binom.agentik.proto.Content +import pw.binom.agentik.proto.Conversation +import pw.binom.agentik.proto.Event +import pw.binom.agentik.proto.Message +import kotlin.time.Instant + +/** + * Главный класс REPL. + * + * Управляет: + * - текущим диалогом ([currentConv]) + позицией в его event-stream ([lastEventAt]); + * - фоновым job'ом, слушающим events и рендерящим их через [EventRenderer]. + * - персистентностью сессии (восстановление последнего диалога при перезапуске CLI). + * + * Один ход = один заход в REPL: пока идёт turn, REPL ждёт его завершения. + * `/interrupt` стучится в [Conversation.interrupt] — фоновый подписчик событий + * увидит [Event.Interrupted] и сам завершится. + */ +class AgentikCli internal constructor(private val config: CliConfig) { + + private val agent: Agent = CliPlatform.openAgent(baseUrl = config.server, id = config.id) + private val terminal: CliTerminal = CliPlatform.openTerminal( + historyFile = if (config.historyEnabled) stateFilePath() else null, + prompt = "agentik> ", + ) + private val sessionRepo = SessionRepository( + filePath = if (config.historyEnabled) stateFilePath() else null, + io = CliPlatform.sessionIo(), + ) + + private var currentConv: Conversation? = null + private var currentTitle: String? = null + private var lastEventAt: Instant = Instant.DISTANT_PAST + + private val scope = CoroutineScope(Dispatchers.Default) + + suspend fun run() { + try { + // Восстановление сессии. + val saved = sessionRepo.load() + if (saved != null) { + val conv = runCatching { agent.getConversation(saved.conversationId) } + .getOrNull() + if (conv != null) { + currentConv = conv + currentTitle = conv.title + lastEventAt = saved.lastEventAt + terminal.printSystem( + "восстановлен диалог ${shorten(conv.id)}" + + " (${conv.title ?: "без названия"})", + ) + } else { + terminal.printSystem( + "прошлый диалог ${shorten(saved.conversationId)} больше не существует", + ) + } + } + + printBanner() + + // Главный цикл. + while (scope.isActive) { + terminal.print(prompt()) + val line = terminal.readLine() ?: break // EOF → выходим + val trimmed = line.trim() + if (trimmed.isEmpty()) continue + + if (trimmed.startsWith("/")) { + when (val r = parseSlash(trimmed.substring(1))) { + is ParseResult.Success -> { + if (handleCommand(r.command) == CommandResult.Exit) break + } + is ParseResult.Failure -> terminal.printSystem(r.message) + } + } else { + handleUserMessage(trimmed) + } + } + } finally { + terminal.printSystem("до свидания.") + currentConv?.close() + terminal.close() + sessionRepo.close() + scope.cancel() + } + } + + // ============================================================ banner / prompt + + private suspend fun printBanner() { + terminal.println() + terminal.println("agentik-cli — id=${config.id} — type /help") + terminal.println("server: ${config.server}") + when (val c = currentConv) { + null -> terminal.println("диалог: не выбран — начните с /new или /switch ") + else -> terminal.println("диалог: ${shorten(c.id)} (${c.title ?: "без названия"})") + } + terminal.println() + } + + private fun prompt(): String = "agentik${if (currentConv != null) "" else " (-)"}> " + + private suspend fun printHelp() { + terminal.println( + """ + |Slash-команды: + | /help эта справка + | /new [title] создать новый диалог + | /list, /ls список диалогов (новые сверху) + | /switch , /sw переключиться на диалог по id + | /rename переименовать текущий диалог + | /delete [<id>], /rm удалить диалог (по id или текущий) + | /history, /h последние сообщения текущего диалога + | /interrupt, /stop прервать текущий ход + | /pwd показать текущий диалог + | /exit, /quit выйти (Ctrl-D тоже) + | + |Любой ввод без ведущего `/` отправляется агенту в текущий диалог. + """.trimMargin(), + ) + } + + // ============================================================ command dispatch + + private suspend fun handleCommand(cmd: SlashCommand): CommandResult = when (cmd) { + SlashCommand.Help -> { printHelp(); CommandResult.Continue } + SlashCommand.Exit, SlashCommand.Quit -> CommandResult.Exit + is SlashCommand.New -> { handleNew(cmd.title); CommandResult.Continue } + SlashCommand.List -> { handleList(); CommandResult.Continue } + is SlashCommand.Switch -> { handleSwitch(cmd.id); CommandResult.Continue } + is SlashCommand.Rename -> { handleRename(cmd.title); CommandResult.Continue } + is SlashCommand.Delete -> { handleDelete(cmd.id); CommandResult.Continue } + SlashCommand.Interrupt -> { handleInterrupt(); CommandResult.Continue } + SlashCommand.History -> { handleHistory(); CommandResult.Continue } + SlashCommand.Pwd -> { handlePwd(); CommandResult.Continue } + } + + private suspend fun handleNew(title: String?) { + val conv = agent.createConversation(temp = false) + if (title != null) conv.rename(title) + currentConv = conv + currentTitle = title ?: conv.title + lastEventAt = Instant.DISTANT_PAST + terminal.printSystem("создан диалог ${shorten(conv.id)}" + if (title != null) " — «$title»" else "") + sessionRepo.save(conv.id, lastEventAt) + } + + private suspend fun handleList() { + terminal.println("диалоги (новые сверху):") + agent.getConversations(offset = 0).collect { conv -> + val marker = if (conv.id == currentConv?.id) "*" else " " + val title = conv.title ?: "(без названия)" + terminal.println(" $marker ${shorten(conv.id)} $title [${conv.updatedAt}]") + } + } + + private suspend fun handleSwitch(id: String) { + val conv = agent.getConversation(id) + if (conv == null) { + terminal.printSystem("диалог $id не найден") + return + } + currentConv?.close() + currentConv = conv + currentTitle = conv.title + lastEventAt = Instant.DISTANT_PAST + sessionRepo.save(conv.id, lastEventAt) + terminal.printSystem("переключились на ${shorten(conv.id)} (${conv.title ?: "без названия"})") + } + + private suspend fun handleRename(title: String) { + val c = currentConv ?: run { + terminal.printSystem("нет активного диалога — /new") + return + } + c.rename(title) + currentTitle = title + terminal.printSystem("заголовок: $title") + } + + private suspend fun handleDelete(id: String?) { + val target = id ?: currentConv?.id + if (target == null) { + terminal.printSystem("нет диалога для удаления") + return + } + val ok = agent.deleteConversation(target) + if (ok) { + terminal.printSystem("удалён ${shorten(target)}") + if (target == currentConv?.id) { + currentConv?.close() + currentConv = null + currentTitle = null + sessionRepo.clear() + } + } else { + terminal.printSystem("диалог ${shorten(target)} не найден") + } + } + + private suspend fun handleInterrupt() { + val c = currentConv ?: run { + terminal.printSystem("нет активного диалога") + return + } + c.interrupt() + terminal.printSystem("прерывание отправлено") + } + + private suspend fun handlePwd() { + val c = currentConv ?: run { + terminal.printSystem("диалог: не выбран") + return + } + terminal.printSystem("id: ${c.id}") + terminal.printSystem("title: ${c.title ?: "—"}") + terminal.printSystem("updatedAt: ${c.updatedAt}") + terminal.printSystem("temporal: ${c.isTemporal}") + } + + private suspend fun handleHistory() { + val c = currentConv ?: run { + terminal.printSystem("нет активного диалога") + return + } + terminal.println("история:") + c.getMessages(after = Instant.DISTANT_PAST).collect { msg -> renderHistoryMessage(msg) } + } + + private suspend fun renderHistoryMessage(msg: Message) { + val prefix = " [${msg.date}] " + when (msg) { + is Message.UserMessage -> + terminal.println(prefix + "user | " + msg.content.text()) + is Message.AssistantMessage -> + terminal.println(prefix + "agent | " + msg.content.text()) + is Message.ToolCall -> + terminal.println(prefix + "tool>${msg.toolName} | ${msg.toolArgs.take(160)}") + is Message.ToolResult -> + terminal.println(prefix + "tool< | " + (msg.result?.take(160) ?: "null")) + is Message.Error -> + terminal.println(prefix + "<error${msg.code?.let { "/$it" } ?: ""}> ${msg.message}") + } + } + + private fun List<Content>.text(): String = + joinToString(separator = "") { c -> + when (c) { + is Content.Text -> c.body + is Content.Image -> "[image:${c.mime}:${c.data.size}B]" + } + } + + // ============================================================ user-message + + private suspend fun handleUserMessage(text: String) { + val conv = currentConv ?: run { + terminal.printSystem("нет активного диалога — /new") + return + } + + terminal.println() // пустая строка для визуального отделения блока + + val renderer = EventRenderer(terminal) + val turnFinished = CompletableDeferred<Unit>() + + // Подписчик events: принимает события и обновляет lastEventAt, + // по терминальному событию закрывает Deferred. + val eventsJob = scope.launch { + try { + conv.events(after = lastEventAt).collect { ev -> + renderer.render(ev) + if (ev.date > lastEventAt) { + lastEventAt = ev.date + sessionRepo.save(conv.id, lastEventAt) + } + if (ev is Event.End || ev is Event.Interrupted || ev is Event.Error) { + if (!turnFinished.isCompleted) turnFinished.complete(Unit) + } + } + } catch (t: Throwable) { + if (!turnFinished.isCompleted) turnFinished.complete(Unit) + if (t !is kotlinx.coroutines.CancellationException) { + terminal.printSystem("[events stream error] ${t.message}") + } + } + } + + try { + conv.send(listOf(Content.Text(text))) + turnFinished.await() + } catch (t: Throwable) { + terminal.printSystem("[send error] ${t.message}") + } finally { + eventsJob.cancel() + renderer.close() + terminal.println() + } + } + + // ============================================================ utils + + private fun shorten(id: String): String = id.take(8) + + private fun stateFilePath(): String? { + val home = CliPlatform.homeDir() ?: return null + val dir = "$home/.agentik" + return "$dir/cli-state.json" + } +} + +private enum class CommandResult { Continue, Exit } diff --git a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/CliPlatform.kt b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/CliPlatform.kt new file mode 100644 index 0000000..3f18453 --- /dev/null +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/CliPlatform.kt @@ -0,0 +1,52 @@ +package pw.binom.agentik.cli + +import pw.binom.agentik.proto.Agent + +/** + * Платформенные зависимости CLI. Все вещи, требующие JVM-stdlib или + * нативных API (терминал, env, файловое IO для state-файла, HTTP-клиент), + * предоставляются здесь как `expect/actual`. + * + * Текущий статус: jvmMain полностью реализован (JLine + `java.io` + `:client`), + * nativeMain — заглушки (подключение native ktor-движков и termios — отдельная задача). + */ +expect object CliPlatform { + fun openAgent(baseUrl: String, id: String): Agent + + fun openTerminal( + historyFile: String?, + prompt: String, + ): CliTerminal + + /** HOME/USERPROFILE для пути пути state-файла; null если недоступна. */ + fun homeDir(): String? + + /** Переменная среды (native API). Для jvmMain — `System.getenv`. */ + fun env(key: String): String? + + /** Файловое IO для session-state; nativeMain возвращает no-op. */ + fun sessionIo(): SessionIo +} + +/** + * Абстракция терминала, нужная для REPL. suspend-методы, чтобы не блокировать + * event-loop агентного цикла во время ожидания ввода. + */ +interface CliTerminal { + val prompt: String + + /** Следующая строка пользователя (без prompt). null = EOF (Ctrl-D/Ctrl-Z). */ + suspend fun readLine(): String? + + /** Печатает строку + перевод строки. */ + suspend fun println(text: String = "") + + /** Печатает строку без перевода (для streamed chunks). */ + suspend fun print(text: String) + + /** Подсветить prompt (символы-разделители сообщений, системные баннеры и т.п.). */ + suspend fun printSystem(text: String) + + /** Закрыть терминал: restore raw mode, flush history file, ... */ + fun close() +} diff --git a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/EventRenderer.kt b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/EventRenderer.kt new file mode 100644 index 0000000..84c2634 --- /dev/null +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/EventRenderer.kt @@ -0,0 +1,82 @@ +package pw.binom.agentik.cli + +import pw.binom.agentik.proto.Event + +/** + * Печатает [Event] в человеко-читаемом виде через [CliTerminal]. + * + * Дизайн: + * - [Event.StartReasoning] — просто системный маркер; текст мысли НЕ выводим + * отдельным форматом (см. proto: reasonig текст идёт через [Event.AppendText]). + * - [Event.StartResponse] с `responseType=TEXT` — начало печати ответа; закрытие + * происходит при [Event.End] или [Event.Interrupted]. + * - [Event.AppendText] — кусок текста, печатается БЕЗ перевода строки (чанки). + * - [Event.AppendImage] — выводим как `[image: <mime>, <bytes> bytes]` placeholder. + * Реальный рендеринг сделаем позже через iTerm/Kitty протоколы. + * - [Event.End] / [Event.Interrupted] — закрывают текущий блок. + * - [Event.Error] — отдельный системный блок `[error: …]`. + */ +class EventRenderer(private val terminal: CliTerminal) { + + /** Трекает открыт ли сейчас «блок ответа» (после [Event.StartResponse], до [Event.End]). */ + private var responseOpen = false + + suspend fun render(event: Event) { + when (event) { + is Event.StartReasoning -> { + terminal.printSystem("…thinking…") + if (responseOpen) { + terminal.println() + responseOpen = false + } + } + + is Event.StartResponse -> { + if (responseOpen) terminal.println() + responseOpen = true + // Без префикса — текст будет стримиться дальше через AppendText. + } + + is Event.AppendText -> { + terminal.print(event.body) + } + + is Event.AppendImage -> { + terminal.print("[image:${event.mime}:${event.body.size} bytes]") + } + + is Event.Interrupted -> { + if (responseOpen) { + terminal.println() + terminal.printSystem("[interrupted]") + responseOpen = false + } else { + terminal.printSystem("[interrupted]") + } + } + + is Event.End -> { + if (responseOpen) { + terminal.println() + responseOpen = false + } + } + + is Event.Error -> { + terminal.println() + terminal.printSystem("[error${event.code?.let { "/$it" } ?: ""}] ${event.message}") + if (responseOpen) responseOpen = false + } + + else -> { + // ToolCall/ToolResult — это «структура» диалога, в текстовом стриме + // не показываем; в веб-UI будет по-другому. + terminal.printSystem("[event:${event::class.simpleName}]") + } + } + } + + fun close() { + responseOpen = false + } +} diff --git a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/Main.kt b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/Main.kt new file mode 100644 index 0000000..f889cd4 --- /dev/null +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/Main.kt @@ -0,0 +1,105 @@ +package pw.binom.agentik.cli + +import kotlinx.coroutines.runBlocking + +/** + * Точка входа CLI. Поддерживает аргументы командной строки: + * + * ``` + * agentik-cli [--server URL] [--id ID] [--no-history] [--help] + * + * --server URL базовый URL сервера agentik (default $AGENTIK_SERVER или + * http://localhost:8080/agentik) + * --id ID идентификатор этого клиента (default "cli:$USER") + * --no-history не сохранять состояние в ~/.agentik/cli-state.json + * --help, -h распечатать usage и выйти + * ``` + * + * Без аргументов — стартует REPL. + */ +fun main(args: Array<String>) = runBlocking { + val cfg = parseCliArgs(args) + if (cfg == null) { + printUsage() + return@runBlocking + } + AgentikCli(cfg).run() +} + +/** + * Конфигурация CLI, вычисленная из аргументов + переменных среды. + * Доступна из других файлов commonMain (видна как `internal` внутри модуля). + */ +internal data class CliConfig( + val server: String, + val id: String, + val historyEnabled: Boolean, +) + +private fun parseCliArgs(args: Array<String>): CliConfig? { + var server: String? = null + var id: String? = null + var historyEnabled = true + + var i = 0 + while (i < args.size) { + when (val a = args[i]) { + "--help", "-h", "help" -> return null + "--server", "-s" -> { + require(i + 1 < args.size) { "$a требует URL" } + server = args[i + 1]; i += 2 + } + "--id" -> { + require(i + 1 < args.size) { "$a требует значение" } + id = args[i + 1]; i += 2 + } + "--no-history" -> { historyEnabled = false; i++ } + "--" -> i++ // разделитель; остальное игнорируем + else -> error("неизвестный аргумент: $a (введите --help)") + } + } + + val resolvedServer = server + ?: CliPlatform.env("AGENTIK_SERVER") + ?: "http://localhost:8080/agentik" + val resolvedId = id ?: "cli:${CliPlatform.env("USER") ?: CliPlatform.env("USERNAME") ?: "anon"}" + + return CliConfig( + server = resolvedServer, + id = resolvedId, + historyEnabled = historyEnabled, + ) +} + +private fun printUsage() { + val defaultServer = CliPlatform.env("AGENTIK_SERVER") ?: "http://localhost:8080/agentik" + val defaultUser = CliPlatform.env("USER") ?: CliPlatform.env("USERNAME") ?: "anon" + println(""" + agentik-cli — REPL поверх протокола agentik + + Использование: + agentik-cli [--server URL] [--id ID] [--no-history] + + Аргументы: + --server, -s URL базовый URL (default: $defaultServer) + --id ID идентификатор клиента (default: cli:${defaultUser}) + --no-history не сохранять состояние в ~/.agentik/cli-state.json + --help, -h эта справка + + Переменные среды: + AGENTIK_SERVER базовый URL агента (используется если --server не задан) + HOME для пути ~/.agentik/cli-state.json + + В REPL: + /help список slash-команд + /new [title] создать диалог (title опционально) + /list, /ls список диалогов + /switch <id>, /sw <id> переключиться на диалог + /rename <title> переименовать текущий диалог + /delete [<id>], /rm удалить (по id или текущий) + /history, /h последние сообщения текущего диалога + /interrupt, /stop прервать текущий ход + /pwd показать текущий диалог + /exit, /quit выйти (Ctrl-D тоже работает) + """.trimIndent()) +} diff --git a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/SessionRepository.kt b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/SessionRepository.kt new file mode 100644 index 0000000..53a9374 --- /dev/null +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/SessionRepository.kt @@ -0,0 +1,81 @@ +package pw.binom.agentik.cli + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlin.time.Instant + +/** + * Состояние CLI между запусками: последний выбранный диалог и момент последнего + * увиденного [Event.date] в его потоке (для корректного `events(after)` после рестарта). + * + * Доступ к диску инкапсулирован в платформенный [CliPlatform] — commonMain ничего + * не знает про `java.io.File`/`NSFileManager`, чтобы KMP-сборка собиралась + * под все цели. Файл: `$HOME/.agentik/cli-state.json`. + */ +internal class SessionRepository internal constructor( + private val filePath: String?, + private val io: SessionIo, +) { + + @Serializable + private data class State( + val conversationId: String, + val lastEventAt: String, + ) + + private val json = Json { prettyPrint = true; ignoreUnknownKeys = true } + + /** Открывается ленивым чтением. [save] ещё не было — файл может отсутствовать. */ + private var cached: State? = null + + fun load(): SavedSession? { + val path = filePath ?: return null + val raw = io.readAll(path) ?: return null + return runCatching { + val state = json.decodeFromString(State.serializer(), raw) + cached = state + SavedSession( + conversationId = state.conversationId, + lastEventAt = Instant.parse(state.lastEventAt), + ) + }.getOrNull() + } + + fun save(conversationId: String, lastEventAt: Instant) { + val path = filePath ?: return + val state = State( + conversationId = conversationId, + lastEventAt = lastEventAt.toString(), + ) + cached = state + val body = json.encodeToString(State.serializer(), state) + io.writeAtomic(path, body) + } + + fun clear() { + val path = filePath ?: return + io.delete(path) + cached = null + } + + fun close() { + // для совместимости с будущим in-memory state; пока no-op + } +} + +internal data class SavedSession( + val conversationId: String, + val lastEventAt: Instant, +) + +/** + * Минимальный платформо-зависимый IO-интерфейс для одного файла. Реализации + * в jvmMain (`java.io.File` + atomic `tmp → rename`) и в nativeMain (пока no-op-stub). + * + * public, потому что его возвращает public [CliPlatform.sessionIo]. + */ +interface SessionIo { + fun readAll(path: String): String? + fun writeAtomic(path: String, body: String) + fun delete(path: String) +} diff --git a/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/SlashCommand.kt b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/SlashCommand.kt new file mode 100644 index 0000000..3987099 --- /dev/null +++ b/agentik-cli/src/commonMain/kotlin/pw/binom/agentik/cli/SlashCommand.kt @@ -0,0 +1,90 @@ +package pw.binom.agentik.cli + +/** + * Slash-команды REPL'а. Первая буква `/` не хранится — парсер уже её отрезал. + * + * Свободный ввод (без `/` в начале) — это сообщение пользователя агенту в + * текущий диалог и НЕ разбирается в [parse]. + */ +sealed interface SlashCommand { + data object Help : SlashCommand + data object Exit : SlashCommand + data object Quit : SlashCommand // синоним Exit + + /** Создать новый диалог; опционально — заголовок. */ + data class New(val title: String?) : SlashCommand + + /** Список диалогов (cold flow — печатаем по мере прихода страниц). */ + data object List : SlashCommand + + /** Подключиться к существующему диалогу по id. */ + data class Switch(val id: String) : SlashCommand + + /** Переименовать текущий диалог. */ + data class Rename(val title: String) : SlashCommand + + /** Удалить диалог (по id или текущий). */ + data class Delete(val id: String?) : SlashCommand + + /** Прервать текущий ход. no-op если хода нет. */ + data object Interrupt : SlashCommand + + /** Показать последние сообщения текущего диалога (cold flow). */ + data object History : SlashCommand + + /** Показать информацию о текущем диалоге. */ + data object Pwd : SlashCommand +} + +/** + * Парсит строку (без ведущего `/`) в [SlashCommand] либо возвращает [Result.Failure] + * с сообщением об ошибке. + * + * Команды нечувствительны к регистру (команда `/LIST` == `/list`). + */ +fun parseSlash(input: String): ParseResult { + val s = input.trim() + if (s.isEmpty()) return ParseResult.Failure("пустая команда (введите /help)") + + // Разбиваем на команду и её аргументы. Поддерживаем склейку: /new foo bar → new "foo bar" + val firstSpace = s.indexOfAny(charArrayOf(' ', '\t')) + val cmd = if (firstSpace < 0) s else s.substring(0, firstSpace) + val rest = if (firstSpace < 0) "" else s.substring(firstSpace + 1).trim() + val args = if (rest.isEmpty()) emptyList() else rest.split(' ').filter { it.isNotEmpty() } + + val command: SlashCommand? = when (cmd.lowercase()) { + "help", "?" -> SlashCommand.Help + "exit" -> SlashCommand.Exit + "quit", "q" -> SlashCommand.Quit + "new" -> SlashCommand.New(rest.takeIf { it.isNotEmpty() }) + "list", "ls" -> SlashCommand.List + "switch", "sw", "cd" -> args.firstOrNull()?.let { SlashCommand.Switch(it) } + "rename", "mv", "title" -> rest.takeIf { it.isNotEmpty() }?.let { SlashCommand.Rename(it) } + "delete", "rm" -> SlashCommand.Delete(args.firstOrNull()) + "interrupt", "stop", "cancel" -> SlashCommand.Interrupt + "history", "hist", "h" -> SlashCommand.History + "pwd", "where" -> SlashCommand.Pwd + else -> null + } + if (command != null) return ParseResult.Success(command) + + // Не нашли команду: либо неизвестная, либо не хватает аргумента. + val cmdLower = cmd.lowercase() + return when (cmdLower) { + "switch", "sw", "cd" -> ParseResult.Failure("укажите id диалога: /switch <id>") + "rename", "mv", "title" -> ParseResult.Failure("укажите заголовок: /rename <title>") + else -> ParseResult.Failure("неизвестная команда: /$cmd (введите /help)") + } +} + +sealed interface ParseResult { + data class Success(val command: SlashCommand) : ParseResult + data class Failure(val message: String) : ParseResult +} + +/** Удобный helper для тестов и общего кода. */ +fun parseSlashOrNull(input: String): SlashCommand? = + when (val r = parseSlash(input)) { + is ParseResult.Success -> r.command + is ParseResult.Failure -> null + } diff --git a/agentik-cli/src/commonTest/kotlin/pw/binom/agentik/cli/EventRendererTest.kt b/agentik-cli/src/commonTest/kotlin/pw/binom/agentik/cli/EventRendererTest.kt new file mode 100644 index 0000000..bb4a9e4 --- /dev/null +++ b/agentik-cli/src/commonTest/kotlin/pw/binom/agentik/cli/EventRendererTest.kt @@ -0,0 +1,81 @@ +package pw.binom.agentik.cli + +import kotlinx.coroutines.test.runTest +import pw.binom.agentik.proto.Event +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Подменяем [CliTerminal] простой in-memory реализацией и проверяем, + * что события рендерятся в правильном формате. + */ +class EventRendererTest { + + private class FakeTerminal : CliTerminal { + override val prompt: String = ">" + val out = StringBuilder() + override suspend fun readLine(): String? = null + override suspend fun println(text: String) { out.appendLine(text) } + override suspend fun print(text: String) { out.append(text) } + override suspend fun printSystem(text: String) { out.appendLine("· $text") } + override fun close() {} + fun text() = out.toString() + } + + @Test + fun `simple response stream`() = runTest { + val t = FakeTerminal() + val r = EventRenderer(t) + r.render(Event.StartResponse(Instant.DISTANT_PAST, Event.ResponseType.TEXT)) + r.render(Event.AppendText(Instant.DISTANT_PAST, "Привет")) + r.render(Event.AppendText(Instant.DISTANT_PAST, ", мир!")) + r.render(Event.End(Instant.DISTANT_PAST)) + + // StartResponse открывает блок, AppendText без \n, End закрывает \n + val text = t.text() + assertTrue(text.contains("Привет, мир!"), "got: $text") + // после End должен быть перевод строки + assertTrue(text.endsWith("\n")) + } + + @Test + fun `interrupted closes block`() = runTest { + val t = FakeTerminal() + val r = EventRenderer(t) + r.render(Event.StartResponse(Instant.DISTANT_PAST, Event.ResponseType.TEXT)) + r.render(Event.AppendText(Instant.DISTANT_PAST, "Частично")) + r.render(Event.Interrupted(Instant.DISTANT_PAST)) + val text = t.text() + assertTrue(text.contains("Частично")) + assertTrue(text.contains("· [interrupted]")) + } + + @Test + fun `error before response`() = runTest { + val t = FakeTerminal() + val r = EventRenderer(t) + r.render(Event.Error(Instant.DISTANT_PAST, message = "что-то сломалось", code = "500")) + val text = t.text() + assertTrue(text.contains("· [error/500] что-то сломалось")) + } + + @Test + fun `start_reasoning is printed as system line`() = runTest { + val t = FakeTerminal() + val r = EventRenderer(t) + r.render(Event.StartReasoning(Instant.DISTANT_PAST)) + assertTrue(t.text().contains("· …thinking…")) + } + + @Test + fun `image append renders placeholder`() = runTest { + val t = FakeTerminal() + val r = EventRenderer(t) + r.render(Event.StartResponse(Instant.DISTANT_PAST, Event.ResponseType.IMAGE)) + r.render(Event.AppendImage(Instant.DISTANT_PAST, body = ByteArray(64), mime = "image/png")) + r.render(Event.End(Instant.DISTANT_PAST)) + assertTrue(t.text().contains("[image:image/png:64 bytes]")) + } +} diff --git a/agentik-cli/src/commonTest/kotlin/pw/binom/agentik/cli/SessionRepositoryTest.kt b/agentik-cli/src/commonTest/kotlin/pw/binom/agentik/cli/SessionRepositoryTest.kt new file mode 100644 index 0000000..a64285d --- /dev/null +++ b/agentik-cli/src/commonTest/kotlin/pw/binom/agentik/cli/SessionRepositoryTest.kt @@ -0,0 +1,108 @@ +package pw.binom.agentik.cli + +import org.junit.After +import org.junit.Before +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Интеграционный тест на реальном временном файле. Только JVM: использует + * [java.io.File] для IO-интерфейса. На native-таргетах тест не собирается — + * TODO: переписать на kotlinx-io Files и перенести в commonTest. + */ +class SessionRepositoryTest { + + private lateinit var tmp: File + + @Before + fun setUp() { + tmp = File.createTempFile("agentik-cli-state", ".json") + tmp.delete() + } + + @After + fun tearDown() { + if (tmp.exists()) tmp.delete() + File(tmp.path + ".tmp").delete() + } + + @Test + fun `load returns null when file missing`() { + val repo = SessionRepository(tmp.path, JvmIo) + assertNull(repo.load()) + } + + @Test + fun `save then load roundtrip`() { + val repo = SessionRepository(tmp.path, JvmIo) + val savedAt = Instant.parse("2026-09-16T10:00:00Z") + repo.save(conversationId = "abcd-1234", lastEventAt = savedAt) + repo.close() + + val repo2 = SessionRepository(tmp.path, JvmIo) + val restored = repo2.load() + assertNotNull(restored) + assertEquals("abcd-1234", restored.conversationId) + assertEquals(savedAt, restored.lastEventAt) + } + + @Test + fun `save overwrites previous state`() { + val repo = SessionRepository(tmp.path, JvmIo) + repo.save("conv-1", Instant.parse("2026-09-16T10:00:00Z")) + repo.save("conv-2", Instant.parse("2026-09-16T11:00:00Z")) + repo.close() + + val restored = SessionRepository(tmp.path, JvmIo).load() + assertNotNull(restored) + assertEquals("conv-2", restored.conversationId) + } + + @Test + fun `null filepath means no-op`() { + val repo = SessionRepository(null, JvmIo) + repo.save("conv-X", Instant.parse("2026-09-16T10:00:00Z")) + // Не должно ни читать, ни писать. + assertNull(repo.load()) + } + + @Test + fun `clear deletes file`() { + val repo = SessionRepository(tmp.path, JvmIo) + repo.save("conv-Z", Instant.parse("2026-09-16T10:00:00Z")) + repo.close() + assertTrue(tmp.exists()) + + val repo2 = SessionRepository(tmp.path, JvmIo) + repo2.clear() + assertTrue(!tmp.exists()) + } + + @Test + fun `corrupt json is ignored (does not throw)`() { + File(tmp.path).writeText("this is not json") + val repo = SessionRepository(tmp.path, JvmIo) + assertNull(repo.load()) + } +} + +// JVM-only helper: реализация [SessionIo] поверх `java.io.File` для теста. +// В продакшен-коде на jvmMain ровно такая же логика. +private object JvmIo : SessionIo { + override fun readAll(path: String): String? { + val f = File(path); if (!f.exists()) return null + return runCatching { f.readText() }.getOrNull() + } + override fun writeAtomic(path: String, body: String) { + val target = File(path); target.parentFile?.mkdirs() + val tmp = File(path + ".tmp") + tmp.writeText(body) + if (!tmp.renameTo(target)) target.writeText(tmp.readText()).also { tmp.delete() } + } + override fun delete(path: String) { File(path).delete() } +} diff --git a/agentik-cli/src/commonTest/kotlin/pw/binom/agentik/cli/SlashCommandTest.kt b/agentik-cli/src/commonTest/kotlin/pw/binom/agentik/cli/SlashCommandTest.kt new file mode 100644 index 0000000..e4381f9 --- /dev/null +++ b/agentik-cli/src/commonTest/kotlin/pw/binom/agentik/cli/SlashCommandTest.kt @@ -0,0 +1,101 @@ +package pw.binom.agentik.cli + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class SlashCommandTest { + + @Test + fun `help is parsed`() { + assertIs<SlashCommand.Help>(parseSlashOrNull("help")) + assertIs<SlashCommand.Help>(parseSlashOrNull("?")) + assertIs<SlashCommand.Help>(parseSlashOrNull("HELP")) + } + + @Test + fun `exit and quit alias`() { + assertIs<SlashCommand.Exit>(parseSlashOrNull("exit")) + assertIs<SlashCommand.Quit>(parseSlashOrNull("q")) + assertIs<SlashCommand.Quit>(parseSlashOrNull("Quit")) + } + + @Test + fun `new without title`() { + assertIs<SlashCommand.New>(parseSlashOrNull("new")).let { + assertEquals(null, it.title) + } + } + + @Test + fun `new with multi-word title`() { + val cmd = parseSlashOrNull("new my cool chat") + assertIs<SlashCommand.New>(cmd) + assertEquals("my cool chat", cmd.title) + } + + @Test + fun `switch requires id`() { + val r = parseSlash("sw") + assertIs<ParseResult.Failure>(r) + } + + @Test + fun `switch with id`() { + val cmd = parseSlashOrNull("switch abc123") + assertIs<SlashCommand.Switch>(cmd) + assertEquals("abc123", cmd.id) + } + + @Test + fun `rename requires title`() { + val r = parseSlash("rename") + assertIs<ParseResult.Failure>(r) + // А "rename " (с пробелом, но без слов после) — это уже успех с пустым title? + // У нас: rest = "" → takeIf { it.isNotEmpty() } → null → Failure. ОК. + } + + @Test + fun `rename with title`() { + val cmd = parseSlashOrNull("rename my new title ") + assertIs<SlashCommand.Rename>(cmd) + assertEquals("my new title", cmd.title) // trim() делает своё + } + + @Test + fun `delete may have id or not`() { + assertIs<SlashCommand.Delete>(parseSlashOrNull("rm")).let { + assertEquals(null, it.id) + } + assertIs<SlashCommand.Delete>(parseSlashOrNull("delete abc")).let { + assertEquals("abc", it.id) + } + } + + @Test + fun `unknown command fails`() { + val r = parseSlash("foobar") + assertIs<ParseResult.Failure>(r) + } + + @Test + fun `empty command fails`() { + val r = parseSlash("") + assertIs<ParseResult.Failure>(r) + } + + @Test + fun `command is case insensitive`() { + assertIs<SlashCommand.List>(parseSlashOrNull("LIST")) + assertIs<SlashCommand.Interrupt>(parseSlashOrNull("STOP")) + assertIs<SlashCommand.Pwd>(parseSlashOrNull("PWD")) + } + + @Test + fun `interrupt synonyms`() { + assertIs<SlashCommand.Interrupt>(parseSlashOrNull("interrupt")) + assertIs<SlashCommand.Interrupt>(parseSlashOrNull("stop")) + assertIs<SlashCommand.Interrupt>(parseSlashOrNull("cancel")) + } +} diff --git a/agentik-cli/src/jvmMain/kotlin/pw/binom/agentik/cli/Platform.jvm.kt b/agentik-cli/src/jvmMain/kotlin/pw/binom/agentik/cli/Platform.jvm.kt new file mode 100644 index 0000000..5064d10 --- /dev/null +++ b/agentik-cli/src/jvmMain/kotlin/pw/binom/agentik/cli/Platform.jvm.kt @@ -0,0 +1,151 @@ +package pw.binom.agentik.cli + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jline.reader.EndOfFileException +import org.jline.reader.LineReader +import org.jline.reader.LineReaderBuilder +import org.jline.reader.UserInterruptException +import org.jline.terminal.TerminalBuilder +import pw.binom.agentik.client.AgentikAgent +import pw.binom.agentik.proto.Agent +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +actual object CliPlatform { + actual fun openAgent(baseUrl: String, id: String): Agent = + AgentikAgent(id = id, baseUrl = baseUrl) + + actual fun openTerminal(historyFile: String?, prompt: String): CliTerminal = + JLineTerminal(historyFile = historyFile, prompt = prompt) + + actual fun homeDir(): String? = + System.getenv("HOME") ?: System.getenv("USERPROFILE") + + actual fun env(key: String): String? = System.getenv(key) + + actual fun sessionIo(): SessionIo = JvmSessionIo +} + +/** + * Реализация [SessionIo] поверх `java.io.File` + atomic `tmp → rename`. + * tmp-файл пишется в той же директории, что и целевой, чтобы rename + * был атомарным в рамках одного раздела (POSIX rename(2) и Windows + * MoveFileEx — атомарны внутри одного тома). + */ +private object JvmSessionIo : SessionIo { + override fun readAll(path: String): String? { + val f = File(path) + if (!f.exists()) return null + return runCatching { f.readText() }.getOrNull() + } + + override fun writeAtomic(path: String, body: String) { + val target = File(path) + target.parentFile?.mkdirs() + val tmp = File(path + ".tmp") + tmp.writeText(body) + if (!tmp.renameTo(target)) { + // fallback: Windows-специфика — renameTo может не перезаписать существующий. + runCatching { Files.move(tmp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) } + .getOrElse { target.writeText(tmp.readText()); tmp.delete() } + } + } override fun delete(path: String) { + runCatching { File(path).delete() } + } +} + +/** + * Реализация [CliTerminal] поверх JLine ([LineReader]). + * + * JLine-3 API: + * - [TerminalBuilder.builder().system(true).build()] — открыть системный TTY. + * - [LineReader] поверх Terminal — readline-редактор (стрелки, history, Ctrl-A/E). + * - [LineReader.readLine(prompt)] — suspend-free, блокирующий IO; мы оборачиваем + * в [withContext] [Dispatchers.IO], чтобы не держать event-loop. + * - [DefaultHistory] (org.jline.reader.history.DefaultHistory) + история из файла. + */ +private class JLineTerminal( + historyFile: String?, + override val prompt: String, +) : CliTerminal { + + private val terminal = TerminalBuilder.builder() + .system(true) + .jna(true) + .build() + + private val historyImpl: org.jline.reader.History? = run { + if (historyFile == null) null else try { + val history = org.jline.reader.impl.history.DefaultHistory() + val histFile = File(historyFile) + histFile.parentFile?.mkdirs() + history.load() + if (histFile.exists()) { + history.append(histFile.toPath(), true) + } + history + } catch (t: Throwable) { + null + } + } + + private val reader: LineReader = LineReaderBuilder.builder() + .terminal(terminal) + .apply { if (historyImpl != null) history(historyImpl) } + .build() + + private val historyFilePath: java.nio.file.Path? = + historyFile?.let { File(it).toPath() } + + override suspend fun readLine(): String? = withContext(Dispatchers.IO) { + try { + val line = reader.readLine(prompt) + // Сохраняем history при каждой строке — дешево, и при Ctrl-D / Ctrl-C + // ничего не теряется. + flushHistory() + line + } catch (_: UserInterruptException) { + // Ctrl-C: трактуем как «всё, выходим», как и EOF. + flushHistory() + null + } catch (_: EndOfFileException) { + // Ctrl-D на пустой строке. + flushHistory() + null + } + } + + override suspend fun println(text: String): Unit = withContext(Dispatchers.IO) { + terminal.writer().println(text) + terminal.writer().flush() + } + + override suspend fun print(text: String): Unit = withContext(Dispatchers.IO) { + terminal.writer().print(text) + terminal.writer().flush() + } + + override suspend fun printSystem(text: String): Unit = withContext(Dispatchers.IO) { + terminal.writer().println("· $text") + terminal.writer().flush() + } + + private fun flushHistory() { + val hf = historyFilePath ?: return + val h = historyImpl ?: return + runCatching { + h.save() + if (!h.isEmpty) { + // читаем из .tmp и дописываем + h.append(hf, true) + } + } + } + + override fun close() { + runCatching { flushHistory() } + runCatching { terminal.close() } + } +} diff --git a/agentik-cli/src/nativeMain/kotlin/pw/binom/agentik/cli/Platform.native.kt b/agentik-cli/src/nativeMain/kotlin/pw/binom/agentik/cli/Platform.native.kt new file mode 100644 index 0000000..c16993c --- /dev/null +++ b/agentik-cli/src/nativeMain/kotlin/pw/binom/agentik/cli/Platform.native.kt @@ -0,0 +1,36 @@ +package pw.binom.agentik.cli + +import pw.binom.agentik.proto.Agent + +/** + * Платформо-зависимая реализация для native-целей. + * + * Текущий статус: stub. native HTTP требует подключения ktor-client-core + + * платформенных engine'ов (ktor-client-darwin для Apple, ktor-client-curl для + * linux/mingw, ktor-client-okhttp для Android в перспективе) и переиспользования + * уже существующего `:client` SSE-парсера. Native readline требует termios + * через `kotlinx.cinterop` — добавим, когда дойдут руки. + * + * Пока запустить агента из native-бинаря CLI нельзя, но проект компилируется + * под все 8 KMP-целей — структурная готовность соблюдена. + */ +actual object CliPlatform { + actual fun openAgent(baseUrl: String, id: String): Agent = + error("agentik-cli native target is not implemented yet (baseUrl=$baseUrl)") + + actual fun openTerminal(historyFile: String?, prompt: String): CliTerminal = + error("agentik-cli native target is not implemented yet (prompt=$prompt)") + + actual fun homeDir(): String? = null + + actual fun env(key: String): String? = null + + actual fun sessionIo(): SessionIo = NoopSessionIo +} + +/** Минимальный no-op-IO для native-целей пока не подключён реальный движок. */ +private object NoopSessionIo : SessionIo { + override fun readAll(path: String): String? = null + override fun writeAtomic(path: String, body: String) {} + override fun delete(path: String) {} +} diff --git a/client/src/main/kotlin/pw/binom/agentik/client/Serialization.kt b/client/src/main/kotlin/pw/binom/agentik/client/Serialization.kt index dc31722..bb3e78e 100644 --- a/client/src/main/kotlin/pw/binom/agentik/client/Serialization.kt +++ b/client/src/main/kotlin/pw/binom/agentik/client/Serialization.kt @@ -15,8 +15,11 @@ import kotlin.time.Instant * wire-формат компактный, альтернатива — отдельный `:wire`-модуль ради 10 строк. */ internal object InstantSerializer : KSerializer<Instant> { + // Имя дескриптора обязано совпадать с тем, что регистрирует :server — иначе + // kotlinx-serialization 1.6+ выбросит «there already exists» при попытке загрузить + // оба варианта (нативный сериализатор Instant + наш custom) в одном процессе. override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("kotlin.time.Instant", PrimitiveKind.STRING) + PrimitiveSerialDescriptor("pw.binom.agentik.Instant", PrimitiveKind.STRING) override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString()) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2102221..a644ded 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ jvector = "3.0.6" text-embedding-kmp = "3.0.0-SNAPSHOT" kotlin-logging = "3.0.5" logback = "1.5.18" +jline = "3.30.0" [plugins] kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } @@ -56,6 +57,9 @@ ktor-client-sse = { module = "io.ktor:ktor-client-sse", version.ref = "ktor" } # --- Model Context Protocol (MCP) --- mcp-sdk-client = { module = "io.modelcontextprotocol:kotlin-sdk-client", version = "0.15.0" } +# --- CLI: JLine (readline для JVM-таргета) --- +jline = { module = "org.jline:jline", version.ref = "jline" } + kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } # --- commons --- diff --git a/settings.gradle.kts b/settings.gradle.kts index 9ab2649..440e3f2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -36,6 +36,9 @@ include(":skills") include(":server") // Ktor-клиент, превращающий HTTP-фасад в `Agent`/`Conversation`. include(":client") +// CLI-клиент поверх :client — REPL со slash-командами и стримингом ответов. +// KMP со всеми целями (jvm + весь натив), jvm-таргет собирается как shadowJar. +include(":agentik-cli") // Встраиваемая долговременная память агента. `:memory-api` — интерфейсы, // `:memory-md` — реализация на базе §-файлов (Hermes-style). include(":memory-api")