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:
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user