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 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 07:17:06 +00:00
parent 8a6678f171
commit f490968dc4
3 changed files with 270 additions and 0 deletions
@@ -9,6 +9,7 @@ import com.correx.apps.cli.commands.RunCommand
import com.correx.apps.cli.commands.SessionCommand import com.correx.apps.cli.commands.SessionCommand
import com.correx.apps.cli.commands.StatsCommand import com.correx.apps.cli.commands.StatsCommand
import com.correx.apps.cli.commands.StatusCommand import com.correx.apps.cli.commands.StatusCommand
import com.correx.apps.cli.commands.TaskCommand
import com.correx.apps.cli.commands.UndoCommand import com.correx.apps.cli.commands.UndoCommand
import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.subcommands import com.github.ajalt.clikt.core.subcommands
@@ -33,4 +34,5 @@ fun buildCli(): CorrexCli = CorrexCli().subcommands(
ReplayCommand(), ReplayCommand(),
StatsCommand(), StatsCommand(),
HealthCommand(), HealthCommand(),
TaskCommand(),
) )
@@ -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<String> = emptyList(),
val affectedPaths: List<String> = emptyList(),
val claimant: String? = null,
val links: List<TaskLinkRow> = emptyList(),
val notes: List<TaskNoteRow> = 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<TaskRow>): 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<TaskRow>(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)
}
}
}
@@ -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")
}
}
}