From f490968dc4c3a9576bb4870a3595454e4f776576 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 07:17:06 +0000 Subject: [PATCH] feat(tasks): correx task CLI correx task list/search/show/create/claim/complete/export over the REST surface, with --json passthrough and pure render helpers. Completes the human entry points alongside the TUI board. Co-Authored-By: Claude Opus 4.8 --- .../kotlin/com/correx/apps/cli/CorrexCli.kt | 2 + .../correx/apps/cli/commands/TaskCommand.kt | 223 ++++++++++++++++++ .../apps/cli/commands/TaskRenderTest.kt | 45 ++++ 3 files changed, 270 insertions(+) create mode 100644 apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt create mode 100644 apps/cli/src/test/kotlin/com/correx/apps/cli/commands/TaskRenderTest.kt diff --git a/apps/cli/src/main/kotlin/com/correx/apps/cli/CorrexCli.kt b/apps/cli/src/main/kotlin/com/correx/apps/cli/CorrexCli.kt index 8d7fd0e6..cd477b82 100644 --- a/apps/cli/src/main/kotlin/com/correx/apps/cli/CorrexCli.kt +++ b/apps/cli/src/main/kotlin/com/correx/apps/cli/CorrexCli.kt @@ -9,6 +9,7 @@ import com.correx.apps.cli.commands.RunCommand import com.correx.apps.cli.commands.SessionCommand import com.correx.apps.cli.commands.StatsCommand import com.correx.apps.cli.commands.StatusCommand +import com.correx.apps.cli.commands.TaskCommand import com.correx.apps.cli.commands.UndoCommand import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.core.subcommands @@ -33,4 +34,5 @@ fun buildCli(): CorrexCli = CorrexCli().subcommands( ReplayCommand(), StatsCommand(), HealthCommand(), + TaskCommand(), ) diff --git a/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt new file mode 100644 index 00000000..3a75d00c --- /dev/null +++ b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt @@ -0,0 +1,223 @@ +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.multiple +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.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import java.io.File +import java.net.URLEncoder +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +private val taskJson = Json { ignoreUnknownKeys = true } + +/** Subset of the server's TaskResponse the CLI renders. Unknown fields are ignored. */ +@Serializable +data class TaskRow( + val id: String, + val key: String? = null, + val status: String = "", + val title: String? = null, + val goal: String? = null, + val acceptanceCriteria: List = emptyList(), + val affectedPaths: List = emptyList(), + val claimant: String? = null, + val links: List = emptyList(), + val notes: List = emptyList(), +) + +@Serializable +data class TaskLinkRow(val targetId: String, val type: String, val targetKind: String) + +@Serializable +data class TaskNoteRow(val author: String, val body: String) + +/** One line per task: id, status, title, and claimant. */ +fun renderTaskList(tasks: List): String { + if (tasks.isEmpty()) return "no tasks" + return tasks.joinToString("\n") { t -> + val claim = t.claimant?.let { " @$it" }.orEmpty() + "${t.id.padEnd(14)} ${t.status.padEnd(12)} ${t.title.orEmpty()}$claim".trimEnd() + } +} + +/** Full single-task view: goal, acceptance criteria, affected paths, links, notes. */ +fun renderTaskDetail(t: TaskRow): String = buildString { + appendLine("task ${t.id} [${t.status}] ${t.title.orEmpty()}".trimEnd()) + t.claimant?.let { appendLine("claimant: $it") } + t.goal?.let { appendLine("goal: $it") } + if (t.acceptanceCriteria.isNotEmpty()) { + appendLine("acceptance criteria:") + t.acceptanceCriteria.forEach { appendLine(" - $it") } + } + if (t.affectedPaths.isNotEmpty()) appendLine("affected paths: ${t.affectedPaths.joinToString(", ")}") + if (t.links.isNotEmpty()) { + appendLine("links:") + t.links.forEach { appendLine(" - ${it.targetId} (${it.type} -> ${it.targetKind})") } + } + if (t.notes.isNotEmpty()) { + appendLine("notes:") + t.notes.forEach { appendLine(" - [${it.author}] ${it.body}") } + } +}.trimEnd() + +class TaskCommand : CliktCommand(name = "task") { + override fun run() = Unit + + init { + subcommands( + TaskListCommand(), + TaskSearchCommand(), + TaskShowCommand(), + TaskCreateCommand(), + TaskClaimCommand(), + TaskCompleteCommand(), + TaskExportCommand(), + ) + } +} + +/** Shared host/port options + HTTP plumbing for the task subcommands. */ +abstract class TaskHttpCommand(name: String) : CliktCommand(name = name) { + protected val host by option("--host").default("localhost") + protected val port by option("--port").default("$DEFAULT_PORT") + + protected fun baseUrl(): String = "http://$host:${port.toIntOrNull() ?: DEFAULT_PORT}" + + protected fun jsonOut(): Boolean = (currentContext.findRoot().command as? CorrexCli)?.json ?: false + + protected fun http(block: suspend (HttpClient) -> Unit) = runBlocking { + val client = HttpClient(CIO) { install(ContentNegotiation) { json(taskJson) } } + runCatching { block(client) }.getOrElse { System.err.println("Error: ${it.message}") } + client.close() + } +} + +private fun enc(value: String): String = URLEncoder.encode(value, "UTF-8") + +class TaskListCommand : TaskHttpCommand("list") { + private val project by option("--project", help = "Limit to a project") + private val status by option("--status", help = "Filter by status (TODO, IN_PROGRESS, …)") + + override fun run() = http { client -> + val params = buildList { + project?.let { add("project=${enc(it)}") } + status?.let { add("status=${enc(it)}") } + } + val query = if (params.isEmpty()) "" else "?${params.joinToString("&")}" + val raw = client.get("${baseUrl()}/tasks$query").bodyAsText() + if (jsonOut()) println(raw) else println(renderTaskList(taskJson.decodeFromString(raw))) + } +} + +class TaskSearchCommand : TaskHttpCommand("search") { + private val query by argument("QUERY", help = "Search text (quote multi-word queries)") + private val project by option("--project", help = "Limit to a project") + + override fun run() = http { client -> + val params = buildList { + add("q=${enc(query)}") + project?.let { add("project=${enc(it)}") } + } + val raw = client.get("${baseUrl()}/tasks?${params.joinToString("&")}").bodyAsText() + if (jsonOut()) println(raw) else println(renderTaskList(taskJson.decodeFromString(raw))) + } +} + +class TaskShowCommand : TaskHttpCommand("show") { + private val id by argument("TASK_ID") + + override fun run() = http { client -> + val resp = client.get("${baseUrl()}/tasks/$id") + if (resp.status == HttpStatusCode.NotFound) { + System.err.println("No such task: $id") + } else { + val raw = resp.bodyAsText() + if (jsonOut()) println(raw) else println(renderTaskDetail(taskJson.decodeFromString(raw))) + } + } +} + +class TaskCreateCommand : TaskHttpCommand("create") { + private val project by option("--project", help = "Project key, e.g. 'auth'").required() + private val title by option("--title").required() + private val goal by option("--goal").required() + private val criteria by option("--criteria", help = "Acceptance criterion (repeatable)").multiple() + private val paths by option("--path", help = "Affected path/glob (repeatable)").multiple() + + override fun run() = http { client -> + val payload = buildJsonObject { + put("project", project) + put("title", title) + put("goal", goal) + if (criteria.isNotEmpty()) put("acceptanceCriteria", JsonArray(criteria.map { JsonPrimitive(it) })) + if (paths.isNotEmpty()) put("affectedPaths", JsonArray(paths.map { JsonPrimitive(it) })) + } + val raw = client.post("${baseUrl()}/tasks") { + contentType(ContentType.Application.Json) + setBody(payload) + }.bodyAsText() + if (jsonOut()) println(raw) else println("created ${taskJson.decodeFromString(raw).id}") + } +} + +class TaskClaimCommand : TaskHttpCommand("claim") { + private val id by argument("TASK_ID") + private val by by option("--by", help = "Claimant").required() + + override fun run() = http { client -> + val resp = client.post("${baseUrl()}/tasks/$id/claim") { + contentType(ContentType.Application.Json) + setBody(buildJsonObject { put("claimant", by) }) + } + if (resp.status == HttpStatusCode.NotFound) System.err.println("No such task: $id") else println("claimed $id") + } +} + +class TaskCompleteCommand : TaskHttpCommand("complete") { + private val id by argument("TASK_ID") + + override fun run() = http { client -> + val resp = client.post("${baseUrl()}/tasks/$id/complete") + if (resp.status == HttpStatusCode.NotFound) System.err.println("No such task: $id") else println("completed $id") + } +} + +class TaskExportCommand : TaskHttpCommand("export") { + private val project by option("--project", help = "Limit to a project") + private val out by option("--out", help = "Write to this file instead of stdout") + + override fun run() = http { client -> + val query = project?.let { "?project=${enc(it)}" }.orEmpty() + val markdown = client.get("${baseUrl()}/tasks/export$query").bodyAsText() + val target = out + if (target != null) { + File(target).writeText(markdown) + println("wrote $target") + } else { + print(markdown) + } + } +} diff --git a/apps/cli/src/test/kotlin/com/correx/apps/cli/commands/TaskRenderTest.kt b/apps/cli/src/test/kotlin/com/correx/apps/cli/commands/TaskRenderTest.kt new file mode 100644 index 00000000..11a11efd --- /dev/null +++ b/apps/cli/src/test/kotlin/com/correx/apps/cli/commands/TaskRenderTest.kt @@ -0,0 +1,45 @@ +package com.correx.apps.cli.commands + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class TaskRenderTest { + + private val task = TaskRow( + id = "auth-1", + key = "auth-1", + status = "IN_PROGRESS", + title = "JWT refresh", + goal = "users stay authenticated", + acceptanceCriteria = listOf("rotates"), + affectedPaths = listOf("backend/auth/**"), + claimant = "claude-opus", + links = listOf(TaskLinkRow("adr-7", "IMPLEMENTS", "DOC")), + notes = listOf(TaskNoteRow("AGENT", "kickoff")), + ) + + @Test + fun `renderTaskList shows one line per task with id, status, title`() { + val out = renderTaskList(listOf(task, TaskRow(id = "billing-3", status = "DONE", title = "Invoice"))) + val lines = out.lines() + assertEquals(2, lines.size) + assertTrue(lines[0].startsWith("auth-1")) + assertTrue(lines[0].contains("IN_PROGRESS") && lines[0].contains("JWT refresh") && lines[0].contains("@claude-opus")) + assertTrue(lines[1].contains("billing-3") && lines[1].contains("DONE")) + } + + @Test + fun `renderTaskList reports empty`() { + assertEquals("no tasks", renderTaskList(emptyList())) + } + + @Test + fun `renderTaskDetail includes goal, criteria, links and notes`() { + val out = renderTaskDetail(task) + assertTrue(out.startsWith("task auth-1 [IN_PROGRESS] JWT refresh")) + for (want in listOf("goal: users stay authenticated", "- rotates", "backend/auth/**", "adr-7 (IMPLEMENTS -> DOC)", "[AGENT] kickoff")) { + assertTrue(out.contains(want), "detail missing: $want") + } + } +}