epic-13: add cli and tui entry point, finish epic.

This commit is contained in:
2026-05-16 13:37:58 +04:00
parent 72d20726ce
commit 2207a37549
60 changed files with 2896 additions and 41 deletions
+7
View File
@@ -1,5 +1,6 @@
plugins {
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
id 'application'
}
@@ -9,4 +10,10 @@ application {
dependencies {
implementation "com.github.ajalt.clikt:clikt:5.0.1"
implementation "io.ktor:ktor-client-core:$ktor_version"
implementation "io.ktor:ktor-client-cio:$ktor_version"
implementation "io.ktor:ktor-client-content-negotiation:$ktor_version"
implementation "io.ktor:ktor-client-websockets:$ktor_version"
implementation "io.ktor:ktor-serialization-kotlinx-json:$ktor_version"
}
@@ -0,0 +1,3 @@
package com.correx.apps.cli
internal const val DEFAULT_PORT = 8080
@@ -0,0 +1,26 @@
package com.correx.apps.cli
import com.correx.apps.cli.commands.ApproveCommand
import com.correx.apps.cli.commands.ProviderCommand
import com.correx.apps.cli.commands.RunCommand
import com.correx.apps.cli.commands.SessionCommand
import com.correx.apps.cli.commands.StatusCommand
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.subcommands
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
class CorrexCli : CliktCommand(name = "correx") {
val json by option("--json", help = "Output machine-readable JSON").flag()
val quiet by option("--quiet", help = "Output errors only").flag()
override fun run() = Unit
}
fun buildCli(): CorrexCli = CorrexCli().subcommands(
RunCommand(),
SessionCommand(),
ApproveCommand(),
StatusCommand(),
ProviderCommand(),
)
@@ -1,5 +1,7 @@
package com.correx.apps.cli
fun main() {
println("correx :: apps/cli")
import com.github.ajalt.clikt.core.main
fun main(args: Array<String>) {
buildCli().main(args)
}
@@ -0,0 +1,67 @@
package com.correx.apps.cli.commands
import com.correx.apps.cli.CorrexCli
import com.correx.apps.cli.DEFAULT_PORT
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.options.required
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
private data class ApprovalRequest(
val decision: String,
val steeringNote: String?,
)
class ApproveCommand : CliktCommand(name = "approve") {
private val sessionId by argument("SESSION_ID")
private val decision by option("--decision", help = "approve | reject | steer").required()
private val note by option("--note", help = "Steering note")
private val host by option("--host").default("localhost")
private val port by option("--port").default("$DEFAULT_PORT")
private val approveJson = Json { ignoreUnknownKeys = true }
override fun run(): Unit = runBlocking {
val portInt = port.toIntOrNull() ?: DEFAULT_PORT
val outputJson = (currentContext.findRoot().command as? CorrexCli)?.json ?: false
val decisionUpper = decision.uppercase()
if (decisionUpper !in setOf("APPROVE", "REJECT", "STEER")) {
System.err.println("Invalid decision: $decision. Must be approve, reject, or steer.")
return@runBlocking
}
val client = HttpClient(CIO) {
install(ContentNegotiation) { json(approveJson) }
}
runCatching {
client.post("http://$host:$portInt/sessions/$sessionId/approve") {
contentType(ContentType.Application.Json)
setBody(ApprovalRequest(decision = decisionUpper, steeringNote = note))
}
if (outputJson) {
println("{\"sessionId\": \"$sessionId\", \"decision\": \"$decisionUpper\"}")
} else {
println("Approval decision '$decisionUpper' sent for session $sessionId.")
}
}.getOrElse { e ->
System.err.println("Error sending approval: ${e.message}")
}
client.close()
}
}
@@ -0,0 +1,63 @@
package com.correx.apps.cli.commands
import com.correx.apps.cli.CorrexCli
import com.correx.apps.cli.DEFAULT_PORT
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.subcommands
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.option
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
class ProviderCommand : CliktCommand(name = "provider") {
override fun run() = Unit
init {
subcommands(ProviderListCommand())
}
}
class ProviderListCommand : CliktCommand(name = "list") {
private val host by option("--host").default("localhost")
private val port by option("--port").default("$DEFAULT_PORT")
private val providerJson = Json { ignoreUnknownKeys = true }
override fun run(): Unit = runBlocking {
val portInt = port.toIntOrNull() ?: DEFAULT_PORT
val outputJson = (currentContext.findRoot().command as? CorrexCli)?.json ?: false
val client = HttpClient(CIO) {
install(ContentNegotiation) { json(providerJson) }
}
runCatching {
val resp = client.get("http://$host:$portInt/providers")
val body = resp.body<JsonArray>()
if (outputJson) {
println(body.toString())
} else {
body.forEach { el ->
val obj = el as? JsonObject ?: return@forEach
val providerId = obj["providerId"]?.jsonPrimitive?.content ?: ""
val status = obj["status"]?.jsonPrimitive?.content ?: ""
val load = obj["loadPercent"]?.jsonPrimitive?.content ?: "?"
println("$providerId status=$status load=${load}%")
}
}
}.getOrElse { e ->
System.err.println("Error fetching providers: ${e.message}")
}
client.close()
}
}
@@ -0,0 +1,256 @@
package com.correx.apps.cli.commands
import com.correx.apps.cli.CorrexCli
import com.correx.apps.cli.DEFAULT_PORT
import com.correx.apps.cli.ws.CliWsClient
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.options.required
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import io.ktor.websocket.WebSocketSession
import io.ktor.websocket.send
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@Serializable
private data class StartSessionRequest(val workflowId: String, val sessionId: String?)
@Serializable
private data class StartSessionResponse(val sessionId: String)
@Serializable
private data class ApprovalResponsePayload(
val type: String,
val requestId: String,
val decision: String,
val steeringNote: String?,
)
private data class RunContext(
val sid: String,
val outputJson: Boolean,
val autoApprove: Boolean,
val isTty: Boolean,
)
private data class ApprovalContext(
val session: WebSocketSession,
val requestId: String,
val toolName: String,
val preview: String?,
)
class RunCommand : CliktCommand(name = "run") {
private val workflow by option("--workflow", help = "Path to workflow definition").required()
private val sessionId by option("--session", help = "Existing session ID to resume")
private val autoApprove by option("--auto-approve", help = "Auto-approve all approval requests").flag()
private val host by option("--host", help = "Server host").default("localhost")
private val port by option("--port", help = "Server port").default("$DEFAULT_PORT")
private val serializer = Json { ignoreUnknownKeys = true; classDiscriminator = "type" }
override fun run(): Unit = runBlocking {
val portInt = port.toIntOrNull() ?: DEFAULT_PORT
val outputJson = (currentContext.findRoot().command as? CorrexCli)?.json ?: false
val client = HttpClient(CIO) {
install(ContentNegotiation) { json(serializer) }
install(WebSockets)
}
val sid = startSession(client, host, portInt, outputJson) ?: run {
client.close()
return@runBlocking
}
printLine("session_started", mapOf("sessionId" to sid, "workflow" to workflow), outputJson)
val ctx = RunContext(sid, outputJson, autoApprove, System.console() != null)
var exitCode = 0
val wsClient = CliWsClient(client)
wsClient.sessionStream(host, portInt, sid) { session, raw ->
val msgJson = runCatching { serializer.parseToJsonElement(raw).jsonObject }.getOrNull()
val keepGoing = handleMessage(session, msgJson, ctx) { exitCode = it }
keepGoing
}
client.close()
if (exitCode != 0) {
throw SystemExitException(exitCode)
}
}
private suspend fun startSession(
client: HttpClient,
host: String,
portInt: Int,
outputJson: Boolean,
): String? = runCatching {
val resp = client.post("http://$host:$portInt/sessions") {
contentType(ContentType.Application.Json)
setBody(StartSessionRequest(workflowId = workflow, sessionId = sessionId))
}
resp.body<StartSessionResponse>().sessionId
}.getOrElse { e ->
printLine("error", mapOf("message" to "Failed to start session: ${e.message}"), outputJson)
null
}
@Suppress("CyclomaticComplexMethod", "LongMethod")
private suspend fun handleMessage(
session: WebSocketSession,
msgJson: JsonObject?,
ctx: RunContext,
setExitCode: (Int) -> Unit,
): Boolean {
val type = msgJson?.get("type")?.jsonPrimitive?.content ?: "unknown"
fun field(key: String): String? = msgJson?.get(key)?.jsonPrimitive?.content
return when (type) {
"SessionCompleted" -> {
printLine("session_completed", mapOf("sessionId" to ctx.sid), ctx.outputJson)
setExitCode(0)
false
}
"SessionFailed" -> {
val reason = field("reason") ?: "unknown"
printLine("session_failed", mapOf("sessionId" to ctx.sid, "reason" to reason), ctx.outputJson)
setExitCode(1)
false
}
"StageStarted" -> {
printLine("stage_started", mapOf("stageId" to (field("stageId") ?: "")), ctx.outputJson)
true
}
"StageCompleted" -> {
printLine("stage_completed", mapOf("stageId" to (field("stageId") ?: "")), ctx.outputJson)
true
}
"StageFailed" -> {
printLine(
"stage_failed",
mapOf("stageId" to (field("stageId") ?: ""), "reason" to (field("reason") ?: "")),
ctx.outputJson,
)
true
}
"ToolStarted" -> {
printLine("tool_started", mapOf("tool" to (field("toolName") ?: "")), ctx.outputJson)
true
}
"ToolCompleted" -> {
printLine("tool_completed", mapOf("tool" to (field("toolName") ?: "")), ctx.outputJson)
true
}
"ToolFailed" -> {
printLine(
"tool_failed",
mapOf("tool" to (field("toolName") ?: ""), "reason" to (field("reason") ?: "")),
ctx.outputJson,
)
true
}
"ToolRejected" -> {
printLine(
"tool_rejected",
mapOf("tool" to (field("toolName") ?: ""), "reason" to (field("reason") ?: "")),
ctx.outputJson,
)
true
}
"ApprovalRequired" -> {
val approvalCtx = ApprovalContext(
session = session,
requestId = field("requestId") ?: "",
toolName = field("toolName") ?: "",
preview = field("preview"),
)
handleApproval(approvalCtx, ctx, setExitCode)
}
else -> true
}
}
private suspend fun handleApproval(
approvalCtx: ApprovalContext,
ctx: RunContext,
setExitCode: (Int) -> Unit,
): Boolean {
val decision = resolveDecision(ctx, approvalCtx.toolName, approvalCtx.preview)
val steeringNote = if (decision == "STEER" && ctx.isTty) {
print("Steering note: ")
readLine()
} else {
null
}
val response = ApprovalResponsePayload(
type = "ApprovalResponse",
requestId = approvalCtx.requestId,
decision = decision,
steeringNote = steeringNote,
)
approvalCtx.session.send(serializer.encodeToString(response))
return if (decision == "REJECT" && !ctx.autoApprove && !ctx.isTty) {
setExitCode(2)
false
} else {
true
}
}
private fun resolveDecision(ctx: RunContext, toolName: String, preview: String?): String = when {
ctx.autoApprove -> "APPROVE"
!ctx.isTty -> {
System.err.println(
"WARNING: approval required but no TTY and --auto-approve not set — denying"
)
"REJECT"
}
else -> promptApproval(toolName, preview)
}
private fun promptApproval(toolName: String, preview: String?): String {
println("\nApproval required for tool: $toolName")
preview?.let { println("Preview: $it") }
print("[a]pprove / [r]eject / [s]teer: ")
return when (readLine()?.trim()?.lowercase()) {
"a", "approve" -> "APPROVE"
"s", "steer" -> "STEER"
else -> "REJECT"
}
}
private fun printLine(event: String, data: Map<String, String?>, outputJson: Boolean) {
if (outputJson) {
val fields = data.entries.joinToString(", ") { (k, v) ->
"\"$k\": ${if (v == null) "null" else "\"$v\""}"
}
println("{\"event\": \"$event\", $fields}")
} else {
val details = data.entries.joinToString(" ") { (k, v) -> "$k=${v ?: "null"}" }
println("[$event] $details")
}
}
}
class SystemExitException(val code: Int) : Exception("exit $code")
@@ -0,0 +1,164 @@
package com.correx.apps.cli.commands
import com.correx.apps.cli.CorrexCli
import com.correx.apps.cli.DEFAULT_PORT
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.subcommands
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.option
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
class SessionCommand : CliktCommand(name = "session") {
override fun run() = Unit
init {
subcommands(
SessionListCommand(),
SessionResumeCommand(),
SessionCancelCommand(),
SessionEventsCommand(),
)
}
}
private fun parentJsonFlag(command: CliktCommand): Boolean =
(command.currentContext.findRoot().command as? CorrexCli)?.json ?: false
private val sessionJson = Json { ignoreUnknownKeys = true }
class SessionListCommand : CliktCommand(name = "list") {
private val limit by option("--limit", help = "Max sessions to return").default("20")
private val host by option("--host").default("localhost")
private val port by option("--port").default("$DEFAULT_PORT")
override fun run(): Unit = runBlocking {
val portInt = port.toIntOrNull() ?: DEFAULT_PORT
val outputJson = parentJsonFlag(this@SessionListCommand)
val client = HttpClient(CIO) {
install(ContentNegotiation) { json(sessionJson) }
}
runCatching {
val resp = client.get("http://$host:$portInt/sessions?limit=$limit")
val body = resp.body<JsonArray>()
if (outputJson) {
println(body.toString())
} else {
body.forEach { el ->
val obj = el as? JsonObject ?: return@forEach
val sid = obj["sessionId"]?.jsonPrimitive?.content ?: ""
val wf = obj["workflowId"]?.jsonPrimitive?.content ?: ""
val status = obj["status"]?.jsonPrimitive?.content ?: ""
println("$sid workflow=$wf status=$status")
}
}
}.getOrElse { e ->
System.err.println("Error listing sessions: ${e.message}")
}
client.close()
}
}
class SessionResumeCommand : CliktCommand(name = "resume") {
private val id by argument("SESSION_ID")
private val host by option("--host").default("localhost")
private val port by option("--port").default("$DEFAULT_PORT")
override fun run(): Unit = runBlocking {
val portInt = port.toIntOrNull() ?: DEFAULT_PORT
val outputJson = parentJsonFlag(this@SessionResumeCommand)
val client = HttpClient(CIO) {
install(ContentNegotiation) { json(sessionJson) }
}
runCatching {
client.post("http://$host:$portInt/sessions/$id/resume")
if (outputJson) {
println("{\"sessionId\": \"$id\", \"action\": \"resumed\"}")
} else {
println("Session $id resumed.")
}
}.getOrElse { e ->
System.err.println("Error resuming session: ${e.message}")
}
client.close()
}
}
class SessionCancelCommand : CliktCommand(name = "cancel") {
private val id by argument("SESSION_ID")
private val host by option("--host").default("localhost")
private val port by option("--port").default("$DEFAULT_PORT")
override fun run(): Unit = runBlocking {
val portInt = port.toIntOrNull() ?: DEFAULT_PORT
val outputJson = parentJsonFlag(this@SessionCancelCommand)
val client = HttpClient(CIO) {
install(ContentNegotiation) { json(sessionJson) }
}
runCatching {
client.post("http://$host:$portInt/sessions/$id/cancel")
if (outputJson) {
println("{\"sessionId\": \"$id\", \"action\": \"cancelled\"}")
} else {
println("Session $id cancelled.")
}
}.getOrElse { e ->
System.err.println("Error cancelling session: ${e.message}")
}
client.close()
}
}
class SessionEventsCommand : CliktCommand(name = "events") {
private val id by argument("SESSION_ID")
private val from by option("--from", help = "Start from event ID")
private val host by option("--host").default("localhost")
private val port by option("--port").default("$DEFAULT_PORT")
override fun run(): Unit = runBlocking {
val portInt = port.toIntOrNull() ?: DEFAULT_PORT
val outputJson = parentJsonFlag(this@SessionEventsCommand)
val client = HttpClient(CIO) {
install(ContentNegotiation) { json(sessionJson) }
}
val query = from?.let { "?from=$it" } ?: ""
runCatching {
val resp = client.get("http://$host:$portInt/sessions/$id/events$query")
val body = resp.body<JsonArray>()
if (outputJson) {
println(body.toString())
} else {
body.forEachIndexed { idx, el ->
println("[$idx] $el")
}
}
}.getOrElse { e ->
System.err.println("Error fetching events: ${e.message}")
}
client.close()
}
}
@@ -0,0 +1,49 @@
package com.correx.apps.cli.commands
import com.correx.apps.cli.CorrexCli
import com.correx.apps.cli.DEFAULT_PORT
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.option
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
class StatusCommand : CliktCommand(name = "status") {
private val host by option("--host").default("localhost")
private val port by option("--port").default("$DEFAULT_PORT")
private val statusJson = Json { ignoreUnknownKeys = true }
override fun run(): Unit = runBlocking {
val portInt = port.toIntOrNull() ?: DEFAULT_PORT
val outputJson = (currentContext.findRoot().command as? CorrexCli)?.json ?: false
val client = HttpClient(CIO) {
install(ContentNegotiation) { json(statusJson) }
}
runCatching {
val resp = client.get("http://$host:$portInt/status")
val body = resp.body<JsonObject>()
if (outputJson) {
println(body.toString())
} else {
body.entries.forEach { (k, v) ->
println("$k: ${v.jsonPrimitive.content}")
}
}
}.getOrElse { e ->
System.err.println("Error fetching status: ${e.message}")
}
client.close()
}
}
@@ -0,0 +1,25 @@
package com.correx.apps.cli.ws
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.WebSocketSession
import io.ktor.websocket.readText
class CliWsClient(private val httpClient: HttpClient) {
suspend fun sessionStream(
host: String,
port: Int,
sessionId: String,
onMessage: suspend (WebSocketSession, String) -> Boolean,
) {
httpClient.webSocket(host = host, port = port, path = "/sessions/$sessionId/stream") {
for (frame in incoming) {
if (frame is Frame.Text) {
val keepGoing = onMessage(this, frame.readText())
if (!keepGoing) break
}
}
}
}
}