From 37c512b6d626e065c314a33e050a5b6f4ee42dba Mon Sep 17 00:00:00 2001 From: Lance Paine Date: Tue, 24 Mar 2026 16:18:45 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20name-chkr=20plugin=20=E2=80=94=20?= =?UTF-8?q?check=20name=20availability=20across=20registries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checks domains (.com/.io/.dev/.org/.app/.sh), GitHub repos (search, top 10 by stars), npm (search with details), PyPI (exact match), crates.io, and Homebrew (formula + cask). Takes multiple names, runs all checks in parallel. Co-Authored-By: Claude Opus 4.6 (1M context) --- plugins/spai-name-chkr | 226 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100755 plugins/spai-name-chkr diff --git a/plugins/spai-name-chkr b/plugins/spai-name-chkr new file mode 100755 index 0000000..03eafd4 --- /dev/null +++ b/plugins/spai-name-chkr @@ -0,0 +1,226 @@ +#!/usr/bin/env bb +;; spai plugin: name-chkr +;; +;; Checks name availability across domains, GitHub, Homebrew, npm, PyPI, crates.io. +;; Takes multiple names as args, checks them all in parallel. +;; +;; Usage: spai name-chkr [name2] [name3] ... + +{:spai/args " [name2] [name3] ..." + :spai/returns "EDN map of availability per name across registries" + :spai/example "spai name-chkr spoqe spindle hedl"} + +(require '[clojure.string :as str] + '[clojure.pprint :as pp] + '[babashka.process :as p] + '[babashka.http-client :as http] + '[cheshire.core :as json]) + +(def args *command-line-args*) + +(when (or (empty? args) (contains? #{"--help" "-h"} (first args))) + (println "Usage: spai name-chkr [name2] [name3] ...") + (println) + (println "Checks availability across:") + (println " domains — .com .io .dev .org .app .sh") + (println " github — org/user name") + (println " homebrew — formula/cask") + (println " npm — package") + (println " pypi — package") + (println " crates.io — crate") + (println) + (println "Examples:") + (println " spai name-chkr spoqe") + (println " spai name-chkr hedl spindle gearn") + (System/exit 0)) + +(def domain-tlds ["com" "io" "dev" "org" "app" "sh"]) + +(defn check-http + "GET url, return status code. Returns :error on exception." + [url & {:keys [timeout] :or {timeout 8000}}] + (try + (let [resp (http/get url {:throw false + :timeout timeout + :headers {"User-Agent" "spai-name-chkr/1.0"}})] + (:status resp)) + (catch Exception e + (let [msg (str e)] + (cond + (str/includes? msg "UnknownHost") :no-dns + (str/includes? msg "ConnectTimeout") :timeout + (str/includes? msg "Timeout") :timeout + (str/includes? msg "Connection refused") :refused + :else :error))))) + +(defn check-domain + "Check if a domain resolves via DNS (dig)." + [domain] + (let [{:keys [exit out]} (p/shell {:out :string :err :string :continue true} + "dig" "+short" "+time=3" "+tries=1" domain "A")] + (if (and (zero? exit) (not (str/blank? out))) + :taken + ;; Also check AAAA + (let [{:keys [exit out]} (p/shell {:out :string :err :string :continue true} + "dig" "+short" "+time=3" "+tries=1" domain "AAAA")] + (if (and (zero? exit) (not (str/blank? out))) + :taken + :available))))) + +(defn check-github + "Search GitHub repos matching name, return top 10 by stars." + [name] + (try + (let [resp (http/get "https://api.github.com/search/repositories" + {:query-params {"q" name "sort" "stars" "order" "desc" "per_page" "10"} + :throw false + :timeout 8000 + :headers {"User-Agent" "spai-name-chkr/1.0" + "Accept" "application/vnd.github.v3+json"}}) + body (when (= 200 (:status resp)) + (cheshire.core/parse-string (:body resp) true))] + (if body + {:total (:total_count body) + :hits (mapv (fn [r] + {:repo (:full_name r) + :stars (:stargazers_count r) + :forks (:forks_count r) + :description (some-> (:description r) (subs 0 (min 80 (count (:description r))))) + :url (:html_url r)}) + (:items body))} + {:total 0 :hits [] :note (str "status:" (:status resp))})) + (catch Exception e + {:total 0 :hits [] :error (str e)}))) + +(defn check-npm + "Search npm for packages matching name, return top hits." + [name] + (try + (let [resp (http/get "https://registry.npmjs.org/-/v1/search" + {:query-params {"text" name "size" "10"} + :throw false + :timeout 8000 + :headers {"User-Agent" "spai-name-chkr/1.0"}}) + body (when (= 200 (:status resp)) + (json/parse-string (:body resp) true))] + (if body + (let [objects (:objects body)] + {:total (get-in body [:total]) + :exact (some #(= name (get-in % [:package :name])) objects) + :hits (mapv (fn [o] + (let [pkg (:package o)] + {:name (:name pkg) + :version (:version pkg) + :description (some-> (:description pkg) (subs 0 (min 80 (count (:description pkg))))) + :weekly-dl (get-in o [:score :detail :popularity])})) + objects)}) + {:total 0 :hits []})) + (catch Exception e + {:total 0 :hits [] :error (str e)}))) + +(defn check-pypi + "Search PyPI for packages matching name, return top hits." + [name] + (try + (let [;; Check exact match first + exact-resp (http/get (str "https://pypi.org/pypi/" name "/json") + {:throw false :timeout 8000 + :headers {"User-Agent" "spai-name-chkr/1.0"}}) + exact (when (= 200 (:status exact-resp)) + (let [body (json/parse-string (:body exact-resp) true) + info (:info body)] + {:name (:name info) + :version (:version info) + :description (some-> (:summary info) (subs 0 (min 80 (count (:summary info))))) + :author (:author info)})) + ;; Search for similar + search-resp (http/get "https://pypi.org/search/" + {:query-params {"q" name} + :throw false :timeout 8000 + :headers {"User-Agent" "spai-name-chkr/1.0" + "Accept" "application/json"}}) + ;; PyPI search doesn't have a JSON API, use simple API list isn't useful + ;; Just use xmlrpc or report exact match + note + ] + (if exact + {:exact true :hit exact} + {:exact false})) + (catch Exception e + {:exact false :error (str e)}))) + +(defn check-crates + "Check crates.io crate existence." + [name] + (let [status (check-http (str "https://crates.io/api/v1/crates/" name))] + (case status + 200 :taken + 404 :available + :unknown))) + +(defn check-homebrew + "Check Homebrew formula/cask existence." + [name] + (let [formula-status (check-http (str "https://formulae.brew.sh/api/formula/" name ".json")) + cask-status (when (not= 200 formula-status) + (check-http (str "https://formulae.brew.sh/api/cask/" name ".json")))] + (cond + (= 200 formula-status) :taken-formula + (= 200 cask-status) :taken-cask + :else :available))) + +(defn check-name + "Check a single name across all registries. Returns EDN map." + [name] + (binding [*out* *err*] + (println (str " checking: " name "..."))) + (let [lower (str/lower-case name) + ;; Fire all checks in parallel + domains (into {} (map (fn [tld] + [(keyword (str tld)) + (future (check-domain (str lower "." tld)))]) + domain-tlds)) + gh-f (future (check-github lower)) + npm-f (future (check-npm lower)) + pypi-f (future (check-pypi lower)) + crate-f (future (check-crates lower)) + brew-f (future (check-homebrew lower))] + + {:domains (into {} (map (fn [[k v]] [k @v]) domains)) + :github @gh-f + :npm @npm-f + :pypi @pypi-f + :crates-io @crate-f + :homebrew @brew-f})) + +(defn registry-taken? [v] + (cond + (keyword? v) (not (#{:available :unknown :error :timeout} v)) + (map? v) (or (:exact v) (pos? (get v :total 0))) + :else false)) + +(defn summarize + "Add a quick summary line." + [results] + (let [domain-vals (vals (:domains results)) + npm-taken (registry-taken? (:npm results)) + pypi-taken (registry-taken? (:pypi results)) + crate-taken (registry-taken? (:crates-io results)) + brew-taken (registry-taken? (:homebrew results))] + (assoc results :summary + {:domains-available (count (filter #{:available} domain-vals)) + :domains-taken (count (filter #{:taken} domain-vals)) + :npm (if npm-taken :taken :available) + :pypi (if pypi-taken :taken :available) + :crates-io (if crate-taken :taken :available) + :homebrew (if brew-taken :taken :available) + :github-repos (get-in results [:github :total] 0)}))) + +;; Main +(binding [*out* *err*] + (println (str "name-chkr: checking " (count args) " name(s)..."))) + +(let [results (into {} + (map (fn [name] + [(keyword name) (summarize (check-name name))]) + args))] + (pp/pprint results))