@kortix/sandbox 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/config/customize.sh +143 -0
  2. package/config/kortix-env-setup.sh +25 -0
  3. package/kortix-master/package.json +22 -0
  4. package/kortix-master/src/config.ts +22 -0
  5. package/kortix-master/src/index.ts +44 -0
  6. package/kortix-master/src/routes/env.ts +65 -0
  7. package/kortix-master/src/routes/proxy.ts +108 -0
  8. package/kortix-master/src/routes/update.ts +185 -0
  9. package/kortix-master/src/services/proxy.ts +43 -0
  10. package/kortix-master/src/services/secret-store.ts +156 -0
  11. package/kortix-master/tsconfig.json +14 -0
  12. package/opencode/agents/kortix-browser.md +142 -0
  13. package/opencode/agents/kortix-build.md +62 -0
  14. package/opencode/agents/kortix-explore.md +66 -0
  15. package/opencode/agents/kortix-image-gen.md +33 -0
  16. package/opencode/agents/kortix-main.md +450 -0
  17. package/opencode/agents/kortix-plan.md +100 -0
  18. package/opencode/agents/kortix-research.md +84 -0
  19. package/opencode/agents/kortix-sheets.md +61 -0
  20. package/opencode/agents/kortix-slides.md +64 -0
  21. package/opencode/agents/kortix-web-dev.md +572 -0
  22. package/opencode/commands/email.md +36 -0
  23. package/opencode/commands/init.md +43 -0
  24. package/opencode/commands/journal.md +44 -0
  25. package/opencode/commands/memory-init.md +81 -0
  26. package/opencode/commands/memory-search.md +50 -0
  27. package/opencode/commands/memory-status.md +56 -0
  28. package/opencode/commands/research.md +36 -0
  29. package/opencode/commands/search.md +38 -0
  30. package/opencode/commands/slides.md +32 -0
  31. package/opencode/commands/spreadsheet.md +30 -0
  32. package/opencode/memory.json +37 -0
  33. package/opencode/ocx.jsonc +10 -0
  34. package/opencode/opencode.jsonc +103 -0
  35. package/opencode/package.json +25 -0
  36. package/opencode/patches/apply.sh +19 -0
  37. package/opencode/patches/opencode-pty-spawn.txt +49 -0
  38. package/opencode/plugin/background-agents.ts.disabled +483 -0
  39. package/opencode/plugin/kdco-primitives/get-project-id.ts +172 -0
  40. package/opencode/plugin/kdco-primitives/index.ts +26 -0
  41. package/opencode/plugin/kdco-primitives/log-warn.ts +51 -0
  42. package/opencode/plugin/kdco-primitives/mutex.ts +122 -0
  43. package/opencode/plugin/kdco-primitives/shell.ts +138 -0
  44. package/opencode/plugin/kdco-primitives/temp.ts +36 -0
  45. package/opencode/plugin/kdco-primitives/terminal-detect.ts +34 -0
  46. package/opencode/plugin/kdco-primitives/types.ts +13 -0
  47. package/opencode/plugin/kdco-primitives/with-timeout.ts +84 -0
  48. package/opencode/plugin/memory.ts +306 -0
  49. package/opencode/plugin/worktree/state.ts +412 -0
  50. package/opencode/plugin/worktree/terminal.ts +1002 -0
  51. package/opencode/plugin/worktree.ts +861 -0
  52. package/opencode/skills/KORTIX-browser/SKILL.md +478 -0
  53. package/opencode/skills/KORTIX-cron-triggers/SKILL.md +173 -0
  54. package/opencode/skills/KORTIX-deep-research/SKILL.md +278 -0
  55. package/opencode/skills/KORTIX-docx/SKILL.md +398 -0
  56. package/opencode/skills/KORTIX-docx/scripts/__init__.py +1 -0
  57. package/opencode/skills/KORTIX-docx/scripts/accept_changes.py +104 -0
  58. package/opencode/skills/KORTIX-docx/scripts/comment.py +244 -0
  59. package/opencode/skills/KORTIX-docx/scripts/office/helpers/__init__.py +0 -0
  60. package/opencode/skills/KORTIX-docx/scripts/office/helpers/merge_runs.py +199 -0
  61. package/opencode/skills/KORTIX-docx/scripts/office/helpers/simplify_redlines.py +197 -0
  62. package/opencode/skills/KORTIX-docx/scripts/office/pack.py +159 -0
  63. package/opencode/skills/KORTIX-docx/scripts/office/soffice.py +183 -0
  64. package/opencode/skills/KORTIX-docx/scripts/office/unpack.py +132 -0
  65. package/opencode/skills/KORTIX-docx/scripts/office/validate.py +111 -0
  66. package/opencode/skills/KORTIX-docx/scripts/office/validators/__init__.py +15 -0
  67. package/opencode/skills/KORTIX-docx/scripts/office/validators/base.py +847 -0
  68. package/opencode/skills/KORTIX-docx/scripts/office/validators/docx.py +446 -0
  69. package/opencode/skills/KORTIX-docx/scripts/office/validators/pptx.py +275 -0
  70. package/opencode/skills/KORTIX-docx/scripts/office/validators/redlining.py +247 -0
  71. package/opencode/skills/KORTIX-docx/scripts/render_docx.py +179 -0
  72. package/opencode/skills/KORTIX-docx/scripts/templates/comments.xml +3 -0
  73. package/opencode/skills/KORTIX-docx/scripts/templates/commentsExtended.xml +3 -0
  74. package/opencode/skills/KORTIX-docx/scripts/templates/commentsExtensible.xml +3 -0
  75. package/opencode/skills/KORTIX-docx/scripts/templates/commentsIds.xml +3 -0
  76. package/opencode/skills/KORTIX-docx/scripts/templates/people.xml +3 -0
  77. package/opencode/skills/KORTIX-domain-research/SKILL.md +96 -0
  78. package/opencode/skills/KORTIX-domain-research/scripts/domain-lookup.py +810 -0
  79. package/opencode/skills/KORTIX-elevenlabs/SKILL.md +230 -0
  80. package/opencode/skills/KORTIX-elevenlabs/scripts/tts.py +389 -0
  81. package/opencode/skills/KORTIX-email/SKILL.md +145 -0
  82. package/opencode/skills/KORTIX-legal-writer/SKILL.md +409 -0
  83. package/opencode/skills/KORTIX-legal-writer/references/bluebook.md +152 -0
  84. package/opencode/skills/KORTIX-legal-writer/references/document-types.md +416 -0
  85. package/opencode/skills/KORTIX-legal-writer/scripts/courtlistener.py +291 -0
  86. package/opencode/skills/KORTIX-legal-writer/scripts/ecfr_lookup.py +299 -0
  87. package/opencode/skills/KORTIX-legal-writer/scripts/verify-legal.py +507 -0
  88. package/opencode/skills/KORTIX-logo-creator/SKILL.md +293 -0
  89. package/opencode/skills/KORTIX-logo-creator/references/prompt-patterns.md +134 -0
  90. package/opencode/skills/KORTIX-logo-creator/scripts/compose_logo.py +406 -0
  91. package/opencode/skills/KORTIX-logo-creator/scripts/create_logo_sheet.py +258 -0
  92. package/opencode/skills/KORTIX-logo-creator/scripts/remove_bg.py +96 -0
  93. package/opencode/skills/KORTIX-memory/SKILL.md +261 -0
  94. package/opencode/skills/KORTIX-memory/scripts/export-sessions.py +409 -0
  95. package/opencode/skills/KORTIX-paper-creator/SKILL.md +549 -0
  96. package/opencode/skills/KORTIX-paper-creator/assets/template.tex +101 -0
  97. package/opencode/skills/KORTIX-paper-creator/scripts/compile.sh +177 -0
  98. package/opencode/skills/KORTIX-paper-creator/scripts/openalex_to_bibtex.py +220 -0
  99. package/opencode/skills/KORTIX-paper-creator/scripts/verify.sh +354 -0
  100. package/opencode/skills/KORTIX-paper-search/SKILL.md +418 -0
  101. package/opencode/skills/KORTIX-pdf/SKILL.md +232 -0
  102. package/opencode/skills/KORTIX-pdf/forms.md +36 -0
  103. package/opencode/skills/KORTIX-pdf/reference.md +105 -0
  104. package/opencode/skills/KORTIX-pdf/scripts/check_bounding_boxes.py +65 -0
  105. package/opencode/skills/KORTIX-pdf/scripts/check_fillable_fields.py +11 -0
  106. package/opencode/skills/KORTIX-pdf/scripts/convert_pdf_to_images.py +33 -0
  107. package/opencode/skills/KORTIX-pdf/scripts/create_validation_image.py +37 -0
  108. package/opencode/skills/KORTIX-pdf/scripts/extract_form_field_info.py +122 -0
  109. package/opencode/skills/KORTIX-pdf/scripts/extract_form_structure.py +115 -0
  110. package/opencode/skills/KORTIX-pdf/scripts/fill_fillable_fields.py +98 -0
  111. package/opencode/skills/KORTIX-pdf/scripts/fill_pdf_form_with_annotations.py +107 -0
  112. package/opencode/skills/KORTIX-plan/SKILL.md +228 -0
  113. package/opencode/skills/KORTIX-presentation-viewer/SKILL.md +87 -0
  114. package/opencode/skills/KORTIX-presentation-viewer/serve.ts +136 -0
  115. package/opencode/skills/KORTIX-presentation-viewer/viewer.html +559 -0
  116. package/opencode/skills/KORTIX-presentations/SKILL.md +344 -0
  117. package/opencode/skills/KORTIX-remotion/SKILL.md +56 -0
  118. package/opencode/skills/KORTIX-remotion/rules/3d.md +86 -0
  119. package/opencode/skills/KORTIX-remotion/rules/animations.md +29 -0
  120. package/opencode/skills/KORTIX-remotion/rules/assets.md +78 -0
  121. package/opencode/skills/KORTIX-remotion/rules/audio-visualization.md +198 -0
  122. package/opencode/skills/KORTIX-remotion/rules/audio.md +169 -0
  123. package/opencode/skills/KORTIX-remotion/rules/calculate-metadata.md +104 -0
  124. package/opencode/skills/KORTIX-remotion/rules/can-decode.md +75 -0
  125. package/opencode/skills/KORTIX-remotion/rules/charts.md +120 -0
  126. package/opencode/skills/KORTIX-remotion/rules/compositions.md +141 -0
  127. package/opencode/skills/KORTIX-remotion/rules/display-captions.md +184 -0
  128. package/opencode/skills/KORTIX-remotion/rules/extract-frames.md +229 -0
  129. package/opencode/skills/KORTIX-remotion/rules/ffmpeg.md +38 -0
  130. package/opencode/skills/KORTIX-remotion/rules/fonts.md +152 -0
  131. package/opencode/skills/KORTIX-remotion/rules/get-audio-duration.md +58 -0
  132. package/opencode/skills/KORTIX-remotion/rules/get-video-dimensions.md +68 -0
  133. package/opencode/skills/KORTIX-remotion/rules/get-video-duration.md +58 -0
  134. package/opencode/skills/KORTIX-remotion/rules/gifs.md +141 -0
  135. package/opencode/skills/KORTIX-remotion/rules/images.md +130 -0
  136. package/opencode/skills/KORTIX-remotion/rules/import-srt-captions.md +69 -0
  137. package/opencode/skills/KORTIX-remotion/rules/light-leaks.md +73 -0
  138. package/opencode/skills/KORTIX-remotion/rules/lottie.md +68 -0
  139. package/opencode/skills/KORTIX-remotion/rules/maps.md +401 -0
  140. package/opencode/skills/KORTIX-remotion/rules/measuring-dom-nodes.md +35 -0
  141. package/opencode/skills/KORTIX-remotion/rules/measuring-text.md +143 -0
  142. package/opencode/skills/KORTIX-remotion/rules/parameters.md +98 -0
  143. package/opencode/skills/KORTIX-remotion/rules/sequencing.md +118 -0
  144. package/opencode/skills/KORTIX-remotion/rules/subtitles.md +36 -0
  145. package/opencode/skills/KORTIX-remotion/rules/tailwind.md +11 -0
  146. package/opencode/skills/KORTIX-remotion/rules/text-animations.md +20 -0
  147. package/opencode/skills/KORTIX-remotion/rules/timing.md +179 -0
  148. package/opencode/skills/KORTIX-remotion/rules/transcribe-captions.md +70 -0
  149. package/opencode/skills/KORTIX-remotion/rules/transitions.md +197 -0
  150. package/opencode/skills/KORTIX-remotion/rules/transparent-videos.md +106 -0
  151. package/opencode/skills/KORTIX-remotion/rules/trimming.md +53 -0
  152. package/opencode/skills/KORTIX-remotion/rules/videos.md +171 -0
  153. package/opencode/skills/KORTIX-secrets/SKILL.md +280 -0
  154. package/opencode/skills/KORTIX-semantic-search/SKILL.md +213 -0
  155. package/opencode/skills/KORTIX-session-search/SKILL.md +807 -0
  156. package/opencode/skills/KORTIX-session-search/Untitled +1 -0
  157. package/opencode/skills/KORTIX-skill-creator/SKILL.md +163 -0
  158. package/opencode/skills/KORTIX-web-research/SKILL.md +69 -0
  159. package/opencode/skills/KORTIX-xlsx/LICENSE.txt +30 -0
  160. package/opencode/skills/KORTIX-xlsx/SKILL.md +549 -0
  161. package/opencode/skills/KORTIX-xlsx/scripts/office/helpers/__init__.py +0 -0
  162. package/opencode/skills/KORTIX-xlsx/scripts/office/helpers/merge_runs.py +199 -0
  163. package/opencode/skills/KORTIX-xlsx/scripts/office/helpers/simplify_redlines.py +197 -0
  164. package/opencode/skills/KORTIX-xlsx/scripts/office/pack.py +159 -0
  165. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -0
  166. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -0
  167. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -0
  168. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -0
  169. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -0
  170. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -0
  171. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -0
  172. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -0
  173. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -0
  174. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -0
  175. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -0
  176. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -0
  177. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -0
  178. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -0
  179. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -0
  180. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -0
  181. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -0
  182. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -0
  183. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -0
  184. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -0
  185. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -0
  186. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -0
  187. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -0
  188. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -0
  189. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -0
  190. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -0
  191. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -0
  192. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -0
  193. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -0
  194. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -0
  195. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -0
  196. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/mce/mc.xsd +75 -0
  197. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/microsoft/wml-2010.xsd +560 -0
  198. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/microsoft/wml-2012.xsd +67 -0
  199. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/microsoft/wml-2018.xsd +14 -0
  200. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -0
  201. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -0
  202. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -0
  203. package/opencode/skills/KORTIX-xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -0
  204. package/opencode/skills/KORTIX-xlsx/scripts/office/soffice.py +183 -0
  205. package/opencode/skills/KORTIX-xlsx/scripts/office/unpack.py +132 -0
  206. package/opencode/skills/KORTIX-xlsx/scripts/office/validate.py +111 -0
  207. package/opencode/skills/KORTIX-xlsx/scripts/office/validators/__init__.py +15 -0
  208. package/opencode/skills/KORTIX-xlsx/scripts/office/validators/base.py +847 -0
  209. package/opencode/skills/KORTIX-xlsx/scripts/office/validators/docx.py +446 -0
  210. package/opencode/skills/KORTIX-xlsx/scripts/office/validators/pptx.py +275 -0
  211. package/opencode/skills/KORTIX-xlsx/scripts/office/validators/redlining.py +247 -0
  212. package/opencode/skills/KORTIX-xlsx/scripts/recalc.py +184 -0
  213. package/opencode/tools/image-gen.ts +342 -0
  214. package/opencode/tools/image-search.ts +190 -0
  215. package/opencode/tools/memory-get.ts +168 -0
  216. package/opencode/tools/memory-search.ts +247 -0
  217. package/opencode/tools/presentation-gen.ts +723 -0
  218. package/opencode/tools/scrape-webpage.ts +115 -0
  219. package/opencode/tools/scripts/.python-version +1 -0
  220. package/opencode/tools/scripts/convert_pdf.py +184 -0
  221. package/opencode/tools/scripts/convert_pptx.py +562 -0
  222. package/opencode/tools/scripts/pyproject.toml +11 -0
  223. package/opencode/tools/scripts/uv.lock +287 -0
  224. package/opencode/tools/scripts/validate_slide.py +74 -0
  225. package/opencode/tools/show-user.ts +217 -0
  226. package/opencode/tools/tests/e2e-presentation-fix.ts +277 -0
  227. package/opencode/tools/tests/image-gen.test.ts +215 -0
  228. package/opencode/tools/tests/image-search.test.ts +125 -0
  229. package/opencode/tools/tests/memory-system-benchmark.ts +1076 -0
  230. package/opencode/tools/tests/presentation-gen.test.ts +389 -0
  231. package/opencode/tools/tests/scrape-webpage.test.ts +74 -0
  232. package/opencode/tools/tests/show-user.test.ts +241 -0
  233. package/opencode/tools/tests/video-gen.test.ts +110 -0
  234. package/opencode/tools/tests/web-search.test.ts +106 -0
  235. package/opencode/tools/video-gen.ts +200 -0
  236. package/opencode/tools/web-search.ts +153 -0
  237. package/opencode/tsconfig.json +29 -0
  238. package/package.json +36 -0
  239. package/patch-agent-browser.js +100 -0
  240. package/postinstall.sh +88 -0
  241. package/services/KORTIX-presentation-viewer/run +37 -0
  242. package/services/agent-browser-viewer/run +48 -0
  243. package/services/kortix-master/run +16 -0
  244. package/services/lss-sync/run +22 -0
  245. package/services/opencode-serve/run +25 -0
  246. package/services/opencode-web/run +21 -0
