Browse Source

feat(skills): add terminal-output design language

Introduce docs/DESIGN.md (glyph palette, layouts, color rules,
anti-patterns) and skills/_lib/term.sh, a small bash helper library
for TTY/NO_COLOR/ASCII detection and shared layout primitives
(term_header, term_table_row, term_state_icon, term_empty, ...).

Retrofit fleet-ops as the proof of concept: the inline icon table
and "── Fleet ──" hand-rolled header now route through term.sh.
Existing e2e suite passes in both Unicode and ASCII modes.

Status: experimental. New output-heavy skills should follow
docs/DESIGN.md and source skills/_lib/term.sh — noted in
rules/skill-agent-updates.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
0xDarkMatter 2 months ago
parent
commit
cffcb5d97e
4 changed files with 400 additions and 25 deletions
  1. 202 0
      docs/DESIGN.md
  2. 4 0
      rules/skill-agent-updates.md
  3. 169 0
      skills/_lib/term.sh
  4. 25 25
      skills/fleet-ops/scripts/fleet.sh

+ 202 - 0
docs/DESIGN.md

@@ -0,0 +1,202 @@
+# Terminal Output Design Language
+
+> Status: **experimental**. The first skill on this language is `fleet-ops`.
+> New output-heavy skills should follow this guide and source `skills/_lib/term.sh`.
+
+claude-mods ships ~70 skills, many of which write to a TTY (`fleet-ops`,
+`git-ops`, `push-gate`, `sync`, ...). When you run five of them in one session
+and each rolled its own glyphs and dividers, the toolkit feels like five
+toolkits. This document is the forcing function: one palette, one helper
+library, one shape.
+
+## Principles
+
+1. **Readable first, structured second, decorative last.** A pipe-friendly
+   plaintext line beats a beautiful one nobody can grep.
+2. **ASCII fallback is mandatory.** Every Unicode glyph has an ASCII twin.
+   Honor `TERM_ASCII=1`, `LANG` without UTF-8, and `TERM=dumb`.
+3. **Respect the pipe.** No color into non-TTY stdout. Honor `NO_COLOR`.
+   `FORCE_COLOR=1` overrides for CI tooling that wants ANSI in logs.
+4. **One screen of output preferred.** A status command should fit in 24
+   lines on a default terminal. Long output earns its length.
+5. **80 columns is the ceiling.** Some users still split panes. Tables that
+   exceed it must wrap or truncate, not scroll horizontally.
+6. **Color is signal, not skin.** Never use color as the *only* differentiator.
+   Glyphs and labels carry the meaning; color amplifies.
+
+## Glyph Palette
+
+State icons. Use through `term_state_icon` when possible; the literals are
+listed for cross-reference.
+
+| Meaning | Unicode | ASCII | Color   | Use for                          |
+| ------- | ------- | ----- | ------- | -------------------------------- |
+| pending | ⏳      | `[.]` | yellow  | running, queued, in-flight       |
+| ready   | ✅      | `[+]` | green   | passed, ready to land            |
+| done    | 🚀      | `[*]` | green   | merged, shipped, terminal good   |
+| failed  | ❌      | `[x]` | red     | tests failed, refused, blocked   |
+| warning | ⚠️      | `[!]` | yellow  | conflict, hygiene flag           |
+| hint    | 💡      | `[i]` | cyan    | suggestion, next-step pointer    |
+
+> Don't introduce new state glyphs without adding them here and to
+> `term_state_icon`. Improvising glyphs is what got us here.
+
+## Box Drawing
+
+Use sparingly — borders that wrap nothing waste lines.
+
+| Role        | Unicode      | ASCII   |
+| ----------- | ------------ | ------- |
+| horizontal  | `─`          | `-`     |
+| vertical    | `│`          | `\|`    |
+| corners     | `┌ ┐ └ ┘`    | `+`     |
+| connectors  | `├ ┤ ┬ ┴ ┼`  | `+`     |
+| tree branch | `├─ └─ │`    | `+- \`- \|` |
+
+`term_header` and `term_divider` already pick the right glyph based on
+`TERM_ASCII_MODE`. Reach for them before drawing your own boxes.
+
+## Layouts
+
+### Header block
+
+A header opens a logical section. Title is cyan; trailing meta is dim.
+
+```
+── Fleet ──────────────────────────────────────────────────────  3 lanes
+```
+
+### Status table
+
+Two- or three-column, glyph-first. No nested tables.
+
+```
+  ⏳  feat/auth-rewrite             RUNNING    12m
+  ✅  fix/cache-bust                READY      2m
+  🚀  chore/bump-deps               LANDED     1h
+  ❌  spike/wasm                    FAILED     34m
+```
+
+### Tree
+
+For hierarchical state — worktrees under a repo, files under a branch.
+
+```
+repo/
+├─ main                           clean
+├─ feat/auth-rewrite              ahead 3, dirty
+└─ fix/cache-bust                 behind 1
+```
+
+### Section divider
+
+Plain rule between blocks. No title.
+
+```
+────────────────────────────────────────────────────────────────
+```
+
+### Empty state
+
+Dim, parenthesised, single line — never a multi-line "nothing here" banner.
+
+```
+  (no lanes — run: fleet init <name>...)
+```
+
+## Colors
+
+Color is signal layered on top of glyph and label. Strip color and the
+output must still be readable.
+
+| Color  | Meaning                                  |
+| ------ | ---------------------------------------- |
+| green  | success, terminal-good (READY, LANDED)   |
+| yellow | pending or warning (RUNNING, CONFLICT)   |
+| red    | failure (FAILED, refused)                |
+| cyan   | section headers, hints                   |
+| dim    | metadata: timestamps, counts, hint text  |
+
+Disabled when stdout isn't a TTY, or `NO_COLOR` is set. Forced on with
+`FORCE_COLOR=1`.
+
+## Examples (rendered)
+
+### Before — `fleet-ops` rolling its own
+
+```
+── Fleet ──────────────────────────────────────────────────────
+        BRANCH                           STATUS     AGE
+────────────────────────────────────────────────────────────────
+  ⏳   feat/auth-rewrite                 RUNNING    12m
+  ✅   fix/cache-bust                    READY      2m
+────────────────────────────────────────────────────────────────
+```
+
+### After — same skill, sourcing `_lib/term.sh`
+
+```
+── Fleet ──────────────────────────────────────────────────────  2 lanes
+  ⏳   feat/auth-rewrite                 RUNNING    12m
+  ✅   fix/cache-bust                    READY      2m
+```
+
+The chrome shrinks; the glyph and meta carry the structure.
+
+### `git-ops/status` reformatted in the same language
+
+```
+── Repo ───────────────────────────────────────────────────────  X:/Forge/claude-mods
+  branch    claude/sleepy-johnson-74f19d
+  HEAD      367b062 fix(skills/fleet-ops): consistent .claude/ path (2h ago)
+  sync      0 ahead / 0 behind
+  tree      0 staged / 2 unstaged / 1 untracked
+
+  ⚠️   HYGIENE  main checkout on 'claude/...' — feature work belongs in worktrees
+```
+
+### `push-gate` refusal
+
+```
+── push-gate ──────────────────────────────────────────────────  refusing
+  ❌   secret scan        2 hits in src/config/keys.ts
+  ✅   forbidden files    none
+  ✅   divergence         clean
+
+  💡   run: gitleaks detect --source . --no-git
+```
+
+## Anti-patterns
+
+- **Decorative emoji.** ✨📦🎉 carry no state. Keep the glyph budget for the
+  six in the palette.
+- **Nested tables or boxes.** A table inside a bordered box is two layouts
+  fighting for the same line. Pick one.
+- **Color as the only difference.** "Red row vs green row" fails for
+  CI logs, screen readers, and color-blind users. Always pair with a glyph.
+- **Lines past 80 columns by default.** If you genuinely need 120, gate it
+  behind `--wide` or auto-detect via `tput cols`.
+- **Assuming color in CI.** GitHub Actions sets `TERM=dumb`. Check.
+- **Multi-line empty states.** `(no lanes)` beats a 4-line ASCII shrug.
+- **New glyphs.** If your state doesn't fit pending/ready/done/failed/warn/hint,
+  the state probably collapses into one of them. If it really doesn't,
+  amend this document first.
+
+## The library
+
+`skills/_lib/term.sh` is the single source of truth for glyphs, colors,
+and layout helpers. Source it, call `term_init`, then use:
+
+```bash
+LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../_lib" && pwd)"
+. "$LIB/term.sh"
+term_init
+
+term_header "Fleet" "$count lanes"
+term_table_row "$(term_state_icon READY)" "$branch" "READY" "$age"
+term_empty "no lanes — run: fleet init <name>..."
+```
+
+The helpers no-op gracefully under `NO_COLOR`, non-TTY, and `TERM_ASCII=1`.
+That's the whole contract — if you're reaching for raw `\033[` codes in a
+skill, you're off the path.

