#!/bin/sh # Ganivra CLI: connects Claude Code and OpenAI Codex to Ganivra and labels # each project's usage with its team, project and client. # # ganivra setup [--key KEY] [--team TEAM] [--tools claude,codex] [--yes] # ganivra label [--project NAME] [--client NAME] [--team TEAM] [--local] [--path DIR] # ganivra status # ganivra uninstall # # It only edits ~/.claude/settings.json, ~/.codex/config.toml, a project's # .claude/settings(.local).json, ~/.ganivra and one line in your shell profile. # Prompts and code are never read. Docs: https://ganivra.com/integration#quick-setup set -eu GANIVRA_VERSION=1 API_URL=${GANIVRA_API_URL:-https://api.ganivra.com} GV_HOME=${GANIVRA_HOME:-$HOME/.ganivra} CLAUDE_USER=$HOME/.claude/settings.json CODEX_CONFIG=${CODEX_HOME:-$HOME/.codex}/config.toml PROJECT_KEYS="project project.name ganivra.project ganivra.workflow" CLIENT_KEYS="client client.name ganivra.client ganivra.customer_id" say() { printf '%s\n' "$*"; } ok() { printf ' \342\234\223 %s\n' "$*"; } warn() { printf ' ! %s\n' "$*" >&2; } die() { printf 'ganivra: %s\n' "$*" >&2; exit 1; } has() { command -v "$1" >/dev/null 2>&1; } tty_ok() { [ -r /dev/tty ] && [ -w /dev/tty ] && (: /dev/null; } ask() { # ask VAR "Question" [default] _a="" if tty_ok; then printf '%s%s: ' "$2" "${3:+ [$3]}" >/dev/tty IFS= read -r _a /dev/tty stty -echo /dev/null || true IFS= read -r _a /dev/null || true printf '\n' >/dev/tty fi eval "$1=\$_a" } confirm() { # confirm "Question" -> 0 for yes [ "${ASSUME_YES:-0}" = 1 ] && return 0 tty_ok || return 1 printf '%s [y/N]: ' "$1" >/dev/tty IFS= read -r _c label printf %s "$1" | tr ' ,' '--' | tr -cd 'A-Za-z0-9._/-' } redact() { # gv_live_abcd...wxyz -> gv_live_…wxyz case "$1" in "") printf 'not set' ;; *) printf 'gv_live_\342\200\246%s' "$(printf %s "$1" | tail -c 4)" ;; esac } # ---------- JSON settings (node or python3; the key is passed on stdin, never argv) JSON_TOOL="" pick_json_tool() { [ -n "$JSON_TOOL" ] && return 0 if has node; then JSON_TOOL=node; return 0; fi if has python3; then # On a Mac without developer tools, /usr/bin/python3 only opens an installer. if [ "$(uname -s)" = Darwin ] && [ "$(command -v python3)" = /usr/bin/python3 ] && ! xcode-select -p >/dev/null 2>&1; then : else JSON_TOOL=python3; return 0; fi fi die "needs node or python3 to edit JSON settings. Install one, or follow https://ganivra.com/integration#claude-code to edit ~/.claude/settings.json by hand." } # json_env MODE FILE: MODE is get (key on stdin -> value), set (KEY=VALUE lines on stdin) or unset (KEY lines on stdin). json_env() { pick_json_tool if [ "$JSON_TOOL" = node ]; then node -e ' const fs = require("fs"); const [mode, file] = process.argv.slice(1); const lines = fs.readFileSync(0, "utf8").split("\n").filter(Boolean); let data = {}; if (fs.existsSync(file) && fs.readFileSync(file, "utf8").trim()) { try { data = JSON.parse(fs.readFileSync(file, "utf8")); } catch { process.stderr.write(file + " is not valid JSON; fix it and run again\n"); process.exit(3); } if (!data || typeof data !== "object" || Array.isArray(data)) process.exit(3); } const env = data.env && typeof data.env === "object" ? data.env : {}; if (mode === "get") { const v = env[lines[0]]; if (typeof v === "string") process.stdout.write(v); process.exit(0); } for (const line of lines) { if (mode === "set") { const i = line.indexOf("="); env[line.slice(0, i)] = line.slice(i + 1); } else delete env[line]; } if (Object.keys(env).length) data.env = env; else delete data.env; const tmp = file + ".ganivra-tmp"; fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n"); fs.renameSync(tmp, file); ' "$1" "$2" else python3 -c ' import json, os, sys mode, path = sys.argv[1], sys.argv[2] lines = [l for l in sys.stdin.read().split("\n") if l] data = {} if os.path.exists(path) and open(path).read().strip(): try: data = json.load(open(path)) except ValueError: sys.stderr.write(path + " is not valid JSON; fix it and run again\n"); sys.exit(3) if not isinstance(data, dict): sys.exit(3) env = data.get("env") if isinstance(data.get("env"), dict) else {} if mode == "get": v = env.get(lines[0]) if lines else None if isinstance(v, str): sys.stdout.write(v) sys.exit(0) for line in lines: if mode == "set": k, _, v = line.partition("="); env[k] = v else: env.pop(line, None) if env: data["env"] = env else: data.pop("env", None) tmp = path + ".ganivra-tmp" with open(tmp, "w") as f: f.write(json.dumps(data, indent=2) + "\n") os.replace(tmp, path) ' "$1" "$2" fi } env_get() { printf '%s\n' "$2" | json_env get "$1"; } # ---------- OTEL_RESOURCE_ATTRIBUTES values attr_get() { # attr_get "a=1,b=2" KEY printf %s "$1" | tr ',' '\n' | sed -n "s/^$(printf %s "$2" | sed 's/\./\\./g')=//p" | head -n 1 } attr_drop() { # attr_drop "a=1,b=2" KEY... -> value without those keys _v=$1; shift for _k in "$@"; do _v=$(printf %s "$_v" | tr ',' '\n' | grep -v "^$(printf %s "$_k" | sed 's/\./\\./g')=" | paste -sd, - || true) done printf %s "$_v" } first_attr() { # first_attr VALUE KEYS... _v=$1; shift for _k in "$@"; do _r=$(attr_get "$_v" "$_k"); [ -n "$_r" ] && { printf %s "$_r"; return 0; } done return 0 } config_team() { [ -f "$GV_HOME/config" ] && sed -n 's/^team=//p' "$GV_HOME/config" | head -n 1 || true; } project_root() { git -C "${1:-.}" rev-parse --show-toplevel 2>/dev/null || (cd "${1:-.}" && pwd); } # Labels a tool started in DIR would send: the project's files first, else the folder name. labels_for() { _root=$(project_root "${1:-.}") for _f in "$_root/.claude/settings.local.json" "$_root/.claude/settings.json"; do [ -f "$_f" ] || continue _l=$(sed -n 's/.*"OTEL_RESOURCE_ATTRIBUTES"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$_f" | head -n 1) [ -n "$_l" ] && { printf '%s\t%s\n' "$_l" "$_f"; return 0; } done _t=$(config_team) printf '%s\tfolder name\n' "${_t:+team.id=$_t,}ganivra.workflow=$(clean_label "${_root##*/}")" } # ---------- shell integration (zsh and bash) write_shell_file() { mkdir -p "$GV_HOME/bin" cat >"$GV_HOME/shell.sh" <<'EOF' # Added by Ganivra (https://ganivra.com/integration#quick-setup). Remove with: ganivra uninstall case ":$PATH:" in *":$HOME/.ganivra/bin:"*) ;; *) PATH="$HOME/.ganivra/bin:$PATH" ;; esac # Codex reads no project files, so give each codex run the labels from the # project's .claude/settings.local.json or .claude/settings.json (the files # Claude Code reads), else name the project after its folder. ganivra_labels() { local root file labels name team root=$(git rev-parse --show-toplevel 2>/dev/null || pwd) for file in "$root/.claude/settings.local.json" "$root/.claude/settings.json"; do [ -f "$file" ] || continue labels=$(sed -n 's/.*"OTEL_RESOURCE_ATTRIBUTES"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$file" | head -n 1) if [ -n "$labels" ]; then printf %s "$labels"; return; fi done case ",$OTEL_RESOURCE_ATTRIBUTES," in *,project=*|*,ganivra.workflow=*) printf %s "$OTEL_RESOURCE_ATTRIBUTES"; return ;; esac team=$(sed -n 's/^team=//p' "$HOME/.ganivra/config" 2>/dev/null | head -n 1) name=${root##*/}; name=${name//[ ,]/-} case ",$OTEL_RESOURCE_ATTRIBUTES," in *,team.id=*|*,team=*|*,cost_center=*) team="" ;; esac printf %s "${OTEL_RESOURCE_ATTRIBUTES:+$OTEL_RESOURCE_ATTRIBUTES,}${team:+team.id=$team,}ganivra.workflow=$name" } codex() { OTEL_RESOURCE_ATTRIBUTES="$(ganivra_labels)" command codex "$@"; } EOF } RC_LINE='[ -f "$HOME/.ganivra/shell.sh" ] && . "$HOME/.ganivra/shell.sh" # ganivra' add_rc() { _sh=${SHELL:-}; _sh=${_sh##*/} for _rc in "$HOME/.zshrc" "$HOME/.bashrc"; do case "$_rc" in */.bashrc) [ -f "$_rc" ] || [ "$_sh" = bash ] || continue ;; */.zshrc) [ -f "$_rc" ] || [ "$_sh" = zsh ] || continue ;; esac if [ -f "$_rc" ] && grep -q '\.ganivra/shell\.sh' "$_rc"; then ok "${_rc##*/} already loads Ganivra"; continue; fi printf '\n%s\n' "$RC_LINE" >>"$_rc" ok "${_rc##*/} loads ~/.ganivra/shell.sh (the codex labels and the ganivra command)" done } remove_rc() { for _rc in "$HOME/.zshrc" "$HOME/.bashrc"; do [ -f "$_rc" ] && grep -q '\.ganivra/shell\.sh' "$_rc" || continue grep -v '\.ganivra/shell\.sh' "$_rc" >"$_rc.ganivra-tmp" && cat "$_rc.ganivra-tmp" >"$_rc" && rm -f "$_rc.ganivra-tmp" ok "removed the Ganivra line from ${_rc##*/}" done } # ---------- Codex config.toml strip_otel() { # prints FILE without its [otel] and [otel.*] tables awk ' /^[[:space:]]*\[/ { h = $0; gsub(/[[:space:]]/, "", h); skip = (h ~ /^\[otel(\]|\.)/) } !skip && !/^# Ganivra: cost tracking/ { print } ' "$1" } codex_key() { [ -f "$CODEX_CONFIG" ] && sed -n 's/.*Authorization[[:space:]]*=[[:space:]]*"Bearer \([^"]*\)".*/\1/p' "$CODEX_CONFIG" | head -n 1 || true; } configure_codex() { mkdir -p "$(dirname "$CODEX_CONFIG")" _ep=""; _bak="" if [ -f "$CODEX_CONFIG" ]; then _ep=$(sed -n 's/^[[:space:]]*endpoint[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "$CODEX_CONFIG" | head -n 1) case "$_ep" in ""|*ganivra*) ;; *) confirm "Codex already exports telemetry to $_ep. Send it to Ganivra instead?" || { warn "left ~/.codex/config.toml unchanged"; return 0; } ;; esac [ -f "$CODEX_CONFIG.bak-ganivra" ] || cp "$CODEX_CONFIG" "$CODEX_CONFIG.bak-ganivra"; _bak=" (backup: config.toml.bak-ganivra)" strip_otel "$CODEX_CONFIG" | awk 'NF { blank = 0 } !NF { if (blank++) next } { print }' >"$CODEX_CONFIG.ganivra-tmp" else : >"$CODEX_CONFIG.ganivra-tmp" fi [ -s "$CODEX_CONFIG.ganivra-tmp" ] && [ -n "$(tail -c 1 "$CODEX_CONFIG.ganivra-tmp")" ] && printf '\n' >>"$CODEX_CONFIG.ganivra-tmp" [ -s "$CODEX_CONFIG.ganivra-tmp" ] && printf '\n' >>"$CODEX_CONFIG.ganivra-tmp" cat >>"$CODEX_CONFIG.ganivra-tmp" <"$_agent" < Labelcom.ganivra.env ProgramArguments/bin/launchctlsetenvOTEL_RESOURCE_ATTRIBUTESteam.id=$TEAM RunAtLoad EOF launchctl setenv OTEL_RESOURCE_ATTRIBUTES "team.id=$TEAM" 2>/dev/null || true ok "Desktop app: team $TEAM, also after restarts (quit and reopen ${1##*/} to apply)" } # ---------- commands verify_key() { has curl || { warn "curl not found; skipped checking the key"; return 0; } _code=$(printf '%s\n' "header = \"Authorization: Bearer $KEY\"" | curl -sS -o /dev/null -w '%{http_code}' -K - \ -X POST -H 'Content-Type: application/json' --data '{"resourceLogs":[]}' --max-time 15 "$API_URL/v1/otlp/v1/logs" 2>/dev/null || true) case "$_code" in 2*) ok "Key accepted by $API_URL" ;; 401|403) die "Ganivra rejected this key. Copy a workspace key from https://ganivra.com/settings and run again." ;; *) warn "Couldn't reach $API_URL to check the key (HTTP ${_code:-none}); continuing" ;; esac } cmd_setup() { KEY=${GANIVRA_API_KEY:-}; TEAM=""; TOOLS=""; ASSUME_YES=0; VERIFY=1; SHELL_HOOK=1; DESKTOP=1 while [ $# -gt 0 ]; do case "$1" in --key) KEY=${2:-}; shift ;; --team) TEAM=${2:-}; shift ;; --tools) TOOLS=${2:-}; shift ;; --yes|-y) ASSUME_YES=1 ;; --no-verify) VERIFY=0 ;; --no-shell) SHELL_HOOK=0 ;; --no-desktop) DESKTOP=0 ;; -h|--help) usage; exit 0 ;; *) die "unknown option $1 (try ganivra setup --help)" ;; esac shift done say "Ganivra setup: Claude Code and OpenAI Codex cost tracking" if [ -z "$TOOLS" ]; then has claude || [ -d "$HOME/.claude" ] && TOOLS="claude" { has codex || [ -d "${CODEX_HOME:-$HOME/.codex}" ] || desktop_app >/dev/null; } && TOOLS="${TOOLS:+$TOOLS,}codex" [ -n "$TOOLS" ] || TOOLS="claude,codex" fi say " Tools: $(printf %s "$TOOLS" | sed 's/claude/Claude Code/; s/codex/OpenAI Codex/; s/,/, /')" [ -n "$KEY" ] || KEY=$(codex_key) [ -n "$KEY" ] || KEY=$(env_get "$CLAUDE_USER" OTEL_EXPORTER_OTLP_HEADERS | sed -n 's/^Authorization=Bearer //p') case "$KEY" in gv_live_*) say " Using the workspace key $(redact "$KEY") (pass --key to change it)" ;; *) [ -n "$KEY" ] && die "that doesn't look like a Ganivra key (they start with gv_live_)" say " Create a workspace key at https://ganivra.com/settings, then paste it here." ask_secret KEY " Workspace API key" ;; esac case "$KEY" in gv_live_*) ;; "") die "no API key given. Pass --key or set GANIVRA_API_KEY." ;; *) die "that doesn't look like a Ganivra key (they start with gv_live_)" ;; esac case "$KEY" in *[!A-Za-z0-9_-]*) die "the key has unexpected characters; copy it again" ;; esac [ -n "$TEAM" ] || TEAM=$(config_team) [ -n "$TEAM" ] || ask TEAM " Your team, used for projects you haven't labelled (optional, e.g. platform)" "" [ -z "$TEAM" ] || valid_label "$TEAM" || die "team must use letters, digits, - _ . or / only" if [ "$VERIFY" = 1 ]; then verify_key; fi mkdir -p "$GV_HOME" { say "version=$GANIVRA_VERSION"; if [ -n "$TEAM" ]; then say "team=$TEAM"; fi; } >"$GV_HOME/config" case ",$TOOLS," in *,claude,*) configure_claude ;; esac case ",$TOOLS," in *,codex,*) configure_codex ;; esac if [ "$SHELL_HOOK" = 1 ]; then write_shell_file; install_self; add_rc; fi if [ "$DESKTOP" = 1 ] && [ -n "$TEAM" ] && _app=$(desktop_app); then configure_desktop_team "$_app"; fi say "" say "Done. Next:" say " 1. Open a new terminal, or run: . ~/.ganivra/shell.sh" say " 2. In each project, label it once: ganivra label --project my-project --client my-client" say " It writes .claude/settings.json; commit it so your whole team sends the same labels." say " 3. Use Claude Code or codex as usual, then open Ganivra -> Dashboard -> Coding agents." say " Restart open Claude Code sessions and desktop apps first. Check anytime with: ganivra status" } install_self() { # When run from a file (not piped), keep a copy so the ganivra command works later. if [ -f "$0" ] && [ "$(basename "$0")" != sh ] && [ "$0" != "$GV_HOME/bin/ganivra" ]; then cp "$0" "$GV_HOME/bin/ganivra" && chmod 755 "$GV_HOME/bin/ganivra" fi [ -x "$GV_HOME/bin/ganivra" ] || warn "the ganivra command isn't installed; rerun: curl -fsSL https://ganivra.com/install.sh | sh" } cmd_label() { PROJECT=""; CLIENT=""; TEAM=""; LOCAL=0; DIR=.; CLEAR_CLIENT=0 while [ $# -gt 0 ]; do case "$1" in --project|-p) PROJECT=${2:-}; shift ;; --client|-c) CLIENT=${2:-}; shift ;; --no-client) CLEAR_CLIENT=1 ;; --team|-t) TEAM=${2:-}; shift ;; --local) LOCAL=1 ;; --path) DIR=${2:-.}; shift ;; -h|--help) usage; exit 0 ;; *) die "unknown option $1 (try ganivra label --help)" ;; esac shift done [ -d "$DIR" ] || die "$DIR is not a folder" ROOT=$(project_root "$DIR") [ "$ROOT" = "$HOME" ] && die "run this inside a project folder, not your home folder" FILE=$ROOT/.claude/settings.json; [ "$LOCAL" = 1 ] && FILE=$ROOT/.claude/settings.local.json mkdir -p "$ROOT/.claude" _cur=$(env_get "$FILE" OTEL_RESOURCE_ATTRIBUTES) [ -n "$PROJECT" ] || PROJECT=$(first_attr "$_cur" ganivra.workflow project ganivra.project project.name) [ -n "$PROJECT" ] || PROJECT=$(clean_label "${ROOT##*/}") [ -n "$CLIENT" ] || [ "$CLEAR_CLIENT" = 1 ] || CLIENT=$(first_attr "$_cur" ganivra.customer_id client ganivra.client client.name) [ -n "$TEAM" ] || TEAM=$(first_attr "$_cur" team.id team team.name) [ -n "$TEAM" ] || TEAM=$(config_team) for _v in "$PROJECT" ${CLIENT:+"$CLIENT"} ${TEAM:+"$TEAM"}; do valid_label "$_v" || die "\"$_v\": use letters, digits, - _ . or / only (no spaces or commas)" done # shellcheck disable=SC2086 _rest=$(attr_drop "$_cur" team.id team team.name $PROJECT_KEYS $CLIENT_KEYS) _val="${TEAM:+team.id=$TEAM,}ganivra.workflow=$PROJECT${CLIENT:+,ganivra.customer_id=$CLIENT}${_rest:+,$_rest}" say "OTEL_RESOURCE_ATTRIBUTES=$_val" | json_env set "$FILE" ok "${FILE#"$ROOT"/}: project $PROJECT${CLIENT:+, client $CLIENT}${TEAM:+, team $TEAM}" if [ "$LOCAL" = 1 ]; then git -C "$ROOT" check-ignore -q "$FILE" 2>/dev/null || warn "add .claude/settings.local.json to .gitignore to keep it to yourself" elif git -C "$ROOT" rev-parse >/dev/null 2>&1; then say " Commit it so everyone on this repository sends the same labels: git add .claude/settings.json" fi say " Claude Code applies it to new sessions here (accept the folder trust prompt if asked); codex to new runs." [ -n "$(env_get "$ROOT/.claude/settings.local.json" OTEL_RESOURCE_ATTRIBUTES)" ] && [ "$LOCAL" = 0 ] && warn ".claude/settings.local.json also sets labels and wins over this file" return 0 } cmd_status() { say "Ganivra status" _ck=$(env_get "$CLAUDE_USER" OTEL_EXPORTER_OTLP_HEADERS | sed -n 's/^Authorization=Bearer //p') _ce=$(env_get "$CLAUDE_USER" OTEL_EXPORTER_OTLP_ENDPOINT) case "$_ce" in *ganivra*|"$API_URL"*) ok "Claude Code exports to $_ce with key $(redact "$_ck")" ;; "") warn "Claude Code isn't connected (run: ganivra setup)" ;; *) warn "Claude Code exports to $_ce, not Ganivra" ;; esac _xk=$(codex_key) if [ -n "$_xk" ]; then ok "Codex exports with key $(redact "$_xk")"; else warn "Codex isn't connected (run: ganivra setup)"; fi _t=$(config_team); say " Default team: ${_t:-none}" if [ -f "$GV_HOME/shell.sh" ]; then ok "Shell: codex runs get each project's labels"; else warn "Shell: codex runs get no project labels (run: ganivra setup)"; fi _tab=$(printf '\t'); _line=$(labels_for .); _l=${_line%%"$_tab"*}; _src=${_line#*"$_tab"}; _src=${_src#"$(project_root .)"/} say " This folder: project $(first_attr "$_l" ganivra.workflow project ganivra.project project.name)$(_c=$(first_attr "$_l" ganivra.customer_id client ganivra.client client.name); [ -n "$_c" ] && printf ', client %s' "$_c")$(_t=$(first_attr "$_l" team.id team team.name cost_center); [ -n "$_t" ] && printf ', team %s' "$_t") (from $_src)" [ "$_src" = "folder name" ] && say " Label it with: ganivra label --project NAME --client NAME" return 0 } cmd_uninstall() { ASSUME_YES=0; [ "${1:-}" = --yes ] && ASSUME_YES=1 confirm "Stop sending Claude Code and Codex usage to Ganivra from this machine?" || { say "Nothing changed."; return 0; } case "$(env_get "$CLAUDE_USER" OTEL_EXPORTER_OTLP_ENDPOINT)" in *ganivra*|"$API_URL"*) printf '%s\n' CLAUDE_CODE_ENABLE_TELEMETRY OTEL_LOGS_EXPORTER OTEL_METRICS_EXPORTER OTEL_EXPORTER_OTLP_PROTOCOL \ OTEL_EXPORTER_OTLP_ENDPOINT OTEL_EXPORTER_OTLP_HEADERS OTEL_LOGS_EXPORT_INTERVAL OTEL_METRICS_INCLUDE_REPOSITORY | json_env unset "$CLAUDE_USER" ok "Claude Code: removed the export from ~/.claude/settings.json" ;; esac if [ -f "$CODEX_CONFIG" ] && grep -q 'ganivra' "$CODEX_CONFIG"; then strip_otel "$CODEX_CONFIG" >"$CODEX_CONFIG.ganivra-tmp" mv "$CODEX_CONFIG.ganivra-tmp" "$CODEX_CONFIG" ok "Codex: removed the [otel] export from ~/.codex/config.toml" fi remove_rc if [ -f "$HOME/Library/LaunchAgents/com.ganivra.env.plist" ]; then rm -f "$HOME/Library/LaunchAgents/com.ganivra.env.plist"; launchctl unsetenv OTEL_RESOURCE_ATTRIBUTES 2>/dev/null || true ok "Desktop app: removed the team label" fi rm -rf "$GV_HOME" say "Done. Project .claude/settings.json labels are left in place; they do nothing without the export." } usage() { cat <<'EOF' Usage: ganivra setup [--key KEY] [--team TEAM] [--tools claude,codex] [--yes] Connect Claude Code and Codex on this machine. Finds installed tools, keeps an existing key unless you pass --key, and backs up files it changes. ganivra label [--project NAME] [--client NAME] [--team TEAM] [--local] [--path DIR] Label the current project: writes .claude/settings.json (or settings.local.json with --local) at the git root. Claude Code and codex both read it. The project defaults to the folder name. --no-client removes a client. ganivra status Show what is connected and this folder's labels. ganivra uninstall Remove the export, the shell line and ~/.ganivra. EOF } case "${1:-}" in setup) shift; cmd_setup "$@" ;; label) shift; cmd_label "$@" ;; status) shift; cmd_status "$@" ;; uninstall) shift; cmd_uninstall "$@" ;; -h|--help|help|"") usage ;; --version|version) say "ganivra $GANIVRA_VERSION" ;; *) usage >&2; exit 1 ;; esac