@@ -0,0 +1,810 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Domain Research CLI -- Free domain availability checking, WHOIS/RDAP lookup.
4
+ Zero credentials required. Uses RDAP (1195+ TLDs) with whois CLI fallback.
5
+
6
+ Usage:
7
+ python3 domain-lookup.py <command> [options]
8
+
9
+ Commands:
10
+ check <domain1,domain2,...> Check domain availability
11
+ search <keyword> [--tlds .com,.net] Search keyword across TLDs
12
+ whois <domain> Full WHOIS/RDAP lookup
13
+ expiry <domain> Check expiration date
14
+ nameservers <domain> Get nameservers
15
+ bulk <file> Check domains from file (one per line)
16
+ """
17
+
18
+ import sys
19
+ import json
20
+ import argparse
21
+ import subprocess
22
+ import urllib.request
23
+ import urllib.error
24
+ import re
25
+ import os
26
+ import time
27
+ from pathlib import Path
28
+ from typing import Optional, Dict, Any, List, Tuple
29
+ from concurrent.futures import ThreadPoolExecutor, as_completed
30
+
31
+ # ─── RDAP BOOTSTRAP ──────────────────────────────────────────
32
+ # Instead of querying rdap.org (which rate-limits aggressively),
33
+ # we download the IANA bootstrap file once and query registry
34
+ # RDAP servers directly. Each registry only serves its own TLDs
35
+ # so rate limits are generous.
36
+
37
+ IANA_BOOTSTRAP_URL = "https://data.iana.org/rdap/dns.json"
38
+ RDAP_FALLBACK = "https://rdap.org/domain/" # Last resort only
39
+
40
+ # Cache bootstrap data in memory (loaded once per run)
41
+ _rdap_bootstrap: Optional[Dict[str, str]] = None # tld -> rdap_base_url
42
+ _bootstrap_loaded = False
43
+
44
+ # Cache file: avoid re-downloading every single run
45
+ _CACHE_DIR = Path.home() / ".cache" / "domain-research"
46
+ _CACHE_FILE = _CACHE_DIR / "rdap-bootstrap.json"
47
+ _CACHE_MAX_AGE = 86400 * 7 # 7 days
48
+
49
+
50
+ def _load_bootstrap() -> Dict[str, str]:
51
+ """Load TLD -> RDAP URL mapping from IANA bootstrap. Cached to disk."""
52
+ global _rdap_bootstrap, _bootstrap_loaded
53
+ if _bootstrap_loaded and _rdap_bootstrap is not None:
54
+ return _rdap_bootstrap
55
+
56
+ # Try disk cache first
57
+ mapping = _load_bootstrap_cache()
58
+ if mapping:
59
+ _rdap_bootstrap = mapping
60
+ _bootstrap_loaded = True
61
+ return mapping
62
+
63
+ # Download fresh
64
+ mapping = _download_bootstrap()
65
+ if mapping:
66
+ _save_bootstrap_cache(mapping)
67
+ _rdap_bootstrap = mapping
68
+ _bootstrap_loaded = True
69
+ return mapping
70
+
71
+ # Empty fallback — will use rdap.org
72
+ _rdap_bootstrap = {}
73
+ _bootstrap_loaded = True
74
+ return _rdap_bootstrap
75
+
76
+
77
+ def _load_bootstrap_cache() -> Optional[Dict[str, str]]:
78
+ """Load cached bootstrap from disk if fresh enough."""
79
+ try:
80
+ if _CACHE_FILE.exists():
81
+ age = time.time() - _CACHE_FILE.stat().st_mtime
82
+ if age < _CACHE_MAX_AGE:
83
+ with open(_CACHE_FILE) as f:
84
+ return json.load(f)
85
+ except Exception:
86
+ pass
87
+ return None
88
+
89
+
90
+ def _save_bootstrap_cache(mapping: Dict[str, str]):
91
+ """Save bootstrap to disk cache."""
92
+ try:
93
+ _CACHE_DIR.mkdir(parents=True, exist_ok=True)
94
+ with open(_CACHE_FILE, "w") as f:
95
+ json.dump(mapping, f)
96
+ except Exception:
97
+ pass # Non-critical
98
+
99
+
100
+ def _download_bootstrap() -> Optional[Dict[str, str]]:
101
+ """Download IANA RDAP bootstrap and build TLD -> URL mapping."""
102
+ try:
103
+ req = urllib.request.Request(IANA_BOOTSTRAP_URL)
104
+ with urllib.request.urlopen(req, timeout=10) as resp:
105
+ data = json.loads(resp.read())
106
+ mapping = {}
107
+ for entry in data.get("services", []):
108
+ tlds, urls = entry
109
+ rdap_url = urls[0] if urls else None
110
+ if rdap_url:
111
+ # Ensure trailing slash
112
+ if not rdap_url.endswith("/"):
113
+ rdap_url += "/"
114
+ for tld in tlds:
115
+ mapping[tld.lower()] = rdap_url
116
+ return mapping
117
+ except Exception:
118
+ return None
119
+
120
+
121
+ def _get_rdap_url(domain: str) -> str:
122
+ """Get the best RDAP URL for a domain. Registry-direct when possible."""
123
+ bootstrap = _load_bootstrap()
124
+ tld = domain.rsplit(".", 1)[-1].lower() if "." in domain else ""
125
+
126
+ if tld in bootstrap:
127
+ base = bootstrap[tld]
128
+ return f"{base}domain/{domain}"
129
+
130
+ # Fallback to rdap.org proxy
131
+ return f"{RDAP_FALLBACK}{domain}"
132
+
133
+
134
+ # ─── WHOIS PATTERNS ──────────────────────────────────────────
135
+
136
+ # Patterns indicating domain is available (order matters: more specific first)
137
+ AVAIL_PATTERNS = re.compile(
138
+ r"(?:"
139
+ r"^No match for " # Verisign .com/.net
140
+ r"|^NOT FOUND\b" # Various registries
141
+ r"|^No Data Found" # Some registries
142
+ r"|^The queried object does not exist" # CentralNic (.store, .online, etc.)
143
+ r"|^No entries found" # DENIC .de
144
+ r"|^Domain not found" # Various
145
+ r"|^No such domain" # Various
146
+ r"|^Status:\s*(?:free|available|AVAILABLE)" # Some ccTLD registries
147
+ r"|^%% No matching objects" # RIPE-style
148
+ r"|^This domain name has not been registered" # .hk
149
+ r"|^The domain has not been registered" # .tw
150
+ r"|^Object does not exist" # Various
151
+ r"|DOMAIN NOT FOUND" # Various (case-insensitive handled by flag)
152
+ r"|is free$" # Some registries
153
+ r"|^not registered" # Some registries
154
+ r")",
155
+ re.IGNORECASE | re.MULTILINE,
156
+ )
157
+
158
+ # Patterns indicating domain is taken — these are strong positive signals
159
+ TAKEN_PATTERNS = re.compile(
160
+ r"(?:"
161
+ r"^Domain Name:\s*\S+" # Standard WHOIS domain record
162
+ r"|^Registry Domain ID:\s*\S+" # ICANN registries
163
+ r"|^Registrar:\s*\S+" # Has a registrar = registered
164
+ r"|^Creation Date:\s*\S+" # Has creation date = registered
165
+ r"|^Registry Expiry Date:\s*\S+" # Has expiry = registered
166
+ r"|^Registrar Registration Expiration Date:" # Alternative expiry format
167
+ r"|^created:\s*\S+" # ccTLD format
168
+ r"|^registered:\s*\S+" # .uk format
169
+ r"|^Registration Date:\s*\S+" # Some registries
170
+ r"|^Domain Status:\s*\S+" # ICANN status flags
171
+ r"|^Registered on:\s*\S+" # .uk
172
+ r")",
173
+ re.IGNORECASE | re.MULTILINE,
174
+ )
175
+
176
+
177
+ DEFAULT_TLDS = [
178
+ ".com", ".net", ".org", ".io", ".co", ".ai", ".dev", ".app",
179
+ ".xyz", ".me", ".tech", ".cloud", ".sh", ".so", ".gg",
180
+ ".info", ".biz", ".us", ".online", ".site", ".store",
181
+ ]
182
+
183
+
184
+ # ─── RDAP LOOKUP ─────────────────────────────────────────────
185
+
186
+ def rdap_lookup(domain: str, retries: int = 2) -> Tuple[Optional[Dict], int]:
187
+ """Query RDAP for a domain via its registry server. Returns (data_or_None, http_status)."""
188
+ url = _get_rdap_url(domain)
189
+ for attempt in range(retries + 1):
190
+ req = urllib.request.Request(url, headers={"Accept": "application/rdap+json,application/json"})
191
+ try:
192
+ with urllib.request.urlopen(req, timeout=10) as resp:
193
+ body = resp.read().decode("utf-8")
194
+ return json.loads(body), resp.status
195
+ except urllib.error.HTTPError as e:
196
+ if e.code == 404:
197
+ return None, 404
198
+ if e.code in (429, 502, 503) and attempt < retries:
199
+ time.sleep(1.5 * (attempt + 1))
200
+ continue
201
+ return None, e.code
202
+ except Exception:
203
+ if attempt < retries:
204
+ time.sleep(1.5 * (attempt + 1))
205
+ continue
206
+ return None, 0
207
+ return None, 0
208
+
209
+
210
+ # ─── WHOIS LOOKUP ────────────────────────────────────────────
211
+
212
+ def _run_whois(domain: str, server: Optional[str] = None) -> Optional[str]:
213
+ """Run whois CLI, optionally targeting a specific server."""
214
+ try:
215
+ cmd = ["whois"]
216
+ if server:
217
+ cmd += ["-h", server]
218
+ cmd.append(domain)
219
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
220
+ return result.stdout + result.stderr
221
+ except FileNotFoundError:
222
+ return None
223
+ except subprocess.TimeoutExpired:
224
+ return None
225
+ except Exception:
226
+ return None
227
+
228
+
229
+ # Known registry whois servers for TLDs where the default whois referral chain fails
230
+ _REGISTRY_WHOIS = {
231
+ ".me": "whois.nic.me",
232
+ ".io": "whois.nic.io",
233
+ ".co": "whois.registry.co",
234
+ ".sh": "whois.nic.sh",
235
+ ".gg": "whois.gg",
236
+ ".so": "whois.nic.so",
237
+ ".cc": "ccwhois.verisign-grs.com",
238
+ ".tv": "tvwhois.verisign-grs.com",
239
+ ".us": "whois.nic.us",
240
+ ".in": "whois.registry.in",
241
+ ".de": "whois.denic.de",
242
+ ".uk": "whois.nic.uk",
243
+ ".eu": "whois.eu",
244
+ ".ca": "whois.cira.ca",
245
+ ".au": "whois.auda.org.au",
246
+ ".nl": "whois.domain-registry.nl",
247
+ ".br": "whois.registro.br",
248
+ ".fr": "whois.nic.fr",
249
+ ".it": "whois.nic.it",
250
+ ".jp": "whois.jprs.jp",
251
+ ".ru": "whois.tcinet.ru",
252
+ ".cn": "whois.cnnic.cn",
253
+ }
254
+
255
+
256
+ def whois_lookup(domain: str) -> Optional[str]:
257
+ """Run whois CLI for a domain. Follows referrals and queries registry directly."""
258
+ tld = "." + domain.rsplit(".", 1)[-1].lower() if "." in domain else ""
259
+
260
+ # Strategy 1: For known problematic TLDs, go straight to the registry
261
+ if tld in _REGISTRY_WHOIS:
262
+ registry_output = _run_whois(domain, _REGISTRY_WHOIS[tld])
263
+ if registry_output and len(registry_output.strip()) > 20:
264
+ return registry_output
265
+
266
+ # Strategy 2: Default whois command (follows IANA referrals on most systems)
267
+ output = _run_whois(domain)
268
+ if output is None:
269
+ return None
270
+
271
+ # Check if we only got TLD-level info with no domain data
272
+ has_domain_data = bool(re.search(
273
+ r"^(?:Domain Name|Registry Domain ID|Registrar|Creation Date|"
274
+ r"Domain Status|created:|registered:):\s*\S",
275
+ output, re.MULTILINE | re.IGNORECASE
276
+ ))
277
+
278
+ if has_domain_data:
279
+ return output
280
+
281
+ # Parse "refer:" from IANA response and follow it
282
+ refer_match = re.search(r"^refer:\s*(\S+)", output, re.MULTILINE | re.IGNORECASE)
283
+ if refer_match:
284
+ registry_output = _run_whois(domain, refer_match.group(1))
285
+ if registry_output and len(registry_output.strip()) > 20:
286
+ return registry_output
287
+
288
+ # Also try "whois:" field (some IANA entries use this instead of "refer:")
289
+ whois_match = re.search(r"^whois:\s*(\S+)", output, re.MULTILINE | re.IGNORECASE)
290
+ if whois_match and whois_match.group(1).strip():
291
+ registry_output = _run_whois(domain, whois_match.group(1))
292
+ if registry_output and len(registry_output.strip()) > 20:
293
+ return registry_output
294
+
295
+ return output
296
+
297
+
298
+ def check_availability_whois(domain: str) -> str:
299
+ """Check availability via whois CLI. Returns 'available'|'taken'|'unknown'."""
300
+ output = whois_lookup(domain)
301
+ if output is None:
302
+ return "unknown"
303
+
304
+ # Check available first (specific patterns)
305
+ if AVAIL_PATTERNS.search(output):
306
+ # But make sure there's no actual domain data contradicting "not found"
307
+ # (some whois servers print "not found" in footer even for registered domains)
308
+ if not TAKEN_PATTERNS.search(output):
309
+ return "available"
310
+
311
+ # Check taken
312
+ if TAKEN_PATTERNS.search(output):
313
+ return "taken"
314
+
315
+ # Last resort: if we got substantial output (>500 chars) with no clear signal,
316
+ # it's likely a registered domain with unusual formatting
317
+ if len(output.strip()) > 500:
318
+ return "taken"
319
+
320
+ return "unknown"
321
+
322
+
323
+ # ─── DOMAIN CHECK (COMBINED) ─────────────────────────────────
324
+
325
+ # TLDs that have NO RDAP support — skip RDAP entirely, go straight to whois
326
+ _NO_RDAP_TLDS = {"io", "co", "me", "sh", "so", "gg", "cc", "tv", "us", "uk", "eu",
327
+ "de", "fr", "it", "nl", "br", "jp", "ru", "cn", "ca", "au", "in"}
328
+
329
+ # TLDs where RDAP 404 is authoritative (the registry itself serves RDAP)
330
+ # For these, we trust 404 = available without whois double-check
331
+ _RDAP_AUTHORITATIVE_TLDS = {
332
+ "com", "net", "org", "ai", "dev", "app", "xyz", "tech", "cloud",
333
+ "info", "biz", "online", "site", "store",
334
+ # Google TLDs
335
+ "page", "how", "new", "day", "mov", "zip", "phd", "prof", "esq",
336
+ # Other well-known gTLDs
337
+ "blog", "shop", "art", "design", "agency", "studio", "media",
338
+ }
339
+
340
+
341
+ def check_domain(domain: str) -> Tuple[str, str, Optional[Dict]]:
342
+ """Check a single domain. Returns (status, method, rdap_data)."""
343
+ tld = domain.rsplit(".", 1)[-1].lower() if "." in domain else ""
344
+
345
+ # For TLDs with no RDAP, go straight to whois
346
+ if tld in _NO_RDAP_TLDS:
347
+ status = check_availability_whois(domain)
348
+ return status, "whois", None
349
+
350
+ # Try RDAP (queries registry directly, not rdap.org)
351
+ data, http_status = rdap_lookup(domain)
352
+
353
+ if http_status == 200 and data:
354
+ return "taken", "RDAP", data
355
+
356
+ if http_status == 404:
357
+ # For authoritative TLDs, trust RDAP 404 = available
358
+ if tld in _RDAP_AUTHORITATIVE_TLDS:
359
+ return "available", "RDAP", None
360
+ # For others, double-check with whois
361
+ whois_status = check_availability_whois(domain)
362
+ if whois_status == "taken":
363
+ return "taken", "whois", None
364
+ return "available", "RDAP+whois", None
365
+
366
+ # RDAP failed (rate limit, timeout, etc.) — fall back to whois
367
+ whois_status = check_availability_whois(domain)
368
+ if whois_status != "unknown":
369
+ return whois_status, "whois", None
370
+
371
+ # Both failed — try rdap.org as absolute last resort
372
+ fallback_url = f"{RDAP_FALLBACK}{domain}"
373
+ req = urllib.request.Request(fallback_url, headers={"Accept": "application/rdap+json,application/json"})
374
+ try:
375
+ with urllib.request.urlopen(req, timeout=10) as resp:
376
+ data = json.loads(resp.read().decode("utf-8"))
377
+ return "taken", "RDAP", data
378
+ except urllib.error.HTTPError as e:
379
+ if e.code == 404:
380
+ return "available", "RDAP", None
381
+ except Exception:
382
+ pass
383
+
384
+ return "unknown", "?", None
385
+
386
+
387
+ # ─── RDAP PARSERS ────────────────────────────────────────────
388
+
389
+ def parse_rdap_events(data: Dict) -> Dict[str, str]:
390
+ """Extract dates from RDAP events."""
391
+ dates = {}
392
+ for event in data.get("events", []):
393
+ action = event.get("eventAction", "")
394
+ date = event.get("eventDate", "")
395
+ if action and date:
396
+ dates[action] = date[:10]
397
+ return dates
398
+
399
+
400
+ def parse_rdap_nameservers(data: Dict) -> List[str]:
401
+ """Extract nameservers from RDAP data."""
402
+ ns_list = []
403
+ for ns in data.get("nameservers", []):
404
+ name = ns.get("ldhName", "")
405
+ if name:
406
+ ns_list.append(name.lower())
407
+ return ns_list
408
+
409
+
410
+ def parse_rdap_registrar(data: Dict) -> str:
411
+ """Extract registrar from RDAP entities."""
412
+ for entity in data.get("entities", []):
413
+ roles = entity.get("roles", [])
414
+ if "registrar" in roles:
415
+ vcard = entity.get("vcardArray", [])
416
+ if len(vcard) > 1:
417
+ for field in vcard[1]:
418
+ if field[0] == "fn":
419
+ return field[3]
420
+ for pid in entity.get("publicIds", []):
421
+ return pid.get("identifier", "")
422
+ handle = entity.get("handle", "")
423
+ if handle:
424
+ return handle
425
+ return "?"
426
+
427
+
428
+ def parse_rdap_status(data: Dict) -> List[str]:
429
+ """Extract status flags."""
430
+ return data.get("status", [])
431
+
432
+
433
+ # ─── COMMANDS ────────────────────────────────────────────────
434
+
435
+ def cmd_check(args):
436
+ """Check domain availability."""
437
+ domains = [d.strip() for d in args.domains.split(",") if d.strip()]
438
+ if not domains:
439
+ print("ERROR: No domains provided.")
440
+ sys.exit(1)
441
+
442
+ # Pre-load bootstrap once before parallel work
443
+ _load_bootstrap()
444
+
445
+ print(f"{'Domain':<45} {'Status':<15} {'Method'}")
446
+ print("-" * 72)
447
+
448
+ def check_one(domain):
449
+ status, method, _ = check_domain(domain)
450
+ return domain, status, method
451
+
452
+ with ThreadPoolExecutor(max_workers=8) as pool:
453
+ futures = {pool.submit(check_one, d): d for d in domains}
454
+ results = []
455
+ for future in as_completed(futures):
456
+ results.append(future.result())
457
+
458
+ result_map = {r[0]: r for r in results}
459
+ for domain in domains:
460
+ if domain in result_map:
461
+ _, status, method = result_map[domain]
462
+ status_str = "AVAILABLE" if status == "available" else ("TAKEN" if status == "taken" else "UNKNOWN")
463
+ print(f"{domain:<45} {status_str:<15} {method}")
464
+
465
+
466
+ def cmd_search(args):
467
+ """Search keyword across TLDs."""
468
+ keyword = args.keyword.lower().strip()
469
+ if not keyword:
470
+ print("ERROR: No keyword provided.")
471
+ sys.exit(1)
472
+
473
+ tlds = [t.strip() for t in args.tlds.split(",")] if args.tlds else DEFAULT_TLDS
474
+ tlds = [t if t.startswith(".") else f".{t}" for t in tlds]
475
+ domains = [f"{keyword}{tld}" for tld in tlds]
476
+
477
+ # Pre-load bootstrap once before parallel work
478
+ _load_bootstrap()
479
+
480
+ print(f"Searching: {keyword}")
481
+ print(f"{'Domain':<45} {'Status':<15} {'Method'}")
482
+ print("-" * 72)
483
+
484
+ def check_one(domain):
485
+ status, method, _ = check_domain(domain)
486
+ return domain, status, method
487
+
488
+ # Process in batches — generous since we query registries directly now
489
+ batch_size = 8
490
+ all_results = []
491
+ for i in range(0, len(domains), batch_size):
492
+ batch = domains[i:i+batch_size]
493
+ with ThreadPoolExecutor(max_workers=6) as pool:
494
+ futures = {pool.submit(check_one, d): d for d in batch}
495
+ for future in as_completed(futures):
496
+ all_results.append(future.result())
497
+ if i + batch_size < len(domains):
498
+ time.sleep(0.2)
499
+
500
+ result_map = {r[0]: r for r in all_results}
501
+ avail_count = 0
502
+ for domain in domains:
503
+ if domain in result_map:
504
+ _, status, method = result_map[domain]
505
+ status_str = "AVAILABLE" if status == "available" else ("TAKEN" if status == "taken" else "UNKNOWN")
506
+ if status == "available":
507
+ avail_count += 1
508
+ print(f"{domain:<45} {status_str:<15} {method}")
509
+
510
+ print(f"\n{avail_count} available out of {len(domains)} checked")
511
+
512
+
513
+ def cmd_whois(args):
514
+ """Full WHOIS/RDAP lookup for a domain."""
515
+ domain = args.domain.lower().strip()
516
+
517
+ # Pre-load bootstrap
518
+ _load_bootstrap()
519
+
520
+ # Try RDAP first
521
+ data, status = rdap_lookup(domain)
522
+ if status == 404:
523
+ print(f"{domain}: AVAILABLE (not registered)")
524
+ return
525
+
526
+ if status == 200 and data:
527
+ print(f"Domain: {data.get('ldhName', domain)}")
528
+
529
+ events = parse_rdap_events(data)
530
+ if "registration" in events:
531
+ print(f"Registered: {events['registration']}")
532
+ if "expiration" in events:
533
+ print(f"Expires: {events['expiration']}")
534
+ if "last changed" in events:
535
+ print(f"Updated: {events['last changed']}")
536
+
537
+ registrar = parse_rdap_registrar(data)
538
+ print(f"Registrar: {registrar}")
539
+
540
+ statuses = parse_rdap_status(data)
541
+ if statuses:
542
+ print(f"Status: {', '.join(statuses)}")
543
+
544
+ ns_list = parse_rdap_nameservers(data)
545
+ if ns_list:
546
+ print(f"Nameservers:")
547
+ for ns in ns_list:
548
+ print(f" {ns}")
549
+
550
+ for entity in data.get("entities", []):
551
+ roles = entity.get("roles", [])
552
+ vcard = entity.get("vcardArray", [])
553
+ if len(vcard) > 1 and roles:
554
+ role_str = "/".join(roles)
555
+ for field in vcard[1]:
556
+ if field[0] == "fn" and field[3]:
557
+ print(f"Contact ({role_str}): {field[3]}")
558
+ break
559
+
560
+ print(f"\nSource: RDAP")
561
+ return
562
+
563
+ # Fallback to whois CLI
564
+ output = whois_lookup(domain)
565
+ if output is None:
566
+ print(f"ERROR: Could not look up {domain} (whois not installed or timed out)")
567
+ return
568
+
569
+ if AVAIL_PATTERNS.search(output) and not TAKEN_PATTERNS.search(output):
570
+ print(f"{domain}: AVAILABLE (not registered)")
571
+ return
572
+
573
+ # Parse common fields from whois output
574
+ print(f"Domain: {domain}")
575
+
576
+ fields = [
577
+ ("Registrar", r"Registrar:\s*(.+)"),
578
+ ("Created", r"Creat(?:ion|ed)\s*(?:Date)?:\s*(.+)"),
579
+ ("Expires", r"(?:Expir(?:y|ation)|Registry Expiry)\s*(?:Date)?:\s*(.+)"),
580
+ ("Updated", r"Updated?\s*(?:Date)?:\s*(.+)"),
581
+ ("Status", r"(?:Domain )?Status:\s*(.+)"),
582
+ ]
583
+
584
+ seen_status = False
585
+ for label, pattern in fields:
586
+ matches = re.findall(pattern, output, re.IGNORECASE | re.MULTILINE)
587
+ if matches:
588
+ if label == "Status":
589
+ if not seen_status:
590
+ seen_status = True
591
+ for m in matches[:3]:
592
+ print(f"Status: {m.strip()}")
593
+ else:
594
+ print(f"{label + ':':<13}{matches[0].strip()}")
595
+
596
+ ns_matches = re.findall(r"Name Server:\s*(.+)", output, re.IGNORECASE)
597
+ if not ns_matches:
598
+ ns_matches = re.findall(r"nserver:\s*(.+)", output, re.IGNORECASE)
599
+ if ns_matches:
600
+ print(f"Nameservers:")
601
+ for ns in ns_matches[:6]:
602
+ print(f" {ns.strip().lower()}")
603
+
604
+ print(f"\nSource: whois CLI")
605
+
606
+
607
+ def cmd_expiry(args):
608
+ """Check domain expiration."""
609
+ domain = args.domain.lower().strip()
610
+
611
+ _load_bootstrap()
612
+
613
+ data, status = rdap_lookup(domain)
614
+ if status == 404:
615
+ print(f"{domain}: AVAILABLE (not registered)")
616
+ return
617
+
618
+ if status == 200 and data:
619
+ events = parse_rdap_events(data)
620
+ exp = events.get("expiration", "?")
621
+ reg = events.get("registration", "?")
622
+ print(f"Domain: {domain}")
623
+ print(f"Registered: {reg}")
624
+ print(f"Expires: {exp}")
625
+
626
+ if exp != "?":
627
+ try:
628
+ from datetime import datetime, date
629
+ exp_date = datetime.strptime(exp, "%Y-%m-%d").date()
630
+ today = date.today()
631
+ delta = (exp_date - today).days
632
+ if delta > 0:
633
+ print(f"Days left: {delta}")
634
+ elif delta == 0:
635
+ print(f"Days left: EXPIRES TODAY")
636
+ else:
637
+ print(f"Days left: EXPIRED {abs(delta)} days ago")
638
+ except Exception:
639
+ pass
640
+ return
641
+
642
+ # Whois fallback
643
+ output = whois_lookup(domain)
644
+ if output is None:
645
+ print(f"ERROR: Could not look up {domain}")
646
+ return
647
+
648
+ if AVAIL_PATTERNS.search(output) and not TAKEN_PATTERNS.search(output):
649
+ print(f"{domain}: AVAILABLE (not registered)")
650
+ return
651
+
652
+ print(f"Domain: {domain}")
653
+ exp_match = re.search(r"(?:Expir(?:y|ation)|Registry Expiry)\s*(?:Date)?:\s*(.+)", output, re.IGNORECASE)
654
+ if exp_match:
655
+ print(f"Expires: {exp_match.group(1).strip()}")
656
+ else:
657
+ print(f"Expires: ? (could not parse)")
658
+
659
+ reg_match = re.search(r"Creat(?:ion|ed)\s*(?:Date)?:\s*(.+)", output, re.IGNORECASE)
660
+ if reg_match:
661
+ print(f"Registered: {reg_match.group(1).strip()}")
662
+
663
+
664
+ def cmd_nameservers(args):
665
+ """Get nameservers for a domain."""
666
+ domain = args.domain.lower().strip()
667
+
668
+ _load_bootstrap()
669
+
670
+ data, status = rdap_lookup(domain)
671
+ if status == 404:
672
+ print(f"{domain}: AVAILABLE (not registered)")
673
+ return
674
+
675
+ if status == 200 and data:
676
+ ns_list = parse_rdap_nameservers(data)
677
+ if ns_list:
678
+ print(f"Nameservers for {domain}:")
679
+ for ns in ns_list:
680
+ print(f" {ns}")
681
+ else:
682
+ print(f"{domain}: No nameservers in RDAP data")
683
+ return
684
+
685
+ # Whois fallback
686
+ output = whois_lookup(domain)
687
+ if output is None:
688
+ print(f"ERROR: Could not look up {domain}")
689
+ return
690
+
691
+ if AVAIL_PATTERNS.search(output) and not TAKEN_PATTERNS.search(output):
692
+ print(f"{domain}: AVAILABLE (not registered)")
693
+ return
694
+
695
+ ns_matches = re.findall(r"Name Server:\s*(.+)", output, re.IGNORECASE)
696
+ if not ns_matches:
697
+ ns_matches = re.findall(r"nserver:\s*(.+)", output, re.IGNORECASE)
698
+
699
+ if ns_matches:
700
+ print(f"Nameservers for {domain}:")
701
+ for ns in ns_matches[:6]:
702
+ print(f" {ns.strip().lower()}")
703
+ else:
704
+ print(f"{domain}: Could not find nameservers")
705
+
706
+
707
+ def cmd_bulk(args):
708
+ """Check domains from a file (one per line)."""
709
+ filepath = args.file
710
+ if not os.path.isfile(filepath):
711
+ print(f"ERROR: File not found: {filepath}")
712
+ sys.exit(1)
713
+
714
+ with open(filepath, "r") as f:
715
+ domains = [line.strip() for line in f if line.strip() and not line.startswith("#")]
716
+
717
+ if not domains:
718
+ print("ERROR: No domains in file.")
719
+ sys.exit(1)
720
+
721
+ _load_bootstrap()
722
+
723
+ print(f"Checking {len(domains)} domains from {filepath}")
724
+ print(f"{'Domain':<45} {'Status':<15} {'Method'}")
725
+ print("-" * 72)
726
+
727
+ avail_count = 0
728
+ taken_count = 0
729
+
730
+ def check_one(domain):
731
+ status, method, _ = check_domain(domain)
732
+ return domain, status, method
733
+
734
+ batch_size = 10
735
+ all_results = []
736
+ for i in range(0, len(domains), batch_size):
737
+ batch = domains[i:i+batch_size]
738
+ with ThreadPoolExecutor(max_workers=8) as pool:
739
+ futures = {pool.submit(check_one, d): d for d in batch}
740
+ for future in as_completed(futures):
741
+ all_results.append(future.result())
742
+ if i + batch_size < len(domains):
743
+ time.sleep(0.3)
744
+
745
+ result_map = {r[0]: r for r in all_results}
746
+ for domain in domains:
747
+ if domain in result_map:
748
+ _, status, method = result_map[domain]
749
+ status_str = "AVAILABLE" if status == "available" else ("TAKEN" if status == "taken" else "UNKNOWN")
750
+ if status == "available":
751
+ avail_count += 1
752
+ elif status == "taken":
753
+ taken_count += 1
754
+ print(f"{domain:<45} {status_str:<15} {method}")
755
+
756
+ print(f"\nSummary: {avail_count} available, {taken_count} taken, {len(domains) - avail_count - taken_count} unknown")
757
+
758
+
759
+ # ─── MAIN ────────────────────────────────────────────────────
760
+
761
+ def main():
762
+ parser = argparse.ArgumentParser(
763
+ description="Domain Research CLI -- Free RDAP + WHOIS lookup, zero credentials",
764
+ formatter_class=argparse.RawDescriptionHelpFormatter,
765
+ )
766
+ subs = parser.add_subparsers(dest="command")
767
+
768
+ p = subs.add_parser("check", help="Check domain availability")
769
+ p.add_argument("domains", help="Comma-separated domains")
770
+
771
+ p = subs.add_parser("search", help="Search keyword across TLDs")
772
+ p.add_argument("keyword", help="Keyword to search")
773
+ p.add_argument("--tlds", default=None, help="Comma-separated TLDs (e.g. .com,.net,.io)")
774
+
775
+ p = subs.add_parser("whois", help="Full WHOIS/RDAP lookup")
776
+ p.add_argument("domain", help="Domain to look up")
777
+
778
+ p = subs.add_parser("expiry", help="Check domain expiration")
779
+ p.add_argument("domain", help="Domain to check")
780
+
781
+ p = subs.add_parser("nameservers", help="Get nameservers")
782
+ p.add_argument("domain", help="Domain to check")
783
+
784
+ p = subs.add_parser("bulk", help="Bulk check from file")
785
+ p.add_argument("file", help="File with one domain per line")
786
+
787
+ args = parser.parse_args()
788
+
789
+ if not args.command:
790
+ parser.print_help()
791
+ sys.exit(1)
792
+
793
+ cmd_map = {
794
+ "check": cmd_check,
795
+ "search": cmd_search,
796
+ "whois": cmd_whois,
797
+ "expiry": cmd_expiry,
798
+ "nameservers": cmd_nameservers,
799
+ "bulk": cmd_bulk,
800
+ }
801
+
802
+ func = cmd_map.get(args.command)
803
+ if func:
804
+ func(args)
805
+ else:
806
+ parser.print_help()
807
+
808
+
809
+ if __name__ == "__main__":
810
+ main()