#!/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 " 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}'"