mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
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:
@@ -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, "[1m")
|
||||
|
||||
fun dim(s: String) = wrap(s, "[2m")
|
||||
|
||||
fun red(s: String) = wrap(s, "[31m")
|
||||
|
||||
fun green(s: String) = wrap(s, "[32m")
|
||||
|
||||
fun yellow(s: String) = wrap(s, "[33m")
|
||||
|
||||
private fun wrap(
|
||||
s: String,
|
||||
code: String,
|
||||
): String = if (enabled && s.isNotEmpty()) "$code$s[0m" 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user