From 41f8d3025f041aaab98fcc3511cb6ec2198f76a7 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Sun, 14 Jun 2026 01:49:48 +0300 Subject: init: vibed --- cli/LICENSE | 21 ++ cli/README.md | 118 +++++++++++ cli/test.sh | 200 ++++++++++++++++++ cli/ugi | 660 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 999 insertions(+) create mode 100644 cli/LICENSE create mode 100644 cli/README.md create mode 100755 cli/test.sh create mode 100755 cli/ugi (limited to 'cli') 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 " >&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 Decode a UGI string (URI or block) + ugi convert 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 "$@" -- cgit v1.2.3