+ 4 - 0
rules/skill-agent-updates.md

@@ -8,3 +8,7 @@
 | Sub-agents | https://code.claude.com/docs/en/sub-agents |
 
 These APIs change frequently. For detailed reference (frontmatter fields, decision frameworks), see `docs/SKILL-SUBAGENT-REFERENCE.md`.
+
+## Terminal output
+
+Skills that print to a TTY follow `docs/DESIGN.md` and source `skills/_lib/term.sh` for glyphs, colors, and layout helpers. Don't roll your own ANSI codes or state icons — `term_init` plus `term_state_icon` / `term_header` / `term_table_row` cover the common cases and keep the toolkit visually coherent.

+ 169 - 0
skills/_lib/term.sh

@@ -0,0 +1,169 @@
+#!/usr/bin/env bash
+# term.sh — shared terminal-output helpers for claude-mods skills.
+#
+# Source from any skill script:
+#   LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../_lib" && pwd)"
+#   . "$LIB/term.sh"
+#   term_init
+#
+# Honors: NO_COLOR, FORCE_COLOR, TERM_ASCII=1.
+# Status: experimental — see docs/DESIGN.md.
+
+# Guard against double-sourcing.
+[[ -n "${__TERM_SH_LOADED:-}" ]] && return 0
+__TERM_SH_LOADED=1
+
+# Globals populated by term_init.
+TERM_TTY=0
+TERM_COLOR=0
+TERM_ASCII_MODE=0
+TERM_WIDTH=80
+
+# State icons (set by term_init based on TERM_ASCII_MODE).
+TERM_ICON_PENDING=""
+TERM_ICON_READY=""
+TERM_ICON_DONE=""
+TERM_ICON_FAILED=""
+TERM_ICON_WARN=""
+TERM_ICON_HINT=""
+
+# ANSI escapes (empty when color disabled).
+TERM_C_GREEN=""
+TERM_C_YELLOW=""
+TERM_C_RED=""
+TERM_C_CYAN=""
+TERM_C_DIM=""
+TERM_C_OFF=""
+
+term_init() {
+  # TTY detection — stdout only.
+  if [[ -t 1 ]]; then TERM_TTY=1; else TERM_TTY=0; fi
+
+  # ASCII fallback: explicit env, or non-UTF locale.
+  if [[ "${TERM_ASCII:-}" == "1" ]] || [[ "${FLEET_ASCII:-}" == "1" ]]; then
+    TERM_ASCII_MODE=1
+  elif [[ "${LC_ALL:-${LANG:-}}" != *[Uu][Tt][Ff]* ]] && [[ -z "${LC_ALL:-${LANG:-}}" || "${TERM:-}" == "dumb" ]]; then
+    TERM_ASCII_MODE=1
+  else
+    TERM_ASCII_MODE=0
+  fi
+
+  # Color: TTY + not NO_COLOR, or FORCE_COLOR overrides.
+  if [[ -n "${FORCE_COLOR:-}" ]]; then
+    TERM_COLOR=1
+  elif [[ -n "${NO_COLOR:-}" ]] || [[ "$TERM_TTY" -eq 0 ]] || [[ "${TERM:-}" == "dumb" ]]; then
+    TERM_COLOR=0
+  else
+    TERM_COLOR=1
+  fi
+
+  # Terminal width — fall back to 80.
+  if [[ "$TERM_TTY" -eq 1 ]] && command -v tput >/dev/null 2>&1; then
+    TERM_WIDTH=$(tput cols 2>/dev/null || echo 80)
+  fi
+  [[ "$TERM_WIDTH" -lt 40 ]] && TERM_WIDTH=80
+
+  if [[ "$TERM_ASCII_MODE" -eq 1 ]]; then
+    TERM_ICON_PENDING="[.]"
+    TERM_ICON_READY="[+]"
+    TERM_ICON_DONE="[*]"
+    TERM_ICON_FAILED="[x]"
+    TERM_ICON_WARN="[!]"
+    TERM_ICON_HINT="[i]"
+  else
+    TERM_ICON_PENDING="⏳"
+    TERM_ICON_READY="✅"
+    TERM_ICON_DONE="🚀"
+    TERM_ICON_FAILED="❌"
+    TERM_ICON_WARN="⚠️ "
+    TERM_ICON_HINT="💡"
+  fi
+
+  if [[ "$TERM_COLOR" -eq 1 ]]; then
+    TERM_C_GREEN=$'\033[32m'
+    TERM_C_YELLOW=$'\033[33m'
+    TERM_C_RED=$'\033[31m'
+    TERM_C_CYAN=$'\033[36m'
+    TERM_C_DIM=$'\033[2m'
+    TERM_C_OFF=$'\033[0m'
+  else
+    TERM_C_GREEN=""; TERM_C_YELLOW=""; TERM_C_RED=""
+    TERM_C_CYAN=""; TERM_C_DIM=""; TERM_C_OFF=""
+  fi
+}
+
+# term_color <name> <text...>  — wrap text in named color (green/yellow/red/cyan/dim).
+term_color() {
+  local name=$1; shift
+  local code=""
+  case "$name" in
+    green)  code="$TERM_C_GREEN" ;;
+    yellow) code="$TERM_C_YELLOW" ;;
+    red)    code="$TERM_C_RED" ;;
+    cyan)   code="$TERM_C_CYAN" ;;
+    dim)    code="$TERM_C_DIM" ;;
+  esac
+  printf '%s%s%s' "$code" "$*" "$TERM_C_OFF"
+}
+
+# term_state_icon <STATE>  — echo glyph for a known state.
+term_state_icon() {
+  case "$1" in
+    RUNNING|PENDING)   printf '%s' "$TERM_ICON_PENDING" ;;
+    READY)             printf '%s' "$TERM_ICON_READY" ;;
+    LANDED|DONE|OK)    printf '%s' "$TERM_ICON_DONE" ;;
+    FAILED|ERROR)      printf '%s' "$TERM_ICON_FAILED" ;;
+    CONFLICT|WARN)     printf '%s' "$TERM_ICON_WARN" ;;
+    HINT|INFO)         printf '%s' "$TERM_ICON_HINT" ;;
+    *)                 printf '%s' "?" ;;
+  esac
+}
+
+# term_repeat <char> <n>
+term_repeat() {
+  local ch=$1 n=$2 i out=""
+  for (( i=0; i<n; i++ )); do out="$out$ch"; done
+  printf '%s' "$out"
+}
+
+# term_header <title> [meta]  — "── title ──────  meta"
+term_header() {
+  local title=$1 meta=${2:-}
+  local glyph="─"; [[ "$TERM_ASCII_MODE" -eq 1 ]] && glyph="-"
+  local pad=$(( TERM_WIDTH - ${#title} - 6 ))
+  [[ $pad -lt 4 ]] && pad=4
+  local line
+  line="$(term_repeat "$glyph" 2) $(term_color cyan "$title") $(term_repeat "$glyph" "$pad")"
+  if [[ -n "$meta" ]]; then
+    printf '%s  %s\n' "$line" "$(term_color dim "$meta")"
+  else
+    printf '%s\n' "$line"
+  fi
+}
+
+# term_divider [width]  — plain horizontal rule.
+term_divider() {
+  local w=${1:-$TERM_WIDTH}
+  local glyph="─"; [[ "$TERM_ASCII_MODE" -eq 1 ]] && glyph="-"
+  printf '%s\n' "$(term_repeat "$glyph" "$w")"
+}
+
+# term_tree_item <icon> <label> [meta]  — "  <icon>  label                  meta"
+term_tree_item() {
+  local icon=$1 label=$2 meta=${3:-}
+  if [[ -n "$meta" ]]; then
+    printf '  %s  %-32s %s\n' "$icon" "$label" "$(term_color dim "$meta")"
+  else
+    printf '  %s  %s\n' "$icon" "$label"
+  fi
+}
+
+# term_table_row <c1> <c2> <c3>  — fixed-width 3-col row.
+term_table_row() {
+  printf '  %-2s  %-32s %-10s %s\n' "${1:-}" "${2:-}" "${3:-}" "${4:-}"
+}
+
+# term_empty <message>  — dim italic-ish empty state.
+term_empty() {
+  printf '  %s\n' "$(term_color dim "($*)")"
+}

+ 25 - 25
skills/fleet-ops/scripts/fleet.sh

@@ -10,6 +10,13 @@ CONFIG="$FLEET_DIR/config"
 PID_FILE="$FLEET_DIR/daemon.pid"
 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 
+# Shared terminal-output helpers (see docs/DESIGN.md).
+# shellcheck source=../../_lib/term.sh
+. "$SCRIPT_DIR/../../_lib/term.sh"
+# Honor legacy FLEET_ASCII alongside TERM_ASCII.
+[[ "${FLEET_ASCII:-}" == "1" || "${icons:-}" == "ascii" ]] && export TERM_ASCII=1
+term_init
+
 # defaults (overridable via .claude/fleet/config: key=value, no quotes)
 MODE="auto"
 WORKTREE_ROOT=".claude/fleet/worktrees"
@@ -19,15 +26,13 @@ BASE_BRANCH="main"
 POLL_INTERVAL=5
 [[ -f "$CONFIG" ]] && source "$CONFIG" 2>/dev/null || true
 
-# OS-aware icon set: Unicode by default, ASCII fallback for legacy terminals
-# Override: FLEET_ASCII=1 or .fleet/config has icons=ascii
-if [[ "${FLEET_ASCII:-}" == "1" ]] || [[ "${icons:-}" == "ascii" ]]; then
-  ICON_RUNNING="[.]"; ICON_READY="[+]"; ICON_LANDED="[*]"
-  ICON_FAILED="[X]";  ICON_CONFLICT="[!]"; ICON_UNKNOWN="[?]"
-else
-  ICON_RUNNING="⏳";  ICON_READY="✅";  ICON_LANDED="🚀"
-  ICON_FAILED="❌";   ICON_CONFLICT="⚠️ "; ICON_UNKNOWN="? "
-fi
+# Icons resolved through the shared term lib (term_state_icon).
+ICON_RUNNING="$(term_state_icon RUNNING)"
+ICON_READY="$(term_state_icon READY)"
+ICON_LANDED="$(term_state_icon LANDED)"
+ICON_FAILED="$(term_state_icon FAILED)"
+ICON_CONFLICT="$(term_state_icon CONFLICT)"
+ICON_UNKNOWN="?"
 
 # Cross-platform mtime: GNU stat (Linux/Git Bash) vs BSD stat (macOS)
 file_mtime() {
@@ -116,14 +121,16 @@ cmd_init() {
 
 cmd_fleet() {
   ensure_fleet_dir
+  local count=0
+  for f in "$LANES_DIR"/*; do [[ -f "$f" ]] && count=$((count+1)); done
+
   echo ""
-  echo "── Fleet ──────────────────────────────────────────────────────"
-  printf "  %-2s  %-32s %-10s %s\n" "" "BRANCH" "STATUS" "AGE"
-  echo "────────────────────────────────────────────────────────────────"
-  local any=0 now=$(date +%s)
+  term_header "Fleet" "$count $([ "$count" -eq 1 ] && echo lane || echo lanes)"
+  term_table_row "" "BRANCH" "STATUS" "AGE"
+
+  local now=$(date +%s)
   for f in "$LANES_DIR"/*; do
     [[ -f "$f" ]] || continue
-    any=1
     local branch state mtime secs age icon
     branch=$(basename "$f")
     state=$(head -n1 "$f")
@@ -133,18 +140,11 @@ cmd_fleet() {
     elif [[ $secs -lt 3600 ]]; then age="$((secs/60))m"
     else age="$((secs/3600))h$(( (secs%3600)/60 ))m"
     fi
-    case $state in
-      RUNNING)  icon="$ICON_RUNNING" ;;
-      READY)    icon="$ICON_READY" ;;
-      LANDED)   icon="$ICON_LANDED" ;;
-      FAILED)   icon="$ICON_FAILED" ;;
-      CONFLICT) icon="$ICON_CONFLICT" ;;
-      *)        icon="$ICON_UNKNOWN" ;;
-    esac
-    printf "  %s  %-32s %-10s %s\n" "$icon" "$branch" "$state" "$age"
+    icon=$(term_state_icon "$state")
+    [[ -z "$icon" || "$icon" == "?" ]] && icon="$ICON_UNKNOWN"
+    term_table_row "$icon" "$branch" "$state" "$age"
   done
-  [[ $any -eq 0 ]] && echo "  (no lanes — run: fleet init <name>...)"
-  echo "────────────────────────────────────────────────────────────────"
+  [[ $count -eq 0 ]] && term_empty "no lanes — run: fleet init <name>..."
 }
 
 cmd_scrub_check() {