feat(cli): colour amy's text output and humanise scalar values

Defaults to ANSI colour when stdout is a TTY (off when piped, off under
NO_COLOR=…, force-on under CLICOLOR_FORCE=1). Layout improvements that
apply to every command without per-command code:

- Sibling keys at each indentation level pad to the widest, so colons
  line up YAML-style.
- Keys render bold; list dashes and "(none)"/"(empty)" markers render
  dim.
- Booleans render as `yes` / `no` (green / red).
- Integer values under `*_at` keys render as
  `2026-04-25 12:30:45Z (2m ago)` instead of a raw epoch second.
- Integer values under `*_bytes` keys (and `size`) render as
  `7.0 KiB` / `1.2 MiB` / etc.
- Errors render `error: <code>: <detail>` with the prefix bold-red and
  the code yellow.

The `--json` shape and exit codes are untouched; the smart formatting
only changes how scalars surface in the human-text mode.
This commit is contained in:
Claude
2026-04-25 13:22:15 +00:00
parent 03eb7be509
commit ce3a82ab3d
@@ -22,6 +22,9 @@ package com.vitorpamplona.amethyst.cli
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
/**
* Stdout/stderr emitter for amy.
@@ -30,6 +33,15 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
* to switch to the machine contract: a single JSON object on stdout per
* successful command, JSON `{"error": ..., "detail": ...}` on stderr for
* failures. The JSON shape is the public API — the text shape is not.
*
* The text renderer:
* - aligns sibling keys at each indentation level (YAML-ish columns),
* - rewrites unix-second timestamps under `*_at` keys to a readable
* ISO + relative form,
* - rewrites byte counts under `*_bytes` keys to KiB / MiB / GiB,
* - rewrites booleans to yes / no,
* - paints the result with ANSI colour when stdout is a TTY (disabled
* by `NO_COLOR`, forced on by `CLICOLOR_FORCE`).
*/
object Output {
enum class Mode { TEXT, JSON }
@@ -57,23 +69,27 @@ object Output {
}
Mode.TEXT -> {
System.err.println(if (detail != null) "error: $code: $detail" else "error: $code")
val color = Ansi.forStream(isStderr = true)
val prefix = color.bold(color.red("error"))
val codePart = color.yellow(code)
System.err.println(if (detail != null) "$prefix: $codePart: $detail" else "$prefix: $codePart")
}
}
return 1
}
private fun renderText(value: Any?): String {
val color = Ansi.forStream(isStderr = false)
val out = StringBuilder()
when (value) {
null -> {}
is Map<*, *> -> {
renderMapBody(out, value, "")
renderMapBody(out, value, "", color)
}
is List<*> -> {
renderListBody(out, value, "")
renderListBody(out, value, "", color)
}
else -> {
@@ -87,35 +103,52 @@ object Output {
out: StringBuilder,
map: Map<*, *>,
prefix: String,
color: Ansi,
) {
for ((k, v) in map) {
if (v == null) continue
val entries = map.entries.filter { it.value != null }
if (entries.isEmpty()) return
val keyWidth = entries.maxOf { it.key.toString().length }
for ((k, v) in entries) {
val key = k.toString()
val coloredKey = color.bold(key)
val padding = " ".repeat(keyWidth - key.length)
when (v) {
is Map<*, *> -> {
if (v.isEmpty()) {
out.append(prefix).append(key).append(": {}\n")
out
.append(prefix)
.append(coloredKey)
.append(":")
.append(padding)
out.append(' ').append(color.dim("(empty)")).append('\n')
} else {
out.append(prefix).append(key).append(":\n")
renderMapBody(out, v, "$prefix ")
out.append(prefix).append(coloredKey).append(":\n")
renderMapBody(out, v, "$prefix ", color)
}
}
is List<*> -> {
if (v.isEmpty()) {
out.append(prefix).append(key).append(": []\n")
out
.append(prefix)
.append(coloredKey)
.append(":")
.append(padding)
out.append(' ').append(color.dim("(none)")).append('\n')
} else {
out.append(prefix).append(key).append(":\n")
renderListBody(out, v, "$prefix ")
out.append(prefix).append(coloredKey).append(":\n")
renderListBody(out, v, "$prefix ", color)
}
}
else -> {
out
.append(prefix)
.append(key)
.append(": ")
.append(v.toString())
.append(coloredKey)
.append(":")
.append(padding)
.append(' ')
.append(formatScalar(key, v, color))
.append('\n')
}
}
@@ -126,7 +159,9 @@ object Output {
out: StringBuilder,
list: List<*>,
prefix: String,
color: Ansi,
) {
val dash = color.dim("-")
for (item in list) {
when (item) {
null -> {}
@@ -134,48 +169,76 @@ object Output {
is Map<*, *> -> {
val entries = item.entries.filter { it.value != null }
if (entries.isEmpty()) {
out.append(prefix).append("- {}\n")
out
.append(prefix)
.append(dash)
.append(' ')
.append(color.dim("(empty)"))
.append('\n')
continue
}
val keyWidth = entries.maxOf { it.key.toString().length }
val first = entries.first()
val firstV = first.value
if (firstV is Map<*, *> || firstV is List<*>) {
out.append(prefix).append("-\n")
renderMapBody(out, item, "$prefix ")
out.append(prefix).append(dash).append('\n')
renderMapBody(out, item, "$prefix ", color)
} else {
val firstKey = first.key.toString()
out
.append(prefix)
.append("- ")
.append(first.key)
.append(": ")
.append(firstV.toString())
.append(dash)
.append(' ')
.append(color.bold(firstKey))
.append(':')
.append(" ".repeat(keyWidth - firstKey.length))
.append(' ')
.append(formatScalar(firstKey, firstV, color))
.append('\n')
for ((rk, rv) in entries.drop(1)) {
val rKey = rk.toString()
val rPad = " ".repeat(keyWidth - rKey.length)
when (rv) {
is Map<*, *> -> {
if (rv.isEmpty()) {
out.append("$prefix ").append(rk).append(": {}\n")
out
.append("$prefix ")
.append(color.bold(rKey))
.append(':')
.append(rPad)
.append(' ')
.append(color.dim("(empty)"))
.append('\n')
} else {
out.append("$prefix ").append(rk).append(":\n")
renderMapBody(out, rv, "$prefix ")
out.append("$prefix ").append(color.bold(rKey)).append(":\n")
renderMapBody(out, rv, "$prefix ", color)
}
}
is List<*> -> {
if (rv.isEmpty()) {
out.append("$prefix ").append(rk).append(": []\n")
out
.append("$prefix ")
.append(color.bold(rKey))
.append(':')
.append(rPad)
.append(' ')
.append(color.dim("(none)"))
.append('\n')
} else {
out.append("$prefix ").append(rk).append(":\n")
renderListBody(out, rv, "$prefix ")
out.append("$prefix ").append(color.bold(rKey)).append(":\n")
renderListBody(out, rv, "$prefix ", color)
}
}
else -> {
out
.append("$prefix ")
.append(rk)
.append(": ")
.append(rv.toString())
.append(color.bold(rKey))
.append(':')
.append(rPad)
.append(' ')
.append(formatScalar(rKey, rv, color))
.append('\n')
}
}
@@ -184,18 +247,128 @@ object Output {
}
is List<*> -> {
out.append(prefix).append("-\n")
renderListBody(out, item, "$prefix ")
out.append(prefix).append(dash).append('\n')
renderListBody(out, item, "$prefix ", color)
}
else -> {
out
.append(prefix)
.append("- ")
.append(item.toString())
.append(dash)
.append(' ')
.append(formatScalar("", item, color))
.append('\n')
}
}
}
}
/**
* Generic scalar formatting that's safe for any command:
* - `*_at` ints → `2026-04-25 12:30:45Z (2m ago)`
* - `*_bytes` ints → `7.0 KiB`
* - bools → `yes` / `no`, coloured.
* The key is the snake_case name from the result map; an empty key
* means the scalar is an array element.
*/
private fun formatScalar(
key: String,
value: Any?,
color: Ansi,
): String {
if (value is Boolean) {
return if (value) color.green("yes") else color.red("no")
}
val asLong = (value as? Number)?.toLong()
if (asLong != null) {
if (key.endsWith("_at") && asLong > 1_000_000_000L) {
return formatTimestamp(asLong, color)
}
if (key.endsWith("_bytes") || key == "size") {
return formatBytes(asLong)
}
}
return value.toString()
}
private fun formatTimestamp(
epochSeconds: Long,
color: Ansi,
): String {
val instant = Instant.ofEpochSecond(epochSeconds)
val iso = ISO_FORMAT.format(instant)
val rel = relativeTime(Instant.now().epochSecond - epochSeconds)
return "$iso ${color.dim("($rel)")}"
}
private fun relativeTime(secondsAgo: Long): String {
val s = if (secondsAgo < 0) -secondsAgo else secondsAgo
val suffix = if (secondsAgo < 0) "from now" else "ago"
val unit =
when {
s < 60 -> "${s}s"
s < 3600 -> "${s / 60}m"
s < 86_400 -> "${s / 3600}h"
s < 30 * 86_400 -> "${s / 86_400}d"
s < 365 * 86_400 -> "${s / (30 * 86_400)}mo"
else -> "${s / (365 * 86_400)}y"
}
return "$unit $suffix"
}
private fun formatBytes(n: Long): String {
if (n < 1024) return "$n B"
val units = arrayOf("KiB", "MiB", "GiB", "TiB", "PiB")
var v = n.toDouble() / 1024.0
var i = 0
while (v >= 1024.0 && i < units.size - 1) {
v /= 1024.0
i++
}
return "%.1f %s".format(v, units[i])
}
private val ISO_FORMAT: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss'Z'").withZone(ZoneOffset.UTC)
}
/**
* Tiny ANSI helper. The standard contract:
* - colour on when stdout (or stderr) is a TTY,
* - off when piped or redirected,
* - `NO_COLOR=…` (any non-empty value) hard-disables,
* - `CLICOLOR_FORCE=1` re-enables even when piped.
*/
internal class Ansi(
private val enabled: Boolean,
) {
fun bold(s: String) = wrap(s, "")
fun dim(s: String) = wrap(s, "")
fun red(s: String) = wrap(s, "")
fun green(s: String) = wrap(s, "")
fun yellow(s: String) = wrap(s, "")
private fun wrap(
s: String,
code: String,
): String = if (enabled && s.isNotEmpty()) "$code$s" else s
companion object {
private val noColor: Boolean = !System.getenv("NO_COLOR").isNullOrEmpty()
private val forceColor: Boolean = System.getenv("CLICOLOR_FORCE") == "1"
fun forStream(isStderr: Boolean): Ansi {
if (noColor) return Ansi(false)
if (forceColor) return Ansi(true)
// System.console() is non-null only when both stdin and stdout are
// connected to a terminal. Good enough for the common case (interactive
// shell vs `amy ... | jq`); honor CLICOLOR_FORCE for the rest.
val tty = System.console() != null
return Ansi(tty)
}
}
}