#!/usr/bin/env bash
# sthes — terminal thesaurus lookup
# Inspired by BreadOnPenguins/scripts/define
# Backends: Oxford Dictionaries API (set OD_APP_ID + OD_APP_KEY)
# sdcv with thesaurus StarDict dict (offline, set STHES_USE_SDCV=1)
# dictionaryapi.dev synonyms (free fallback)
set -euo pipefail
BOLD='\033[1m'
DIM='\033[2m'
BLUE='\033[34m'
CYAN='\033[36m'
GREEN='\033[32m'
YELLOW='\033[33m'
MAGENTA='\033[35m'
RESET='\033[0m'
usage() {
echo -e "${BOLD}sthes${RESET} — look up synonyms and antonyms"
echo ""
echo -e " ${BOLD}Usage:${RESET} sthes <word>"
echo ""
echo -e " ${BOLD}Backends (checked in order):${RESET}"
echo " 1. Oxford Dictionaries API — set OD_APP_ID and OD_APP_KEY"
echo " 2. sdcv (offline thesaurus) — set STHES_USE_SDCV=1"
echo " 3. dictionaryapi.dev — free, no config needed"
exit 0
}
die() { echo -e "\033[31merror:\033[0m $1" >&2; exit 1; }
[[ $# -lt 1 || "$1" == "-h" || "$1" == "--help" ]] && usage
word="$1"
[[ "$word" =~ [^a-zA-Z-] ]] && die "invalid word: $word"
# helper: wrap a comma-separated list at terminal width
wrap_list() {
local color="$1"
shift
local words=("$@")
local line=" "
local term_width
term_width=$(tput cols 2>/dev/null || echo 80)
for w in "${words[@]}"; do
if (( ${#line} + ${#w} + 2 > term_width )); then
echo -e "${color}${line}${RESET}"
line=" ${w}, "
else
line+="${w}, "
fi
done
# trim trailing comma+space
line="${line%, }"
[[ -n "$line" ]] && echo -e "${color}${line}${RESET}"
}
# --- Backend 1: Oxford Dictionaries API (Thesaurus endpoint) ---
oxford_lookup() {
local resp
resp=$(curl -sf --connect-timeout 5 --max-time 10 \
-H "app_id: $OD_APP_ID" \
-H "app_key: $OD_APP_KEY" \
"https://od-api-v2.oxforddictionaries.com/api/v2/thesaurus/en/${word,,}") || return 1
echo -e "${BOLD}${BLUE}${word}${RESET}"
echo ""
# Extract synonyms and antonyms by part of speech
echo "$resp" | jq -r '
.results[].lexicalEntries[] |
.lexicalCategory.id as $pos |
.entries[].senses[] |
{
pos: $pos,
syns: [.synonyms[]?.text] | join("|"),
ants: [.antonyms[]?.text] | join("|")
} |
"\(.pos)~\(.syns)~\(.ants)"
' 2>/dev/null | while IFS='~' read -r pos syns ants; do
echo -e " ${GREEN}${pos}${RESET}"
if [[ -n "$syns" ]]; then
echo -e " ${BOLD}synonyms:${RESET}"
IFS='|' read -ra syn_arr <<< "$syns"
wrap_list "$CYAN" "${syn_arr[@]}"
fi
if [[ -n "$ants" ]]; then
echo -e " ${BOLD}antonyms:${RESET}"
IFS='|' read -ra ant_arr <<< "$ants"
wrap_list "$MAGENTA" "${ant_arr[@]}"
fi
echo ""
done
}
# --- Backend 2: sdcv (offline thesaurus) ---
sdcv_lookup() {
command -v sdcv >/dev/null 2>&1 || return 1
# Try Collins Thesaurus or Moby Thesaurus if available
local result
result=$(sdcv -n -u "Collins Thesaurus" "$word" 2>/dev/null) \
|| result=$(sdcv -n -u "Moby Thesaurus" "$word" 2>/dev/null) \
|| result=$(sdcv -n "$word" 2>/dev/null) \
|| return 1
[[ "$result" == *"Nothing similar"* ]] && return 1
echo -e "${BOLD}${BLUE}${word}${RESET}"
echo ""
echo "$result" | sed 's/^/ /'
echo ""
}
# --- Backend 3: Free Dictionary API (fallback — synonyms from meanings) ---
free_lookup() {
local resp
resp=$(curl -sf --connect-timeout 5 --max-time 10 \
"https://api.dictionaryapi.dev/api/v2/entries/en_US/${word}") || return 1
[[ "$resp" == *"No Definitions Found"* ]] && return 1
echo -e "${BOLD}${BLUE}${word}${RESET}"
echo ""
local found_any=0
echo "$resp" | jq -r '
.[].meanings[] |
.partOfSpeech as $pos |
{
pos: $pos,
def: (.definitions[0].definition // ""),
syns: ([.synonyms[]?] + [.definitions[].synonyms[]?] | unique | join("|")),
ants: ([.antonyms[]?] + [.definitions[].antonyms[]?] | unique | join("|"))
} |
"\(.pos)~\(.def)~\(.syns)~\(.ants)"
' 2>/dev/null | while IFS='~' read -r pos def syns ants; do
[[ -z "$syns" && -z "$ants" ]] && continue
echo -e " ${GREEN}${pos}${RESET} ${DIM}${def}${RESET}"
if [[ -n "$syns" ]]; then
echo -e " ${BOLD}synonyms:${RESET}"
IFS='|' read -ra syn_arr <<< "$syns"
wrap_list "$CYAN" "${syn_arr[@]}"
found_any=1
fi
if [[ -n "$ants" ]]; then
echo -e " ${BOLD}antonyms:${RESET}"
IFS='|' read -ra ant_arr <<< "$ants"
wrap_list "$MAGENTA" "${ant_arr[@]}"
found_any=1
fi
echo ""
done
# If free API had no synonyms, try datamuse as last resort
if [[ "$found_any" == "0" ]]; then
local dm
dm=$(curl -sf --connect-timeout 5 --max-time 10 \
"https://api.datamuse.com/words?rel_syn=${word}&max=20") || return 1
local dm_words
dm_words=$(echo "$dm" | jq -r '.[].word' 2>/dev/null)
[[ -z "$dm_words" ]] && return 1
echo -e " ${BOLD}synonyms:${RESET}"
local arr=()
while IFS= read -r w; do arr+=("$w"); done <<< "$dm_words"
wrap_list "$CYAN" "${arr[@]}"
echo ""
# also get antonyms from datamuse
local dm_ant
dm_ant=$(curl -sf --connect-timeout 5 --max-time 10 \
"https://api.datamuse.com/words?rel_ant=${word}&max=20") || true
local ant_words
ant_words=$(echo "$dm_ant" | jq -r '.[].word' 2>/dev/null)
if [[ -n "$ant_words" ]]; then
echo -e " ${BOLD}antonyms:${RESET}"
local ant_arr=()
while IFS= read -r w; do ant_arr+=("$w"); done <<< "$ant_words"
wrap_list "$MAGENTA" "${ant_arr[@]}"
echo ""
fi
fi
echo -e " ${DIM}source: dictionaryapi.dev / datamuse${RESET}"
}
# --- Dispatch ---
if [[ -n "${OD_APP_ID:-}" && -n "${OD_APP_KEY:-}" ]]; then
oxford_lookup && exit 0
echo -e "${DIM}Oxford API failed, trying fallback...${RESET}" >&2
fi
if [[ "${STHES_USE_SDCV:-0}" == "1" ]]; then
sdcv_lookup && exit 0
echo -e "${DIM}sdcv failed, trying fallback...${RESET}" >&2
fi
free_lookup || die "no results found for '${word}'"