feat(agentik-cli): REPL-клиент на базе :client для всех KMP-целей
Новый 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 диалогов.
This commit is contained in:
@@ -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>("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
|
||||
}
|
||||
@@ -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 <id>")
|
||||
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 <id>, /sw переключиться на диалог по id
|
||||
| /rename <title> переименовать текущий диалог
|
||||
| /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 }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]"))
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user