aboutsummaryrefslogtreecommitdiffstats
path: root/cli
diff options
context:
space:
mode:
Diffstat (limited to 'cli')
-rw-r--r--cli/LICENSE21
-rw-r--r--cli/README.md118
-rwxr-xr-xcli/test.sh200
-rwxr-xr-xcli/ugi660
4 files changed, 999 insertions, 0 deletions
diff --git a/cli/LICENSE b/cli/LICENSE
new file mode 100644
index 0000000..dd35f66
--- /dev/null
+++ b/cli/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Ahmed Mohamed Alaa
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/cli/README.md b/cli/README.md
new file mode 100644
index 0000000..478c178
--- /dev/null
+++ b/cli/README.md
@@ -0,0 +1,118 @@
+# ugi-cli
+
+Command-line tool for encoding, decoding, and converting UGI (Unified Geek Identifier) strings.
+
+## Dependencies
+
+- **bash** 4.0+
+- **jq** — [jqlang.github.io/jq](https://jqlang.github.io/jq/)
+- **ugi_registry_v0.json** — from [ugi-spec](https://git.sr.ht/~gumxcc/ugi-spec)
+
+## Setup
+
+```bash
+git clone https://git.sr.ht/~gumxcc/ugi-cli
+cd ugi-cli
+
+# Point to the registry (pick one):
+export UGI_REGISTRY=/path/to/ugi_registry_v0.json
+# or place/symlink it next to the script:
+ln -s /path/to/ugi-spec/ugi_registry_v0.json .
+```
+
+Optionally add to `$PATH`:
+
+```bash
+ln -s "$PWD/ugi" ~/.local/bin/ugi
+```
+
+## Usage
+
+### Decode
+
+Parse a UGI string into a human-readable table:
+
+```
+$ ugi decode "ugi:0@jdoe:gcs$/dev,a4,b5/4,ppy6$js6ts5,i5"
+
+Version: 0
+Handle: @jdoe
+
+Field Code Decoded
+----------------------------------------------------------------------
+Geek Specialization g Computer Science ($), dev
+Age a4 25-34
+Build b5/4 height: Above average; width: Average
+Programming Languages p Python=Advanced / daily [$], JavaScript=Advanced / daily, TypeScript=Competent
+AI Stance i5 Positive
+```
+
+Reads from stdin if no argument given:
+
+```bash
+echo "ugi:0@jdoe:gcs,a4" | ugi decode
+```
+
+### Convert
+
+Convert between URI and block format:
+
+```bash
+# URI → Block
+ugi convert "ugi:0@jdoe:gcs/dev,a4,ppy6rs5"
+
+# Block → URI
+ugi convert <<'EOF'
+------- BEGIN UGI BLOCK -------
+v:0 @jdoe Gcs/dev
+A4
+Ppy6 Prs5
+-------- END UGI BLOCK --------
+EOF
+```
+
+### Encode
+
+Interactive guided encoder:
+
+```bash
+ugi encode
+```
+
+### Fields
+
+List all field codes:
+
+```bash
+ugi fields
+```
+
+### Short aliases
+
+```bash
+ugi d "ugi:0@jdoe:gcs,a4" # decode
+ugi c "ugi:0@jdoe:gcs,a4" # convert
+ugi e # encode
+ugi f # fields
+```
+
+## Testing
+
+Run the test suite against the shared test cases from [ugi-spec](https://git.sr.ht/~gumxcc/ugi-spec):
+
+```bash
+./test.sh /path/to/ugi_registry_v0.json /path/to/ugi-spec/tests/cases.json
+```
+
+The test runner validates decode output (version, handle, field labels) and convert roundtrip (URI → Block → URI) for each case.
+
+## Environment
+
+| Variable | Description |
+|---|---|
+| `UGI_REGISTRY` | Path to `ugi_registry_v0.json`. Falls back to `./ugi_registry_v0.json` or `../ugi-spec/ugi_registry_v0.json`. |
+
+
+## License
+
+MIT — see [LICENSE](./LICENSE).
diff --git a/cli/test.sh b/cli/test.sh
new file mode 100755
index 0000000..357beeb
--- /dev/null
+++ b/cli/test.sh
@@ -0,0 +1,200 @@
+#!/usr/bin/env bash
+# Test runner for ugi-cli against cases.json
+set -uo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+UGI="$SCRIPT_DIR/ugi"
+
+if [[ $# -ne 2 ]]; then
+ echo "Usage: $0 <ugi_registry_v0.json> <cases.json>" >&2
+ exit 1
+fi
+
+REGISTRY="$1"
+CASES="$2"
+
+if [[ ! -f "$REGISTRY" ]]; then echo "error: registry not found: $REGISTRY" >&2; exit 1; fi
+if [[ ! -f "$CASES" ]]; then echo "error: cases not found: $CASES" >&2; exit 1; fi
+if [[ ! -x "$UGI" ]]; then echo "error: ugi not found at $UGI" >&2; exit 1; fi
+
+export UGI_REGISTRY="$REGISTRY"
+
+pass=0 fail=0 skip=0
+failures=()
+
+# ── Helpers ──────────────────────────────────────────────────────────────────
+
+expect_field_label() {
+ local case_id="$1" field_code="$2"
+ jq -r --arg id "$case_id" --arg fc "$field_code" '
+ .cases[] | select(.id == $id) | .decoded.fields[$fc] //empty |
+ if .type == "direct" then .label
+ elif .type == "special" and .dimensions then
+ [.dimensions | to_entries[] | "\(.key): \(.value.label)"] | join("; ")
+ elif .type == "special" and .domains then
+ [.domains[] | .name + (if .paid then " ($)" else "" end)] | join(", ")
+ elif .entries then
+ [.entries[] |
+ .name + (if .value != null then "=\(.label)" else "" end) +
+ (if .paid then " [$]" else "" end) +
+ (if .aspire then " [+\(.aspire)]" else "" end)
+ ] | join(", ")
+ else empty end
+ ' "$CASES"
+}
+
+run_decode_test() {
+ local case_id="$1" uri="$2"
+ local output
+ output=$("$UGI" decode "$uri" 2>&1) || true
+
+ local exp_ver
+ exp_ver=$(jq -r --arg id "$case_id" '.cases[] | select(.id == $id) | .decoded.version' "$CASES")
+ if ! echo "$output" | grep -qF "Version: $exp_ver"; then
+ failures+=("$case_id/decode/version: expected '$exp_ver'")
+ return 1
+ fi
+
+ local exp_handle
+ exp_handle=$(jq -r --arg id "$case_id" '.cases[] | select(.id == $id) | .decoded.handle' "$CASES")
+ if ! echo "$output" | grep -qF "Handle: @$exp_handle"; then
+ failures+=("$case_id/decode/handle: expected '@$exp_handle'")
+ return 1
+ fi
+
+ local field_codes
+ field_codes=$(jq -r --arg id "$case_id" '.cases[] | select(.id == $id) | .decoded.fields | keys[]' "$CASES" 2>/dev/null) || return 0
+
+ for fc in $field_codes; do
+ local exp_label
+ exp_label=$(expect_field_label "$case_id" "$fc")
+ [[ -z "$exp_label" ]] && continue
+
+ local entry_count
+ entry_count=$(jq -r --arg id "$case_id" --arg fc "$fc" '
+ .cases[] | select(.id == $id) | .decoded.fields[$fc].entries // [] | length
+ ' "$CASES")
+
+ if [[ "$entry_count" -gt 0 ]]; then
+ local entry_names
+ entry_names=$(jq -r --arg id "$case_id" --arg fc "$fc" '
+ .cases[] | select(.id == $id) | .decoded.fields[$fc].entries[]? | .name
+ ' "$CASES")
+ for ename in $entry_names; do
+ if ! echo "$output" | grep -qF "$ename"; then
+ failures+=("$case_id/decode/$fc: missing entry name '$ename' in output")
+ return 1
+ fi
+ done
+ elif [[ -n "$exp_label" ]]; then
+ IFS=';' read -ra label_parts <<< "$exp_label"
+ for lp in "${label_parts[@]}"; do
+ lp="${lp## }"
+ if ! echo "$output" | grep -qF "$lp"; then
+ failures+=("$case_id/decode/$fc: missing '$lp' in output")
+ return 1
+ fi
+ done
+ fi
+ done
+
+ return 0
+}
+
+run_convert_test() {
+ local case_id="$1" uri="$2" expected_block="$3"
+
+ local block_output
+ block_output=$("$UGI" convert "$uri" 2>&1) || true
+
+ if ! echo "$block_output" | grep -qF "BEGIN UGI BLOCK"; then
+ failures+=("$case_id/convert-uri2block: missing BEGIN header")
+ return 1
+ fi
+ if ! echo "$block_output" | grep -qF "END UGI BLOCK"; then
+ failures+=("$case_id/convert-uri2block: missing END footer")
+ return 1
+ fi
+
+ local exp_handle
+ exp_handle=$(jq -r --arg id "$case_id" '.cases[] | select(.id == $id) | .decoded.handle' "$CASES")
+ if ! echo "$block_output" | grep -qF "@$exp_handle"; then
+ failures+=("$case_id/convert-uri2block: missing handle '@$exp_handle'")
+ return 1
+ fi
+
+ local uri_output
+ uri_output=$("$UGI" convert "$block_output" 2>&1) || true
+
+ if [[ "${uri_output,,}" != "ugi:"* ]]; then
+ failures+=("$case_id/convert-block2uri: output doesn't start with 'ugi:' got '$uri_output'")
+ return 1
+ fi
+
+ if ! echo "$uri_output" | grep -qF "@$exp_handle"; then
+ failures+=("$case_id/convert-block2uri: missing handle in roundtrip")
+ return 1
+ fi
+
+ return 0
+}
+
+# ── Run tests ────────────────────────────────────────────────────────────────
+
+echo "=== UGI CLI Test Suite ==="
+echo "Registry: $REGISTRY"
+echo "Cases: $CASES"
+echo "CLI: $UGI"
+echo
+
+case_ids=$(jq -r '.cases[].id' "$CASES")
+
+for cid in $case_ids; do
+ uri=$(jq -r --arg id "$cid" '.cases[] | select(.id == $id) | .uri // empty' "$CASES")
+ has_decoded=$(jq -r --arg id "$cid" '.cases[] | select(.id == $id) | .decoded // empty | if . then "yes" else "no" end' "$CASES")
+
+ if [[ -z "$uri" ]]; then
+ printf " SKIP %-35s (no single URI)\n" "$cid"
+ skip=$((skip + 1))
+ continue
+ fi
+
+ if [[ "$has_decoded" == "no" || -z "$has_decoded" ]]; then
+ printf " SKIP %-35s (no decoded expectations)\n" "$cid"
+ skip=$((skip + 1))
+ continue
+ fi
+
+ if run_decode_test "$cid" "$uri"; then
+ printf " PASS %-35s decode\n" "$cid"
+ pass=$((pass + 1))
+ else
+ printf " FAIL %-35s decode\n" "$cid"
+ fail=$((fail + 1))
+ fi
+
+ block=$(jq -r --arg id "$cid" '.cases[] | select(.id == $id) | .block // empty' "$CASES")
+ if [[ -n "$block" ]]; then
+ if run_convert_test "$cid" "$uri" "$block"; then
+ printf " PASS %-35s convert roundtrip\n" "$cid"
+ pass=$((pass + 1))
+ else
+ printf " FAIL %-35s convert roundtrip\n" "$cid"
+ fail=$((fail + 1))
+ fi
+ fi
+done
+
+echo
+echo "=== Results: $pass passed, $fail failed, $skip skipped ==="
+
+if [[ ${#failures[@]} -gt 0 ]]; then
+ echo
+ echo "Failures:"
+ for f in "${failures[@]}"; do
+ echo " - $f"
+ done
+ exit 1
+fi
+
+exit 0
diff --git a/cli/ugi b/cli/ugi
new file mode 100755
index 0000000..16fca01
--- /dev/null
+++ b/cli/ugi
@@ -0,0 +1,660 @@
+#!/usr/bin/env bash
+# ugi — encode, decode, and convert UGI strings
+set -euo pipefail
+
+VERSION="0.1.0"
+
+# ── Registry path ────────────────────────────────────────────────────────────
+
+REGISTRY="${UGI_REGISTRY:-}"
+if [[ -z "$REGISTRY" ]]; then
+ # Try common locations relative to script
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ for candidate in \
+ "$SCRIPT_DIR/ugi_registry_v0.json" \
+ "$SCRIPT_DIR/../ugi-spec/ugi_registry_v0.json" \
+ "$SCRIPT_DIR/ugi-spec/ugi_registry_v0.json"; do
+ if [[ -f "$candidate" ]]; then
+ REGISTRY="$candidate"
+ break
+ fi
+ done
+fi
+
+require_registry() {
+ if [[ -z "$REGISTRY" || ! -f "$REGISTRY" ]]; then
+ echo "error: registry not found. set UGI_REGISTRY or place ugi_registry_v0.json nearby." >&2
+ exit 1
+ fi
+}
+
+# ── Helpers ──────────────────────────────────────────────────────────────────
+
+jq_check() {
+ if ! command -v jq &>/dev/null; then
+ echo "error: jq is required. install it: https://jqlang.github.io/jq/" >&2
+ exit 1
+ fi
+}
+
+# ── Decode ───────────────────────────────────────────────────────────────────
+
+decode_uri() {
+ local uri="$1"
+ require_registry
+ jq_check
+
+ # Strip ugi: prefix, split header and fields
+ local rest="${uri#ugi:}"
+ local version="${rest%%@*}"
+ rest="${rest#*@}"
+ local handle="${rest%%:*}"
+ local fields_str="${rest#*:}"
+
+ printf "Version: %s\nHandle: @%s\n\n" "$version" "$handle"
+ printf "%-25s %-8s %s\n" "Field" "Code" "Decoded"
+ printf "%s\n" "----------------------------------------------------------------------"
+
+ IFS=',' read -ra field_parts <<< "$fields_str"
+ for part in "${field_parts[@]}"; do
+ [[ -z "$part" ]] && continue
+ local code="${part:0:1}"
+ local rest_field="${part:1}"
+ decode_single_field "$code" "$rest_field" "$part"
+ done
+}
+
+decode_single_field() {
+ local code="$1" rest="$2" raw="$3"
+ local name type
+
+ name=$(jq -r --arg c "$code" '.fields[$c].name // "Unknown"' "$REGISTRY")
+ type=$(jq -r --arg c "$code" '.fields[$c].type // "unknown"' "$REGISTRY")
+
+ case "$type" in
+ direct)
+ decode_direct "$code" "$rest" "$name"
+ ;;
+ special)
+ decode_special "$code" "$rest" "$name"
+ ;;
+ multi)
+ decode_multi "$code" "$rest" "$name"
+ ;;
+ single)
+ decode_multi "$code" "$rest" "$name"
+ ;;
+ *)
+ printf "%-25s %-8s %s\n" "$name" "$code" "$rest"
+ ;;
+ esac
+}
+
+decode_direct() {
+ local code="$1" rest="$2" name="$3"
+ [[ -z "$rest" ]] && return
+
+ local digit="${rest:0:1}"
+ local mods="${rest:1}"
+ local label
+
+ # Try custom values first, then scale_labels, then global scale
+ label=$(jq -r --arg c "$code" --arg d "$digit" '
+ .fields[$c].values[$d].label //
+ .fields[$c].scale_labels[$d] //
+ empty
+ ' "$REGISTRY" 2>/dev/null || true)
+
+ if [[ -z "$label" ]]; then
+ local scale_type
+ scale_type=$(jq -r --arg c "$code" '.fields[$c].scale_type // ""' "$REGISTRY")
+ if [[ -n "$scale_type" && "$scale_type" != "custom" ]]; then
+ label=$(jq -r --arg d "$digit" --arg st "$scale_type" '
+ .scale.values[] | select(.value == ($d | tonumber)) | .[$st] // empty
+ ' "$REGISTRY" 2>/dev/null || true)
+ fi
+ fi
+ [[ -z "$label" ]] && label="$digit"
+
+ # Parse modifiers
+ local mod_str=""
+ if [[ "$mods" == *"/"* ]]; then
+ local alt_digit="${mods#*/}"
+ alt_digit="${alt_digit:0:1}"
+ local alt_label
+ alt_label=$(jq -r --arg c "$code" --arg d "$alt_digit" '
+ .fields[$c].values[$d].label // $d
+ ' "$REGISTRY" 2>/dev/null || true)
+ label="$label / $alt_label"
+ mods="${mods//\/$alt_digit/}"
+ fi
+ [[ "$mods" == *'$'* ]] && mod_str+=" [\$]"
+ if [[ "$mods" == *"+"* ]]; then
+ local asp="${mods#*+}"
+ asp="${asp:0:1}"
+ mod_str+=" [+$asp]"
+ fi
+
+ printf "%-25s %-8s %s%s\n" "$name" "${code}${digit}" "$label" "$mod_str"
+}
+
+decode_special() {
+ local code="$1" rest="$2" name="$3"
+
+ case "$code" in
+ g)
+ # Geek type: domain/domain/$
+ local decoded=""
+ IFS='/' read -ra domains <<< "$rest"
+ for d in "${domains[@]}"; do
+ local paid=""
+ local dname="$d"
+ [[ "$d" == *'$' ]] && { paid=" (\$)"; dname="${d%\$}"; }
+
+ local resolved
+ if [[ "$dname" == "~"* ]]; then
+ resolved="$dname"
+ else
+ resolved=$(jq -r --arg sid "$dname" '
+ [.fields.g.sub_id_groups[].sub_ids[$sid] // empty] | first // $sid
+ ' "$REGISTRY" 2>/dev/null || echo "$dname")
+ # Handle string vs object
+ if [[ "$resolved" == "{"* ]]; then
+ resolved=$(echo "$resolved" | jq -r '.name // empty' 2>/dev/null || echo "$dname")
+ fi
+ fi
+ [[ -n "$decoded" ]] && decoded+=", "
+ decoded+="${resolved}${paid}"
+ done
+ printf "%-25s %-8s %s\n" "$name" "$code" "$decoded"
+ ;;
+ b|v)
+ IFS='/' read -ra dims <<< "$rest"
+ local dim_names dim_labels=""
+ dim_names=$(jq -r --arg c "$code" '.fields[$c].dimensions | keys_unsorted[]' "$REGISTRY")
+ local i=0
+ while IFS= read -r dim_name; do
+ local val="${dims[$i]:-}"
+ if [[ -n "$val" ]]; then
+ local dim_label
+ dim_label=$(jq -r --arg c "$code" --arg dn "$dim_name" --arg v "$val" '
+ .fields[$c].dimensions[$dn].values[$v] // $v
+ ' "$REGISTRY")
+ [[ -n "$dim_labels" ]] && dim_labels+="; "
+ dim_labels+="${dim_name}: ${dim_label}"
+ fi
+ i=$((i + 1)) || true
+ done <<< "$dim_names"
+ printf "%-25s %-8s %s\n" "$name" "${code}${rest}" "$dim_labels"
+ ;;
+ l)
+ # Languages: 2-char code + digit + mods, repeating
+ decode_multi_inner "$code" "$rest" "$name" 2
+ ;;
+ esac
+}
+
+decode_multi() {
+ local code="$1" rest="$2" name="$3"
+ decode_multi_inner "$code" "$rest" "$name" 0
+}
+
+decode_multi_inner() {
+ local code="$1" rest="$2" name="$3" fixed_len="$4"
+ local entries=""
+ local i=0 len=${#rest}
+
+ while (( i < len )); do
+ local ch="${rest:$i:1}"
+ local sub_id=""
+
+ if [[ "$ch" == "~" ]]; then
+ # Custom sub-ID
+ local j=$((i + 1))
+ while (( j < len )) && [[ "${rest:$j:1}" =~ [a-zA-Z] ]]; do
+ j=$((j + 1))
+ done
+ sub_id="${rest:$i:$((j - i))}"
+ i=$j
+ elif [[ "$ch" =~ [a-zA-Z] ]]; then
+ if (( fixed_len > 0 )); then
+ sub_id="${rest:$i:$fixed_len}"
+ i=$((i + fixed_len))
+ else
+ local j=$((i))
+ while (( j < len )) && [[ "${rest:$j:1}" =~ [a-zA-Z] ]]; do
+ j=$((j + 1))
+ done
+ sub_id="${rest:$i:$((j - i))}"
+ i=$j
+ fi
+ else
+ i=$((i + 1))
+ continue
+ fi
+
+ # Resolve name
+ local resolved
+ if [[ "$sub_id" == "~"* ]]; then
+ resolved="$sub_id"
+ else
+ resolved=$(jq -r --arg c "$code" --arg sid "$sub_id" '
+ ((.fields[$c].sub_ids[$sid] // null) as $flat |
+ if $flat then
+ if ($flat | type) == "object" then $flat.name else $flat end
+ else null end) //
+ (if .fields[$c].sub_id_groups then
+ [.fields[$c].sub_id_groups[].sub_ids[$sid] // empty] | first |
+ if type == "object" then .name else . end
+ else null end) //
+ (.fields[$c].common_codes[$sid] // null) //
+ $sid
+ ' "$REGISTRY" 2>/dev/null || echo "$sub_id")
+ fi
+
+ # Read digit
+ local digit=""
+ if (( i < len )) && [[ "${rest:$i:1}" =~ [0-7] ]]; then
+ digit="${rest:$i:1}"
+ i=$((i + 1))
+ fi
+
+ # Read modifiers
+ local mod_str=""
+ if (( i < len )) && [[ "${rest:$i:1}" == '$' ]]; then
+ mod_str+=" [\$]"
+ i=$((i + 1))
+ fi
+ if (( i < len )) && [[ "${rest:$i:1}" == "+" ]]; then
+ i=$((i + 1))
+ if (( i < len )) && [[ "${rest:$i:1}" =~ [0-7] ]]; then
+ mod_str+=" [+${rest:$i:1}]"
+ i=$((i + 1))
+ fi
+ fi
+
+ # Get label for digit
+ local label=""
+ if [[ -n "$digit" ]]; then
+ label=$(jq -r --arg c "$code" --arg d "$digit" '
+ .fields[$c].values[$d].label //
+ .fields[$c].scale_labels[$d] //
+ empty
+ ' "$REGISTRY" 2>/dev/null || true)
+ if [[ -z "$label" ]]; then
+ local st
+ st=$(jq -r --arg c "$code" '.fields[$c].scale_type // ""' "$REGISTRY")
+ if [[ -n "$st" && "$st" != "custom" ]]; then
+ label=$(jq -r --arg d "$digit" --arg st "$st" '
+ .scale.values[] | select(.value == ($d | tonumber)) | .[$st] // empty
+ ' "$REGISTRY" 2>/dev/null || true)
+ fi
+ fi
+ [[ -z "$label" ]] && label="$digit"
+ fi
+
+ [[ -n "$entries" ]] && entries+=", "
+ if [[ -n "$digit" ]]; then
+ entries+="${resolved}=${label}${mod_str}"
+ else
+ entries+="${resolved}${mod_str}"
+ fi
+ done
+
+ printf "%-25s %-8s %s\n" "$name" "$code" "$entries"
+}
+
+# ── Convert ──────────────────────────────────────────────────────────────────
+
+convert_uri_to_block() {
+ local uri="$1"
+ require_registry
+ jq_check
+
+ local rest="${uri#ugi:}"
+ local version="${rest%%@*}"
+ rest="${rest#*@}"
+ local handle="${rest%%:*}"
+ local fields_str="${rest#*:}"
+
+ echo "------- BEGIN UGI BLOCK -------"
+
+ local first_line="v:${version} @${handle}"
+ local -a remaining=()
+
+ IFS=',' read -ra parts <<< "$fields_str"
+ for part in "${parts[@]}"; do
+ [[ -z "$part" ]] && continue
+ local code="${part:0:1}"
+ if [[ "$code" == "g" ]]; then
+ first_line+=" G${part:1}"
+ else
+ remaining+=("$part")
+ fi
+ done
+ echo "$first_line"
+
+ # Group by category
+ local -A cat_fields
+ local -a cat_order=(identity appearance tech stance entertainment lifestyle)
+ for cat in "${cat_order[@]}"; do
+ cat_fields[$cat]=""
+ done
+
+ for part in "${remaining[@]}"; do
+ local code="${part:0:1}"
+ local cat
+ cat=$(jq -r --arg c "$code" '.fields[$c].category // "tech"' "$REGISTRY")
+ local ftype
+ ftype=$(jq -r --arg c "$code" '.fields[$c].type // "direct"' "$REGISTRY")
+ local uc="${code^^}"
+
+ if [[ "$ftype" == "multi" || "$ftype" == "single" ]]; then
+ # Expand merged entries
+ local expanded
+ expanded=$(expand_multi_block "$uc" "${part:1}" "$code")
+ cat_fields[$cat]+="${cat_fields[$cat]:+ }${expanded}"
+ else
+ cat_fields[$cat]+="${cat_fields[$cat]:+ }${uc}${part:1}"
+ fi
+ done
+
+ for cat in "${cat_order[@]}"; do
+ [[ -n "${cat_fields[$cat]:-}" ]] && echo "${cat_fields[$cat]}"
+ done
+
+ echo "-------- END UGI BLOCK --------"
+}
+
+expand_multi_block() {
+ local uc="$1" rest="$2" code="$3"
+ local result="" i=0 len=${#rest}
+ local fixed_len=0
+ [[ "$code" == "l" ]] && fixed_len=2
+
+ while (( i < len )); do
+ local ch="${rest:$i:1}"
+ local sub_id="" tail=""
+
+ if [[ "$ch" == "~" ]]; then
+ local j=$((i + 1))
+ while (( j < len )) && [[ "${rest:$j:1}" =~ [a-zA-Z] ]]; do j=$((j + 1)); done
+ sub_id="${rest:$i:$((j - i))}"
+ i=$j
+ elif [[ "$ch" =~ [a-zA-Z] ]]; then
+ if (( fixed_len > 0 )); then
+ sub_id="${rest:$i:$fixed_len}"
+ i=$((i + fixed_len))
+ else
+ local j=$i
+ while (( j < len )) && [[ "${rest:$j:1}" =~ [a-zA-Z] ]]; do j=$((j + 1)); done
+ sub_id="${rest:$i:$((j - i))}"
+ i=$j
+ fi
+ else
+ i=$((i + 1)); continue
+ fi
+
+ # digit + modifiers
+ while (( i < len )) && [[ "${rest:$i:1}" =~ [0-9\$+] ]]; do
+ tail+="${rest:$i:1}"
+ i=$((i + 1))
+ done
+
+ result+="${result:+ }${uc}${sub_id}${tail}"
+ done
+
+ echo "$result"
+}
+
+convert_block_to_uri() {
+ local block="$1"
+ require_registry
+ jq_check
+
+ local version="" handle=""
+ local -a field_parts=()
+
+ while IFS= read -r line; do
+ line="${line#"${line%%[![:space:]]*}"}" # trim leading whitespace
+ [[ "$line" == "---"*"BEGIN"* ]] && continue
+ [[ "$line" == "---"*"END"* ]] && continue
+ [[ -z "$line" ]] && continue
+
+ for token in $line; do
+ if [[ "$token" == "v:"* ]]; then
+ version="${token#v:}"
+ elif [[ "$token" == "@"* ]]; then
+ handle="${token#@}"
+ elif [[ "${token:0:1}" =~ [A-Za-z] ]]; then
+ field_parts+=("$token")
+ fi
+ done
+ done <<< "$block"
+
+ # Merge repeated field codes
+ local -A merged
+ local -a order=()
+ for part in "${field_parts[@]}"; do
+ local code="${part:0:1}"
+ code="${code,,}" # lowercase
+ if [[ -z "${merged[$code]+x}" ]]; then
+ merged[$code]="${part:1}"
+ order+=("$code")
+ else
+ merged[$code]+="${part:1}"
+ fi
+ done
+
+ local fields_str=""
+ for code in "${order[@]}"; do
+ [[ -n "$fields_str" ]] && fields_str+=","
+ fields_str+="${code}${merged[$code]}"
+ done
+
+ echo "ugi:${version}@${handle}:${fields_str}"
+}
+
+# ── Fields ───────────────────────────────────────────────────────────────────
+
+list_fields() {
+ require_registry
+ jq_check
+
+ printf "%-6s %-25s %-15s %s\n" "Code" "Field" "Type" "Category"
+ printf "%s\n" "------------------------------------------------------------"
+
+ jq -r '
+ .fields | to_entries | sort_by(.key)[] |
+ "\(.key)\t\(.value.name)\t\(.value.type)\t\(.value.category)"
+ ' "$REGISTRY" | while IFS=$'\t' read -r code fname ftype fcat; do
+ printf "%-6s %-25s %-15s %s\n" "$code" "$fname" "$ftype" "$fcat"
+ done
+
+ # Reserved codes
+ jq -r '.format.reserved_codes[]' "$REGISTRY" 2>/dev/null | sort | while read -r code; do
+ printf "%-6s %-25s %-15s %s\n" "$code" "(reserved)" "—" "—"
+ done
+}
+
+# ── Encode (interactive) ────────────────────────────────────────────────────
+
+encode_interactive() {
+ require_registry
+ jq_check
+
+ echo "=== UGI Encoder ==="
+ echo
+
+ # Handle
+ read -rp "Handle (username): " handle
+ [[ -z "$handle" ]] && { echo "Handle is required." >&2; exit 1; }
+
+ local -a parts=()
+
+ # Geek type (mandatory)
+ echo
+ echo "--- Geek Specialization (g) --- [mandatory]"
+ echo "Enter domain codes separated by /. Use ~ for custom, \$ after domain for paid."
+ echo "Example: cs\$/ai/wr~fermenting"
+ echo
+ echo "Available domains:"
+ jq -r '
+ .fields.g.sub_id_groups | to_entries[] |
+ " \(.value.label): " + ([.value.sub_ids | to_entries[:6][] | "\(.key)=\(if (.value|type) == "object" then .value.name else .value end)"] | join(", ")) + "..."
+ ' "$REGISTRY"
+ echo
+ read -rp "Geek domains: " geek
+ [[ -z "$geek" ]] && { echo "Geek type is required." >&2; exit 1; }
+ parts+=("g${geek}")
+
+ # Other fields
+ local field_codes
+ field_codes=$(jq -r '.fields | keys[] | select(. != "g")' "$REGISTRY" | sort)
+
+ for code in $field_codes; do
+ local name type
+ name=$(jq -r --arg c "$code" '.fields[$c].name' "$REGISTRY")
+ type=$(jq -r --arg c "$code" '.fields[$c].type' "$REGISTRY")
+
+ echo
+ echo "--- ${name} (${code}) --- [s=skip]"
+
+ case "$type" in
+ direct)
+ # Show scale
+ jq -r --arg c "$code" '
+ .fields[$c].values // {} | to_entries | sort_by(.key)[] |
+ " \(.key): \(.value.label // .value)"
+ ' "$REGISTRY" 2>/dev/null || true
+
+ read -rp "Value (0-7, s=skip): " val
+ [[ "$val" == "s" || -z "$val" ]] && continue
+ [[ "$val" =~ ^[0-7]$ ]] || { echo "Invalid, skipping."; continue; }
+ parts+=("${code}${val}")
+ ;;
+ special)
+ case "$code" in
+ b)
+ echo " Height and width, each 0-7"
+ read -rp " Height (0-7, s=skip): " h
+ [[ "$h" == "s" || -z "$h" ]] && continue
+ read -rp " Width (0-7): " w
+ [[ -z "$w" ]] && continue
+ parts+=("b${h}/${w}")
+ ;;
+ v)
+ echo " Social: 0=far-right auth → 7=far-left lib"
+ echo " Economic: 0=unreg capitalism → 7=full socialism"
+ read -rp " Social (0-7, s=skip): " s
+ [[ "$s" == "s" || -z "$s" ]] && continue
+ read -rp " Economic (0-7): " e
+ [[ -z "$e" ]] && continue
+ parts+=("v${s}/${e}")
+ ;;
+ l)
+ echo " Enter ISO 639-1 codes with ratings, no spaces."
+ echo " Example: en7ar6de3"
+ read -rp " Languages (s=skip): " langs
+ [[ "$langs" == "s" || -z "$langs" ]] && continue
+ parts+=("l${langs}")
+ ;;
+ esac
+ ;;
+ multi|single)
+ jq -r --arg c "$code" '
+ ((.fields[$c].sub_ids // {}) | to_entries[:8][] |
+ "\(.key)=\(if (.value|type) == "object" then .value.name else .value end)"),
+ ((.fields[$c].sub_id_groups // {}) | to_entries[] | .value.sub_ids | to_entries[:4][] |
+ "\(.key)=\(if (.value|type) == "object" then .value.name else .value end)")
+ ' "$REGISTRY" 2>/dev/null | head -12 | sed 's/^/ /'
+ echo " ..."
+
+ if [[ "$type" == "single" ]]; then
+ echo " Enter one sub-ID + rating. Example: is6"
+ else
+ echo " Enter sub-ID+rating pairs. Example: py6rs5js4"
+ fi
+ read -rp " ${name} (s=skip): " val
+ [[ "$val" == "s" || -z "$val" ]] && continue
+ parts+=("${code}${val}")
+ ;;
+ esac
+ done
+
+ local uri
+ uri="ugi:0@${handle}:$(IFS=,; echo "${parts[*]}")"
+
+ echo
+ echo "=== Result ==="
+ echo
+ echo "$uri"
+ echo
+ convert_uri_to_block "$uri"
+}
+
+# ── Main ─────────────────────────────────────────────────────────────────────
+
+usage() {
+ cat <<'EOF'
+ugi — UGI (Unified Geek Identifier) CLI tool
+
+Usage:
+ ugi decode <ugi-string> Decode a UGI string (URI or block)
+ ugi convert <ugi-string> Convert between URI and block format
+ ugi encode Interactive encoder
+ ugi fields List all field codes
+
+ Aliases: d=decode, c=convert, e=encode, f=fields
+
+Environment:
+ UGI_REGISTRY Path to ugi_registry_v0.json
+
+Options:
+ -h, --help Show this help
+ -v, --version Show version
+
+Reads from stdin if no string argument given (decode/convert).
+EOF
+}
+
+main() {
+ [[ $# -eq 0 ]] && { usage; exit 0; }
+
+ case "$1" in
+ -h|--help) usage; exit 0 ;;
+ -v|--version) echo "ugi $VERSION"; exit 0 ;;
+ decode|d)
+ shift
+ local input="${1:-}"
+ [[ -z "$input" ]] && input="$(cat)"
+ if [[ "${input,,}" == "ugi:"* ]]; then
+ decode_uri "$input"
+ elif [[ "$input" == *"BEGIN UGI"* || "$input" == *"v:"* ]]; then
+ # Parse block to URI first, then decode
+ local uri
+ uri=$(convert_block_to_uri "$input")
+ decode_uri "$uri"
+ else
+ echo "error: unrecognized format" >&2; exit 1
+ fi
+ ;;
+ convert|c)
+ shift
+ local input="${1:-}"
+ [[ -z "$input" ]] && input="$(cat)"
+ if [[ "${input,,}" == "ugi:"* ]]; then
+ convert_uri_to_block "$input"
+ elif [[ "$input" == *"BEGIN UGI"* || "$input" == *"v:"* ]]; then
+ convert_block_to_uri "$input"
+ else
+ echo "error: unrecognized format" >&2; exit 1
+ fi
+ ;;
+ encode|e) encode_interactive ;;
+ fields|f) list_fields ;;
+ *) echo "error: unknown command '$1'" >&2; usage; exit 1 ;;
+ esac
+}
+
+main "$@"