cct-cli 0.7.9.0__py3-none-any.whl

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 (325) hide show
  1. calc_terminal/__init__.py +14 -0
  2. calc_terminal/__main__.py +14 -0
  3. calc_terminal/activity.py +1334 -0
  4. calc_terminal/agent.py +3387 -0
  5. calc_terminal/agent_runtime.py +519 -0
  6. calc_terminal/ai_context.py +447 -0
  7. calc_terminal/ai_modes.py +752 -0
  8. calc_terminal/ai_personalization.py +286 -0
  9. calc_terminal/ai_preview_feedback.py +213 -0
  10. calc_terminal/aicore.py +2572 -0
  11. calc_terminal/anim.py +367 -0
  12. calc_terminal/app.py +3685 -0
  13. calc_terminal/art.py +639 -0
  14. calc_terminal/atomsim.py +368 -0
  15. calc_terminal/attachments.py +743 -0
  16. calc_terminal/benchmark_system.py +414 -0
  17. calc_terminal/browser/__init__.py +36 -0
  18. calc_terminal/browser/browser_state.py +346 -0
  19. calc_terminal/browser/devserver.py +176 -0
  20. calc_terminal/browser/engine.py +494 -0
  21. calc_terminal/browser/navigation.py +84 -0
  22. calc_terminal/browser/preview.py +429 -0
  23. calc_terminal/browser/preview_entry.py +95 -0
  24. calc_terminal/browser/project_detector.py +144 -0
  25. calc_terminal/browser/server.py +449 -0
  26. calc_terminal/browser/state.py +75 -0
  27. calc_terminal/browser/watcher.py +99 -0
  28. calc_terminal/browser_gui/__init__.py +1 -0
  29. calc_terminal/browser_gui/__main__.py +3 -0
  30. calc_terminal/browser_gui/launcher.py +173 -0
  31. calc_terminal/browser_gui/playwright_browser.py +117 -0
  32. calc_terminal/browser_gui/qt_browser.py +1501 -0
  33. calc_terminal/browser_gui/webview_browser.py +57 -0
  34. calc_terminal/capabilities/__init__.py +35 -0
  35. calc_terminal/capabilities/adapters/__init__.py +33 -0
  36. calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
  37. calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
  38. calc_terminal/capabilities/adapters/filesystem.py +206 -0
  39. calc_terminal/capabilities/adapters/git_adapter.py +202 -0
  40. calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
  41. calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
  42. calc_terminal/capabilities/adapters/platforms.py +200 -0
  43. calc_terminal/capabilities/adapters/python_exec.py +93 -0
  44. calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
  45. calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
  46. calc_terminal/capabilities/adapters/structural_bio.py +161 -0
  47. calc_terminal/capabilities/adapters/terminal.py +99 -0
  48. calc_terminal/capabilities/bus.py +178 -0
  49. calc_terminal/capabilities/discovery.py +207 -0
  50. calc_terminal/capabilities/schema.py +221 -0
  51. calc_terminal/cat.ico +0 -0
  52. calc_terminal/cat_browser.py +2018 -0
  53. calc_terminal/chat_store.py +703 -0
  54. calc_terminal/cli.py +1178 -0
  55. calc_terminal/code_editor.py +640 -0
  56. calc_terminal/collaboration.py +723 -0
  57. calc_terminal/commands_data.py +139 -0
  58. calc_terminal/compatibility_engine.py +352 -0
  59. calc_terminal/compute/__init__.py +31 -0
  60. calc_terminal/compute/fabric.py +350 -0
  61. calc_terminal/config.py +227 -0
  62. calc_terminal/core/__init__.py +41 -0
  63. calc_terminal/core/checkpoint.py +156 -0
  64. calc_terminal/core/input/__init__.py +45 -0
  65. calc_terminal/core/mode_registry.py +300 -0
  66. calc_terminal/core/project_graph.py +172 -0
  67. calc_terminal/core/recovery.py +129 -0
  68. calc_terminal/core/security_layer.py +112 -0
  69. calc_terminal/core/task_graph.py +202 -0
  70. calc_terminal/core/unified_runtime.py +184 -0
  71. calc_terminal/core/verification.py +257 -0
  72. calc_terminal/customization.py +1566 -0
  73. calc_terminal/derivations.py +153 -0
  74. calc_terminal/device_control.py +263 -0
  75. calc_terminal/diagnostics/__init__.py +27 -0
  76. calc_terminal/diagnostics/doctor_engine.py +382 -0
  77. calc_terminal/diagnostics/self_test.py +247 -0
  78. calc_terminal/doctor.py +519 -0
  79. calc_terminal/easter_eggs.py +274 -0
  80. calc_terminal/editor/__init__.py +1 -0
  81. calc_terminal/editor/actions.py +263 -0
  82. calc_terminal/editor/commands.py +160 -0
  83. calc_terminal/editor/shortcuts.py +226 -0
  84. calc_terminal/engine.py +259 -0
  85. calc_terminal/errors.py +120 -0
  86. calc_terminal/event_stream.py +146 -0
  87. calc_terminal/eventbus.py +133 -0
  88. calc_terminal/extensions.py +733 -0
  89. calc_terminal/fallback_cli.py +1321 -0
  90. calc_terminal/first_run.py +265 -0
  91. calc_terminal/fomoji_auth.py +1043 -0
  92. calc_terminal/formulas.py +82 -0
  93. calc_terminal/fs_cache.py +121 -0
  94. calc_terminal/fs_watcher.py +277 -0
  95. calc_terminal/game.py +193 -0
  96. calc_terminal/gen1.py +5 -0
  97. calc_terminal/generators.py +245 -0
  98. calc_terminal/gestures/__init__.py +42 -0
  99. calc_terminal/gestures/bindings.py +175 -0
  100. calc_terminal/gestures/manager.py +477 -0
  101. calc_terminal/goodbye.py +363 -0
  102. calc_terminal/gpu3d.py +290 -0
  103. calc_terminal/graphs.py +358 -0
  104. calc_terminal/hardware_analyzer.py +440 -0
  105. calc_terminal/host/__init__.py +30 -0
  106. calc_terminal/host/browser_manager.py +187 -0
  107. calc_terminal/host/desktop.py +1386 -0
  108. calc_terminal/host/launcher.py +395 -0
  109. calc_terminal/host/terminal.py +279 -0
  110. calc_terminal/identity.py +216 -0
  111. calc_terminal/input/__init__.py +54 -0
  112. calc_terminal/input/capabilities.py +258 -0
  113. calc_terminal/input/focus.py +87 -0
  114. calc_terminal/input/gestures.py +64 -0
  115. calc_terminal/input/pointer.py +114 -0
  116. calc_terminal/input/touch.py +345 -0
  117. calc_terminal/keys.py +84 -0
  118. calc_terminal/live_automation.py +165 -0
  119. calc_terminal/mathtext.py +433 -0
  120. calc_terminal/mcp.py +386 -0
  121. calc_terminal/memory.py +337 -0
  122. calc_terminal/memory_v2.py +479 -0
  123. calc_terminal/metrics.py +333 -0
  124. calc_terminal/mode_detection.py +146 -0
  125. calc_terminal/model.py +2431 -0
  126. calc_terminal/model_router.py +665 -0
  127. calc_terminal/models/__init__.py +0 -0
  128. calc_terminal/models/active_state.py +187 -0
  129. calc_terminal/models/dynamic_registry.py +584 -0
  130. calc_terminal/models/manager.py +781 -0
  131. calc_terminal/models/model_metadata.json +3526 -0
  132. calc_terminal/models/profiles.py +194 -0
  133. calc_terminal/models/registry.py +265 -0
  134. calc_terminal/models/schema.py +197 -0
  135. calc_terminal/models/validator.py +287 -0
  136. calc_terminal/models/verification_engine.py +368 -0
  137. calc_terminal/native_picker.py +215 -0
  138. calc_terminal/ollama_catalog.py +279 -0
  139. calc_terminal/ollama_download.py +233 -0
  140. calc_terminal/orchestrator.py +304 -0
  141. calc_terminal/package_research.py +322 -0
  142. calc_terminal/packages.py +1024 -0
  143. calc_terminal/pc_specs.py +116 -0
  144. calc_terminal/permissions.py +334 -0
  145. calc_terminal/pet.py +106 -0
  146. calc_terminal/pipeline.py +505 -0
  147. calc_terminal/platform/__init__.py +491 -0
  148. calc_terminal/platform/desktop.py +491 -0
  149. calc_terminal/platform/web.py +781 -0
  150. calc_terminal/preview/__init__.py +1 -0
  151. calc_terminal/preview/dev_server.py +303 -0
  152. calc_terminal/preview/diagnostics.py +131 -0
  153. calc_terminal/preview/live_reload.py +66 -0
  154. calc_terminal/preview/manager.py +129 -0
  155. calc_terminal/project_stats.py +209 -0
  156. calc_terminal/projects.py +328 -0
  157. calc_terminal/providers/__init__.py +0 -0
  158. calc_terminal/providers/adapters/__init__.py +80 -0
  159. calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
  160. calc_terminal/providers/adapters/base.py +106 -0
  161. calc_terminal/providers/adapters/chinese_adapters.py +420 -0
  162. calc_terminal/providers/adapters/gemini_adapter.py +101 -0
  163. calc_terminal/providers/adapters/ollama_adapter.py +83 -0
  164. calc_terminal/providers/adapters/openai_adapter.py +159 -0
  165. calc_terminal/providers/adapters/other_adapters.py +246 -0
  166. calc_terminal/providers/anthropic_provider.py +172 -0
  167. calc_terminal/providers/auto_update.py +416 -0
  168. calc_terminal/providers/base_provider.py +105 -0
  169. calc_terminal/providers/discovery_manager.py +207 -0
  170. calc_terminal/providers/gemini_provider.py +178 -0
  171. calc_terminal/providers/lifecycle.py +767 -0
  172. calc_terminal/providers/ollama_adapter.py +707 -0
  173. calc_terminal/providers/openai_provider.py +254 -0
  174. calc_terminal/providers/provider_manager.py +1827 -0
  175. calc_terminal/providers/providers.json +4075 -0
  176. calc_terminal/reactionsim.py +279 -0
  177. calc_terminal/registry.py +337 -0
  178. calc_terminal/report.py +162 -0
  179. calc_terminal/research/__init__.py +45 -0
  180. calc_terminal/research/artifact_intel.py +126 -0
  181. calc_terminal/research/data_lineage.py +123 -0
  182. calc_terminal/research/experiment_ledger.py +303 -0
  183. calc_terminal/research/reproducibility.py +131 -0
  184. calc_terminal/resilience/__init__.py +47 -0
  185. calc_terminal/resilience/agent_state.py +121 -0
  186. calc_terminal/resilience/capability_matcher.py +174 -0
  187. calc_terminal/resilience/circuit_breaker.py +158 -0
  188. calc_terminal/resilience/failover_engine.py +230 -0
  189. calc_terminal/resilience/health_monitor.py +192 -0
  190. calc_terminal/resilience/ollama_adapter.py +125 -0
  191. calc_terminal/resilience/orchestrator.py +312 -0
  192. calc_terminal/resilience/types.py +134 -0
  193. calc_terminal/sandbox.py +98 -0
  194. calc_terminal/scires.py +558 -0
  195. calc_terminal/security_scanner.py +126 -0
  196. calc_terminal/session.py +294 -0
  197. calc_terminal/sim3d.py +206 -0
  198. calc_terminal/solver.py +276 -0
  199. calc_terminal/sound.py +127 -0
  200. calc_terminal/task_reports.py +287 -0
  201. calc_terminal/terminal_host.py +201 -0
  202. calc_terminal/terminal_identity.py +411 -0
  203. calc_terminal/test_ai_mode_reliability.py +344 -0
  204. calc_terminal/test_browser.py +368 -0
  205. calc_terminal/test_code_editor_upgrade.py +485 -0
  206. calc_terminal/test_customization.py +1148 -0
  207. calc_terminal/test_customization_ui.py +612 -0
  208. calc_terminal/test_dynamic_registry.py +304 -0
  209. calc_terminal/test_extensions.py +436 -0
  210. calc_terminal/test_overhaul.py +557 -0
  211. calc_terminal/test_project_detect.py +255 -0
  212. calc_terminal/test_root_cause_fix.py +527 -0
  213. calc_terminal/test_stability.py +532 -0
  214. calc_terminal/test_terminal_identity.py +132 -0
  215. calc_terminal/test_v079_speed.py +460 -0
  216. calc_terminal/theme.py +1107 -0
  217. calc_terminal/timeline.py +139 -0
  218. calc_terminal/todos.py +246 -0
  219. calc_terminal/tool_call_normalizer.py +419 -0
  220. calc_terminal/tui.py +104 -0
  221. calc_terminal/ui/__init__.py +8 -0
  222. calc_terminal/ui/activity_panel.py +231 -0
  223. calc_terminal/ui/activity_stream_panel.py +238 -0
  224. calc_terminal/ui/animations.py +122 -0
  225. calc_terminal/ui/app.py +7271 -0
  226. calc_terminal/ui/attach_panel.py +597 -0
  227. calc_terminal/ui/attachments.py +424 -0
  228. calc_terminal/ui/backup_panel.py +810 -0
  229. calc_terminal/ui/browser_shell.py +887 -0
  230. calc_terminal/ui/cat_agent.py +357 -0
  231. calc_terminal/ui/chats_panel.py +899 -0
  232. calc_terminal/ui/command_palette.py +125 -0
  233. calc_terminal/ui/command_palette_modal.py +166 -0
  234. calc_terminal/ui/composer.py +1141 -0
  235. calc_terminal/ui/context_menu.py +197 -0
  236. calc_terminal/ui/conversation.py +1435 -0
  237. calc_terminal/ui/customization_panel.py +1229 -0
  238. calc_terminal/ui/dashboard.py +404 -0
  239. calc_terminal/ui/design_system.py +557 -0
  240. calc_terminal/ui/diff_panel.py +213 -0
  241. calc_terminal/ui/editor.py +2102 -0
  242. calc_terminal/ui/empty_state.py +302 -0
  243. calc_terminal/ui/events.py +487 -0
  244. calc_terminal/ui/extensions_panel.py +815 -0
  245. calc_terminal/ui/footer.py +166 -0
  246. calc_terminal/ui/gestures_panel.py +383 -0
  247. calc_terminal/ui/goodbye_screen.py +100 -0
  248. calc_terminal/ui/header.py +1034 -0
  249. calc_terminal/ui/help_panel.py +254 -0
  250. calc_terminal/ui/live_activities.py +914 -0
  251. calc_terminal/ui/mcp_panel.py +570 -0
  252. calc_terminal/ui/memory_center.py +524 -0
  253. calc_terminal/ui/mode_colors_panel.py +525 -0
  254. calc_terminal/ui/nav_screens.py +747 -0
  255. calc_terminal/ui/ollama_panel.py +536 -0
  256. calc_terminal/ui/palette.py +221 -0
  257. calc_terminal/ui/permission_panel.py +269 -0
  258. calc_terminal/ui/personalization_panel.py +517 -0
  259. calc_terminal/ui/personalize_center.py +1568 -0
  260. calc_terminal/ui/preview_panel.py +441 -0
  261. calc_terminal/ui/resizers.py +402 -0
  262. calc_terminal/ui/sidebar.py +1285 -0
  263. calc_terminal/ui/statusbar.py +168 -0
  264. calc_terminal/ui/theme_css.py +1396 -0
  265. calc_terminal/ui/thinking.py +226 -0
  266. calc_terminal/ui/timeline_panel.py +102 -0
  267. calc_terminal/ui/todo_panel.py +193 -0
  268. calc_terminal/ui/viewport.py +136 -0
  269. calc_terminal/ui/vision_panel.py +489 -0
  270. calc_terminal/ui/welcome_modal.py +343 -0
  271. calc_terminal/ui/widgets.py +160 -0
  272. calc_terminal/ui/workspace.py +831 -0
  273. calc_terminal/viewers/__init__.py +1 -0
  274. calc_terminal/viewers/document_viewer.py +252 -0
  275. calc_terminal/viewers/image_viewer.py +241 -0
  276. calc_terminal/viewers/pdf_viewer.py +203 -0
  277. calc_terminal/viewers/presentation_viewer.py +164 -0
  278. calc_terminal/viewers/registry.py +120 -0
  279. calc_terminal/viewers/spreadsheet_viewer.py +204 -0
  280. calc_terminal/vision/__init__.py +89 -0
  281. calc_terminal/vision/analysis.py +194 -0
  282. calc_terminal/vision/annotations.py +297 -0
  283. calc_terminal/vision/capture.py +171 -0
  284. calc_terminal/vision/context.py +231 -0
  285. calc_terminal/vision/correlation.py +169 -0
  286. calc_terminal/vision/cursor.py +258 -0
  287. calc_terminal/vision/events.py +66 -0
  288. calc_terminal/vision/frame_pipeline.py +259 -0
  289. calc_terminal/vision/priority.py +218 -0
  290. calc_terminal/vision/provider.py +180 -0
  291. calc_terminal/vision/safety.py +149 -0
  292. calc_terminal/vision/session.py +281 -0
  293. calc_terminal/vision/verify.py +162 -0
  294. calc_terminal/vision.py +514 -0
  295. calc_terminal/vscode_integration.py +113 -0
  296. calc_terminal/web/__init__.py +8 -0
  297. calc_terminal/web/cat_runtime.py +710 -0
  298. calc_terminal/web/server.py +2891 -0
  299. calc_terminal/web/static/css/app.css +3152 -0
  300. calc_terminal/web/static/icons/badge-72.png +0 -0
  301. calc_terminal/web/static/icons/cat.ico +0 -0
  302. calc_terminal/web/static/icons/icon-128.png +0 -0
  303. calc_terminal/web/static/icons/icon-144.png +0 -0
  304. calc_terminal/web/static/icons/icon-152.png +0 -0
  305. calc_terminal/web/static/icons/icon-192.png +0 -0
  306. calc_terminal/web/static/icons/icon-384.png +0 -0
  307. calc_terminal/web/static/icons/icon-512.png +0 -0
  308. calc_terminal/web/static/icons/icon-72.png +0 -0
  309. calc_terminal/web/static/icons/icon-96.png +0 -0
  310. calc_terminal/web/static/icons/icon.svg +34 -0
  311. calc_terminal/web/static/icons/new-project.png +0 -0
  312. calc_terminal/web/static/icons/open-project.png +0 -0
  313. calc_terminal/web/static/index.html +734 -0
  314. calc_terminal/web/static/js/app.js +2403 -0
  315. calc_terminal/web/static/manifest.json +88 -0
  316. calc_terminal/web/static/sw.js +230 -0
  317. calc_terminal/workflow_engine.py +769 -0
  318. calc_terminal/workspace.py +593 -0
  319. calc_terminal/workspace_index.py +385 -0
  320. cct_cli-0.7.9.0.dist-info/METADATA +210 -0
  321. cct_cli-0.7.9.0.dist-info/RECORD +325 -0
  322. cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
  323. cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
  324. cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
  325. cct_cli-0.7.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1043 @@
1
+ """
2
+ CAT — Fomoji Authentication Gate.
3
+
4
+ Every CAT entrypoint (cat CLI, python main.py, python -m calc_terminal)
5
+ MUST pass through this module before any UI or REPL is shown.
6
+
7
+ Architecture:
8
+ Fomoji (Node/Express) ── owns identity + auth, never sees CAT's code
9
+ ^ HTTP device-authorization flow (RFC 8628 shape)
10
+ |
11
+ fomoji_auth.py <-- you are here (CAT side gate)
12
+ ^
13
+ |
14
+ CAT (calc_terminal.cli)
15
+
16
+ Why a device flow and not a redirect?
17
+ A CLI/desktop Python process has no browser of its own to redirect through,
18
+ so it shows a short code + verification URL, a human approves it in their
19
+ browser on the Fomoji site, and this module polls until that happens — the
20
+ same pattern `gh auth login` / `docker login` use.
21
+
22
+ Storage:
23
+ The connector token (and nothing else) lives at:
24
+ ~/.fomoji/connectors/cat.json (0600, per application)
25
+ No password, no passkey secret, no session cookie is ever stored here.
26
+
27
+ Enforcement:
28
+ Without a valid `connected` status, CAT refuses to start. See
29
+ `require_auth()` / `enforce_or_exit()` — there is NO silent bypass.
30
+ The only intentional overrides are:
31
+ * CAT_SKIP_AUTH=1 (or CAT_ALLOW_NO_AUTH=1) — for automated tests / CI.
32
+ * --help / --version / --doctor — never gated.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ import os
39
+ import stat
40
+ import sys
41
+ import time
42
+ import urllib.error
43
+ import urllib.request
44
+ from pathlib import Path
45
+ from typing import Any, Dict, Optional
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Config — keep these in sync with fomoji-connector-py/connector.py and
49
+ # fomoji-server/src/db.js (where `cat` is seeded as the first-party app).
50
+ # ---------------------------------------------------------------------------
51
+
52
+ APPLICATION_ID = os.environ.get("FOMOJI_APP_ID", "cat")
53
+ DEFAULT_FOMOJI_URL = os.environ.get("FOMOJI_URL", "http://localhost:3000").rstrip("/")
54
+ CONNECTOR_DIR = Path(
55
+ os.environ.get(
56
+ "FOMOJI_CONNECTOR_DIR",
57
+ str(Path.home() / ".fomoji" / "connectors"),
58
+ )
59
+ )
60
+ TOKEN_PATH = CONNECTOR_DIR / f"{APPLICATION_ID}.json"
61
+
62
+ # Permissions CAT asks for by default. Must be a subset of the
63
+ # `permissions_available` seeded for `cat` in fomoji-server/src/db.js:
64
+ # ['IDENTITY','PROFILE','CAT_ACCESS','PROJECT']
65
+ DEFAULT_PERMISSIONS = ["IDENTITY", "CAT_ACCESS"]
66
+
67
+ # Skip flag — intentionally env-only, never a CLI flag that a script could
68
+ # pass accidentally and leave a machine wide-open.
69
+ _SKIP_ENV = {"CAT_SKIP_AUTH", "CAT_ALLOW_NO_AUTH"}
70
+
71
+
72
+ class FomojiAuthError(RuntimeError):
73
+ """Raised when Fomoji auth is required but cannot be completed."""
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Local token store — 0600, same as connector.py.
78
+ # ---------------------------------------------------------------------------
79
+
80
+ def _load_local() -> Optional[Dict[str, Any]]:
81
+ if not TOKEN_PATH.exists():
82
+ return None
83
+ try:
84
+ return json.loads(TOKEN_PATH.read_text(encoding="utf-8"))
85
+ except (json.JSONDecodeError, OSError):
86
+ return None
87
+
88
+
89
+ def _save_local(data: Dict[str, Any]) -> None:
90
+ CONNECTOR_DIR.mkdir(parents=True, exist_ok=True)
91
+ TOKEN_PATH.write_text(json.dumps(data, indent=2), encoding="utf-8")
92
+ try:
93
+ os.chmod(TOKEN_PATH, stat.S_IRUSR | stat.S_IWUSR)
94
+ except OSError:
95
+ pass
96
+
97
+
98
+ def _clear_local() -> None:
99
+ try:
100
+ TOKEN_PATH.unlink()
101
+ except FileNotFoundError:
102
+ pass
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # HTTP helpers — stdlib only, no extra deps.
107
+ # ---------------------------------------------------------------------------
108
+
109
+ def get_fomoji_url() -> str:
110
+ return os.environ.get("FOMOJI_URL", DEFAULT_FOMOJI_URL).rstrip("/")
111
+
112
+
113
+ def _request(
114
+ method: str,
115
+ path: str,
116
+ body: Optional[Dict[str, Any]] = None,
117
+ auth_token: Optional[str] = None,
118
+ query: Optional[Dict[str, str]] = None,
119
+ timeout: int = 15,
120
+ ) -> Dict[str, Any]:
121
+ base = get_fomoji_url()
122
+ url = f"{base}{path}"
123
+ if query:
124
+ from urllib.parse import urlencode
125
+ url = f"{url}?{urlencode(query)}"
126
+ data = json.dumps(body).encode("utf-8") if body is not None else None
127
+ req = urllib.request.Request(url, data=data, method=method)
128
+ req.add_header("Content-Type", "application/json")
129
+ if auth_token:
130
+ req.add_header("Authorization", f"Bearer {auth_token}")
131
+ try:
132
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
133
+ raw = resp.read().decode("utf-8")
134
+ return json.loads(raw) if raw else {}
135
+ except urllib.error.HTTPError as e:
136
+ raw = e.read().decode("utf-8")
137
+ try:
138
+ parsed = json.loads(raw)
139
+ message = parsed.get("error", raw)
140
+ except json.JSONDecodeError:
141
+ message = raw or str(e)
142
+ raise FomojiAuthError(f"Fomoji request failed ({e.code}): {message}") from None
143
+ except urllib.error.URLError as e:
144
+ raise FomojiAuthError(f"Could not reach Fomoji at {base}: {e.reason}") from None
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Status helpers
149
+ # ---------------------------------------------------------------------------
150
+
151
+ def is_skip_enabled() -> bool:
152
+ for key in _SKIP_ENV:
153
+ v = os.environ.get(key, "").strip().lower()
154
+ if v in ("1", "true", "yes", "on"):
155
+ return True
156
+ return False
157
+
158
+
159
+ def _find_fomoji_server_dir() -> Optional[Path]:
160
+ """Locate the fomoji-server directory for auto-start."""
161
+ candidates: List[Path] = []
162
+ env_dir = os.environ.get("FOMOJI_SERVER_DIR", "").strip()
163
+ if env_dir:
164
+ candidates.append(Path(env_dir))
165
+ # Relative to this file's repo layout (source checkout)
166
+ try:
167
+ here = Path(__file__).resolve()
168
+ # calc_terminal/fomoji_auth.py -> parents[1] = CAT_v0.7.x
169
+ repo_root = here.parents[1]
170
+ candidates.append(repo_root.parent / "fomoji-updated" / "fomoji-server")
171
+ candidates.append(repo_root / "fomoji-updated" / "fomoji-server")
172
+ candidates.append(repo_root / ".." / "fomoji-updated" / "fomoji-server")
173
+ except Exception:
174
+ pass
175
+ candidates.append(Path.cwd() / "fomoji-updated" / "fomoji-server")
176
+ candidates.append(Path.cwd() / "fomoji-server")
177
+ candidates.append(Path.home() / "fomoji-server")
178
+ # Also check sibling of CAT_v0.7.9 folder (common download layout)
179
+ try:
180
+ dl_root = Path(__file__).resolve().parents[2]
181
+ candidates.append(dl_root / "fomoji-updated" / "fomoji-server")
182
+ except Exception:
183
+ pass
184
+ for cand in candidates:
185
+ try:
186
+ c = cand.resolve()
187
+ if (c / "package.json").exists() and (c / "src" / "server.js").exists():
188
+ return c
189
+ if (c / "src" / "server.js").exists():
190
+ return c
191
+ except Exception:
192
+ continue
193
+ return None
194
+
195
+
196
+ def _is_server_process_running() -> bool:
197
+ # Lightweight: just probe, don't cache PID yet.
198
+ return check_server_reachable(timeout=2)
199
+
200
+
201
+ _SERVER_LOADING_STEPS = [
202
+ (0, "Initializing"),
203
+ (10, "Finding server files"),
204
+ (25, "Starting Node.js"),
205
+ (45, "Waiting for server"),
206
+ (65, "Checking connection"),
207
+ (80, "Verifying endpoints"),
208
+ (95, "Almost ready"),
209
+ (100, "Connected"),
210
+ ]
211
+
212
+ def _show_server_loading(quiet: bool = False):
213
+ """Show a loading animation while the Fomoji server starts up."""
214
+ if quiet:
215
+ return lambda: None
216
+ try:
217
+ from . import theme
218
+ theme.enable_windows_ansi()
219
+ is_themed = True
220
+ except Exception:
221
+ is_themed = False
222
+
223
+ _frame_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
224
+ _state = {"frame": 0, "step": 0, "running": True}
225
+
226
+ def _tick():
227
+ if not _state["running"]:
228
+ return
229
+ ch = _frame_chars[_state["frame"] % len(_frame_chars)]
230
+ _state["frame"] += 1
231
+ step_idx = min(_state["step"], len(_SERVER_LOADING_STEPS) - 1)
232
+ pct, label = _SERVER_LOADING_STEPS[step_idx]
233
+ bar_width = 24
234
+ filled = int(bar_width * pct / 100)
235
+ bar = "█" * filled + "░" * (bar_width - filled)
236
+ if is_themed:
237
+ print(
238
+ f"\r {theme.cyan(ch)} {theme.text(label + '...'):40s} "
239
+ f"{theme.dim('[')}{theme.text(bar)}{theme.dim(']')} "
240
+ f"{theme.text(str(pct) + '%')}",
241
+ end="", flush=True,
242
+ )
243
+ else:
244
+ print(
245
+ f"\r {ch} {label:40s} [{bar}] {pct}%",
246
+ end="", flush=True,
247
+ )
248
+
249
+ def _advance(msg: str = ""):
250
+ _state["step"] = min(_state["step"] + 1, len(_SERVER_LOADING_STEPS) - 1)
251
+ _tick()
252
+
253
+ def _finish(success: bool = True, msg: str = ""):
254
+ _state["running"] = False
255
+ if is_themed:
256
+ if success:
257
+ print(f"\r {theme.green('✓')} {'Server ready':40s} [{'█' * 24}] 100% ")
258
+ else:
259
+ print(f"\r {theme.red('✗')} {'Server failed to start':40s} ")
260
+ else:
261
+ sym = "OK" if success else "FAIL"
262
+ print(f"\r {sym} {'Server ready' if success else 'Failed'}")
263
+
264
+ return _advance, _finish
265
+
266
+
267
+ def ensure_fomoji_server(auto_start: bool = True, timeout: int = 18) -> bool:
268
+ """Ensure Fomoji server is reachable. If not and auto_start is True,
269
+ try to spawn `npm start` / `node src/server.js` from the located
270
+ server dir and wait for it to become reachable.
271
+
272
+ Returns True if reachable (already or after auto-start), False otherwise.
273
+ Never raises."""
274
+ if check_server_reachable(timeout=3):
275
+ return True
276
+ if not auto_start:
277
+ return False
278
+ env_url = os.environ.get("FOMOJI_URL", "").strip()
279
+ if env_url and "localhost" not in env_url and "127.0.0.1" not in env_url:
280
+ return False
281
+ server_dir = _find_fomoji_server_dir()
282
+ if not server_dir:
283
+ return False
284
+ marker = Path.home() / ".cat_fomoji_autostart.pid"
285
+ try:
286
+ if marker.exists():
287
+ try:
288
+ age = time.time() - marker.stat().st_mtime
289
+ if age < 30 and check_server_reachable(timeout=2):
290
+ return True
291
+ if age < 10:
292
+ advance, finish = _show_server_loading()
293
+ advance()
294
+ for _ in range(6):
295
+ time.sleep(1)
296
+ advance()
297
+ if check_server_reachable(timeout=2):
298
+ finish(True)
299
+ return True
300
+ finish(False)
301
+ return False
302
+ except Exception:
303
+ pass
304
+ except Exception:
305
+ pass
306
+ try:
307
+ import subprocess
308
+ import shutil
309
+ try:
310
+ (server_dir / "data").mkdir(exist_ok=True)
311
+ except Exception:
312
+ pass
313
+ node = shutil.which("node")
314
+ npm = shutil.which("npm")
315
+ cmd = None
316
+ # Prefer `node` directly over `npm` to avoid Windows popup windows.
317
+ # npm spawns cmd.exe which shows a console even with hidden flags.
318
+ if node:
319
+ if (server_dir / "src" / "server.js").exists():
320
+ cmd = [node, "src/server.js"]
321
+ elif (server_dir / "server.js").exists():
322
+ cmd = [node, "server.js"]
323
+ if not cmd and npm and (server_dir / "package.json").exists():
324
+ cmd = [npm, "start", "--silent"]
325
+ if not cmd:
326
+ return False
327
+ log_file = Path.home() / ".cat_fomoji_server.log"
328
+ try:
329
+ lf = open(log_file, "ab")
330
+ except Exception:
331
+ lf = subprocess.DEVNULL # type: ignore
332
+ kwargs: Dict[str, Any] = {"cwd": str(server_dir), "stdout": lf, "stderr": lf}
333
+ if sys.platform == "win32":
334
+ si = subprocess.STARTUPINFO()
335
+ si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
336
+ si.wShowWindow = 0 # SW_HIDE
337
+ kwargs["startupinfo"] = si
338
+ # CREATE_NO_WINDOW alone prevents any console popup.
339
+ # DETACHED_PROCESS + CREATE_NO_WINDOW can conflict and show
340
+ # a brief flash — use only CREATE_NO_WINDOW for clean background.
341
+ kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
342
+ else:
343
+ kwargs["start_new_session"] = True
344
+ try:
345
+ subprocess.Popen(cmd, **kwargs) # type: ignore
346
+ except Exception:
347
+ try:
348
+ lf.close() # type: ignore
349
+ except Exception:
350
+ pass
351
+ return False
352
+ try:
353
+ marker.write_text(str(time.time()))
354
+ except Exception:
355
+ pass
356
+ advance, finish = _show_server_loading()
357
+ advance()
358
+ for i in range(timeout):
359
+ time.sleep(1)
360
+ advance()
361
+ if check_server_reachable(timeout=2):
362
+ finish(True)
363
+ return True
364
+ finish(False)
365
+ return False
366
+ except Exception:
367
+ return False
368
+
369
+
370
+ def check_server_reachable(timeout: int = 4) -> bool:
371
+ """Best-effort liveness probe — hits a public endpoint, never auth."""
372
+ base = get_fomoji_url()
373
+ url = f"{base}/api/connector/applications"
374
+ req = urllib.request.Request(url, method="GET")
375
+ req.add_header("Content-Type", "application/json")
376
+ try:
377
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
378
+ return 200 <= resp.status < 400
379
+ except Exception:
380
+ return False
381
+
382
+
383
+ def status() -> str:
384
+ """
385
+ Returns: 'connected' | 'not_connected' | 'expired' | 'server_unreachable'
386
+ Never raises just because there is no local connection yet.
387
+ """
388
+ local = _load_local()
389
+ if not local or not local.get("token"):
390
+ return "not_connected"
391
+ token = local["token"]
392
+ try:
393
+ result = _request(
394
+ "GET",
395
+ "/api/connector/status",
396
+ auth_token=token,
397
+ query={"applicationId": APPLICATION_ID},
398
+ timeout=8,
399
+ )
400
+ except FomojiAuthError as e:
401
+ msg = str(e)
402
+ if "Could not reach Fomoji" in msg:
403
+ return "server_unreachable"
404
+ return "not_connected"
405
+ s = result.get("status", "not_connected")
406
+ if s != "connected":
407
+ # Token is stale — clean up so the next launch re-prompts instead
408
+ # of looping on an expired token forever.
409
+ if s == "expired":
410
+ _clear_local()
411
+ return "expired"
412
+ # not_connected — also clear stale file
413
+ if s == "not_connected":
414
+ # Only clear if we had a token but server says not connected
415
+ # (could be revoked server-side).
416
+ pass
417
+ return s
418
+ return "connected"
419
+
420
+
421
+ def is_authenticated() -> bool:
422
+ return status() == "connected"
423
+
424
+
425
+ def get_identity() -> Optional[Dict[str, Any]]:
426
+ local = _load_local()
427
+ if not local:
428
+ return None
429
+ if status() != "connected":
430
+ return None
431
+ return local.get("identity")
432
+
433
+
434
+ def get_identity_display() -> str:
435
+ ident = get_identity()
436
+ if not ident:
437
+ return "Unknown (not connected)"
438
+ name = ident.get("name", "?")
439
+ fid = ident.get("fomojiId", "?")
440
+ itype = ident.get("identityType", "PERSON")
441
+ return f"{name} ({fid}) [{itype}]"
442
+
443
+
444
+ # ---------------------------------------------------------------------------
445
+ # Device flow — the ONLY way a non-browser client pairs.
446
+ # ---------------------------------------------------------------------------
447
+
448
+ def _device_start(permissions) -> Dict[str, Any]:
449
+ return _request(
450
+ "POST",
451
+ "/api/connector/device/start",
452
+ {
453
+ "applicationId": APPLICATION_ID,
454
+ "permissions": permissions or DEFAULT_PERMISSIONS,
455
+ "connectionType": "standard",
456
+ "environment": "production",
457
+ },
458
+ )
459
+
460
+
461
+ def _device_poll(device_code: str) -> Dict[str, Any]:
462
+ return _request("POST", "/api/connector/device/poll", {"deviceCode": device_code})
463
+
464
+
465
+ def _launch_cat_browser_for_verification(verification_url: str, user_code: str) -> None:
466
+ """Show the verification code in the terminal.
467
+
468
+ Browser navigation is handled by bootstrap() — this function only
469
+ prints the code prompt so the user can see it. The CAT browser
470
+ (QWebEngineView) is already open and will navigate to the
471
+ verification page automatically.
472
+ """
473
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
474
+ return
475
+ try:
476
+ print()
477
+ try:
478
+ from . import theme
479
+ theme.enable_windows_ansi()
480
+ print(theme.dim(" ┌─────────────────────────────────────────────────────────┐"))
481
+ print(theme.dim(" │ FOMOJI VERIFICATION (CAT Browser) │"))
482
+ print(theme.dim(" └─────────────────────────────────────────────────────────┘"))
483
+ print()
484
+ print(theme.cyan(f" CAT Browser is opening the verification page"))
485
+ print(theme.dim(f" (URL hidden for security)"))
486
+ print()
487
+ print(theme.cyan(f" Your code:"))
488
+ print(theme.text(f" {user_code}", bold=True))
489
+ print()
490
+ print(theme.faint(" Approve the request in CAT's browser, then return here."))
491
+ print(theme.faint(" CAT will detect approval automatically."))
492
+ except Exception:
493
+ print(f"\n FOMOJI VERIFICATION (CAT Browser)")
494
+ print(f" Code: {user_code}")
495
+ print(f" Approve in CAT's browser, then return here.")
496
+ except Exception:
497
+ pass
498
+
499
+
500
+ def device_login(
501
+ permissions=None,
502
+ timeout_seconds: int = 300,
503
+ on_prompt=None,
504
+ ) -> Dict[str, Any]:
505
+ """
506
+ Run the full device-authorization flow:
507
+ 1. POST /device/start -> device_code, user_code, verificationUrl
508
+ 2. Show user_code + verificationUrl for human approval in browser
509
+ 3. Poll /device/poll until approved / denied / expired
510
+
511
+ Returns the identity dict on success and caches the token locally.
512
+ Raises FomojiAuthError on any terminal failure.
513
+ """
514
+ perms = permissions if permissions is not None else DEFAULT_PERMISSIONS
515
+ # Ensure server is up before starting (auto-start if needed)
516
+ ensure_fomoji_server(auto_start=True, timeout=12)
517
+ start = _device_start(perms)
518
+ device_code = start["deviceCode"]
519
+ user_code = start["userCode"]
520
+ verification_url = f"{get_fomoji_url()}{start.get('verificationUrl', '/connector.html')}"
521
+ poll_interval = int(start.get("pollIntervalSeconds", 3))
522
+
523
+ if on_prompt:
524
+ try:
525
+ on_prompt(user_code=user_code, verification_url=verification_url)
526
+ except Exception:
527
+ pass
528
+ else:
529
+ _print_device_prompt(user_code, verification_url)
530
+ # Offer to handle verification inside CAT's own browser (no external browser needed)
531
+ # Only when interactive and polling hasn't yet begun, and user hasn't supplied custom on_prompt.
532
+ if sys.stdin.isatty() and sys.stdout.isatty():
533
+ try:
534
+ _launch_cat_browser_for_verification(verification_url, user_code)
535
+ except Exception:
536
+ pass
537
+
538
+ deadline = time.monotonic() + timeout_seconds
539
+ while time.monotonic() < deadline:
540
+ result = _device_poll(device_code)
541
+ st = result.get("status")
542
+ if st == "approved":
543
+ identity = {
544
+ "fomojiId": result.get("fomojiId"),
545
+ "name": result.get("name"),
546
+ "identityType": result.get("identityType", "PERSON"),
547
+ "permissions": result.get("permissions", perms),
548
+ "applicationId": result.get("applicationId", APPLICATION_ID),
549
+ }
550
+ _save_local({"token": result["connectorToken"], "identity": identity})
551
+ return identity
552
+ if st == "denied":
553
+ raise FomojiAuthError("Connection request was denied in Fomoji.")
554
+ if st == "expired":
555
+ raise FomojiAuthError("Connection request expired before approval (5 min). Run again.")
556
+ # pending — wait and retry
557
+ time.sleep(poll_interval)
558
+
559
+ raise FomojiAuthError("Timed out waiting for approval (no response in time).")
560
+
561
+
562
+ def _print_device_prompt(user_code: str, verification_url: str) -> None:
563
+ """Show the connector code in terminal with easy copy + Windows notification."""
564
+ import subprocess
565
+
566
+ # --- Step 1: Print code in terminal FIRST (always visible) ---
567
+ try:
568
+ from . import theme
569
+ theme.enable_windows_ansi()
570
+ print()
571
+ print(theme.panel([
572
+ theme.text("CAT CONNECTOR CODE", bold=True),
573
+ "",
574
+ theme.text(f" {user_code} ", bold=True),
575
+ "",
576
+ theme.dim(" 1. Log in or create an account on Fomoji"),
577
+ theme.dim(" 2. Paste this code on the Connect page"),
578
+ theme.dim(" 3. Click Approve"),
579
+ "",
580
+ theme.faint(" Waiting for approval... (Ctrl+C to cancel)"),
581
+ ], title="fomoji auth", color=theme.CYAN, width=60))
582
+ print()
583
+ except Exception:
584
+ print()
585
+ print("=" * 50)
586
+ print(" CAT CONNECTOR CODE")
587
+ print()
588
+ print(f" >>> {user_code} <<<")
589
+ print()
590
+ print(" 1. Log in or create an account on Fomoji")
591
+ print(" 2. Paste this code on the Connect page")
592
+ print(" 3. Click Approve")
593
+ print("=" * 50)
594
+ print()
595
+
596
+ # --- Step 2: Copy to clipboard (background, non-blocking) ---
597
+ def _copy_to_clipboard():
598
+ # Method 1: pyperclip (most reliable)
599
+ try:
600
+ import pyperclip
601
+ pyperclip.copy(user_code)
602
+ return
603
+ except Exception:
604
+ pass
605
+ # Method 2: powershell Set-Clipboard
606
+ try:
607
+ subprocess.run(
608
+ ["powershell", "-Command", f'Set-Clipboard -Value "{user_code}"'],
609
+ capture_output=True, timeout=5,
610
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)
611
+ )
612
+ return
613
+ except Exception:
614
+ pass
615
+ # Method 3: clip.exe
616
+ try:
617
+ p = subprocess.Popen(
618
+ ["clip"],
619
+ stdin=subprocess.PIPE,
620
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)
621
+ )
622
+ p.communicate(input=user_code.encode("utf-16le"), timeout=5)
623
+ except Exception:
624
+ pass
625
+
626
+ import threading
627
+ threading.Thread(target=_copy_to_clipboard, daemon=True).start()
628
+
629
+ # --- Step 3: Show Windows notification (background, non-blocking) ---
630
+ if sys.platform == "win32":
631
+ def _show_notification():
632
+ # Write a .ps1 file to avoid PowerShell escaping issues
633
+ try:
634
+ import tempfile as _tf
635
+ _code = user_code
636
+ _ps1 = _tf.NamedTemporaryFile(
637
+ suffix=".ps1", prefix="cat_notify_", delete=False, mode="w",
638
+ encoding="utf-8"
639
+ )
640
+ _ps1.write('Add-Type -AssemblyName System.Windows.Forms\n')
641
+ _ps1.write('$n = New-Object System.Windows.Forms.NotifyIcon\n')
642
+ _ps1.write('$n.Icon = [System.Drawing.SystemIcons]::Information\n')
643
+ _ps1.write('$n.BalloonTipTitle = "CAT - Your Connector Code"\n')
644
+ _ps1.write('$n.BalloonTipText = "' + _code + '"\n')
645
+ _ps1.write('$n.Visible = $true\n')
646
+ _ps1.write('$n.ShowBalloonTip(10000)\n')
647
+ _ps1.write('Start-Sleep -Seconds 3\n')
648
+ _ps1.close()
649
+ subprocess.run(
650
+ ["powershell", "-ExecutionPolicy", "Bypass", "-File", _ps1.name],
651
+ capture_output=True, timeout=15,
652
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)
653
+ )
654
+ try:
655
+ os.unlink(_ps1.name)
656
+ except Exception:
657
+ pass
658
+ except Exception:
659
+ pass
660
+
661
+ threading.Thread(target=_show_notification, daemon=True).start()
662
+
663
+
664
+ def stop_fomoji_server() -> bool:
665
+ """Best-effort stop the auto-started Node.js Fomoji server.
666
+ Called on sign-out so the background node process does not linger.
667
+ Returns True if we attempted a stop."""
668
+ stopped = False
669
+ # 1) Try graceful port-based kill via marker + common ports
670
+ try:
671
+ import subprocess as _sp
672
+ import shutil as _sh
673
+ # Try to find node process running fomoji server
674
+ if sys.platform == "win32":
675
+ # taskkill any node running server.js
676
+ for cmd in [
677
+ 'taskkill /F /IM node.exe 2>nul',
678
+ 'wmic process where "commandline like \'%fomoji%server.js%\'" delete 2>nul',
679
+ ]:
680
+ try:
681
+ _sp.run(cmd, shell=True, timeout=4, stdout=_sp.DEVNULL, stderr=_sp.DEVNULL,
682
+ creationflags=getattr(_sp, "CREATE_NO_WINDOW", 0))
683
+ stopped = True
684
+ except Exception:
685
+ pass
686
+ # Also try to kill by port 3000 via netstat
687
+ try:
688
+ out = _sp.check_output('netstat -ano | findstr :3000', shell=True, timeout=4, stderr=_sp.DEVNULL, text=True)
689
+ for line in out.splitlines():
690
+ parts = line.strip().split()
691
+ if parts:
692
+ pid = parts[-1]
693
+ if pid.isdigit():
694
+ _sp.run(f'taskkill /F /PID {pid}', shell=True, timeout=3, stdout=_sp.DEVNULL, stderr=_sp.DEVNULL,
695
+ creationflags=getattr(_sp, "CREATE_NO_WINDOW", 0))
696
+ stopped = True
697
+ except Exception:
698
+ pass
699
+ else:
700
+ # Unix: pkill / kill
701
+ for kcmd in [['pkill', '-f', 'fomoji.*server.js'], ['pkill', '-f', 'node.*server.js']]:
702
+ try:
703
+ if _sh.which(kcmd[0]):
704
+ _sp.run(kcmd, timeout=4, stdout=_sp.DEVNULL, stderr=_sp.DEVNULL)
705
+ stopped = True
706
+ except Exception:
707
+ pass
708
+ # Also try fuser on port 3000
709
+ try:
710
+ if _sh.which('fuser'):
711
+ _sp.run(['fuser', '-k', '3000/tcp'], timeout=4, stdout=_sp.DEVNULL, stderr=_sp.DEVNULL)
712
+ stopped = True
713
+ except Exception:
714
+ pass
715
+ except Exception:
716
+ pass
717
+ # Clean marker/log regardless
718
+ try:
719
+ (Path.home() / ".cat_fomoji_autostart.pid").unlink(missing_ok=True)
720
+ except Exception:
721
+ try:
722
+ (Path.home() / ".cat_fomoji_autostart.pid").unlink()
723
+ except Exception:
724
+ pass
725
+ try:
726
+ (Path.home() / ".cat_fomoji_server.log").unlink(missing_ok=True)
727
+ except Exception:
728
+ try:
729
+ (Path.home() / ".cat_fomoji_server.log").unlink()
730
+ except Exception:
731
+ pass
732
+ return stopped
733
+
734
+
735
+ def logout(clear_local_only: bool = False) -> None:
736
+ """
737
+ Revoke server-side and clear local. If the server is unreachable,
738
+ still clears locally (so the user is not stuck).
739
+ """
740
+ local = _load_local()
741
+ if local and local.get("token") and not clear_local_only:
742
+ try:
743
+ _request(
744
+ "POST",
745
+ "/api/connector/disconnect",
746
+ {"applicationId": APPLICATION_ID},
747
+ auth_token=local["token"],
748
+ )
749
+ except FomojiAuthError:
750
+ pass # already revoked / server down — still clear locally
751
+ _clear_local()
752
+ # Also stop background Node.js server so it does not linger after sign-out
753
+ try:
754
+ stop_fomoji_server()
755
+ except Exception:
756
+ pass
757
+
758
+
759
+ # ---------------------------------------------------------------------------
760
+ # Gate — called by cli.main() before ANY UI/REPL/business logic runs.
761
+ # ---------------------------------------------------------------------------
762
+
763
+ def _print_locked_message(reason: str = "") -> None:
764
+ base = get_fomoji_url()
765
+ try:
766
+ from . import theme
767
+ theme.enable_windows_ansi()
768
+ lines = [
769
+ theme.red("CAT IS LOCKED — Fomoji authentication required", bold=True),
770
+ "",
771
+ theme.dim("No valid Fomoji session was found for this device."),
772
+ theme.dim(f"Fomoji server: {base}"),
773
+ ]
774
+ if reason:
775
+ lines.append(theme.orange(f"Reason: {reason}"))
776
+ lines += [
777
+ "",
778
+ theme.text("To authenticate:", bold=True),
779
+ theme.cyan(f" 1. Ensure Fomoji server is running at {base}"),
780
+ theme.dim(" (in fomoji-updated/fomoji-server: npm install && npm start)"),
781
+ theme.cyan(" 2. Run: cat --auth login"),
782
+ theme.cyan(" or: python -m calc_terminal.fomoji_auth login"),
783
+ theme.dim(" 3. Open the shown URL, enter the code, and approve"),
784
+ "",
785
+ theme.faint("After approval, run `cat` again. Your session is cached at:"),
786
+ theme.faint(f" {TOKEN_PATH}"),
787
+ "",
788
+ theme.faint("Tip: `cat --auth status` checks the current session."),
789
+ ]
790
+ print()
791
+ print(theme.panel(lines, title="locked", color=theme.RED, width=76))
792
+ print()
793
+ except Exception:
794
+ print("\n[CAT LOCKED] Fomoji authentication required.", file=sys.stderr)
795
+ if reason:
796
+ print(f"Reason: {reason}", file=sys.stderr)
797
+ print(f"Fomoji server: {base}", file=sys.stderr)
798
+ print("Run: cat --auth login (then open the URL and approve)\n", file=sys.stderr)
799
+
800
+
801
+ def _print_unreachable_help() -> None:
802
+ base = get_fomoji_url()
803
+ try:
804
+ from . import theme
805
+ lines = [
806
+ theme.red("FOMOJI SERVER UNREACHABLE", bold=True),
807
+ theme.dim("Could not reach the Fomoji server"),
808
+ "",
809
+ theme.text("Start the Fomoji server first:", bold=True),
810
+ theme.dim(" cd fomoji-updated/fomoji-server"),
811
+ theme.dim(" npm install"),
812
+ theme.dim(" npm start"),
813
+ "",
814
+ theme.faint("CAT will try to start the server automatically on next launch."),
815
+ ]
816
+ print(theme.panel(lines, title="fomoji", color=theme.RED, width=72))
817
+ print()
818
+ except Exception:
819
+ print(f"\n[FOMOJI] Could not reach the Fomoji server.", file=sys.stderr)
820
+ print("Start it: cd fomoji-updated/fomoji-server && npm start\n", file=sys.stderr)
821
+
822
+
823
+ def require_auth(
824
+ interactive: bool = True,
825
+ auto_prompt: bool = True,
826
+ permissions=None,
827
+ ) -> bool:
828
+ """
829
+ Ensure a valid Fomoji session exists. Returns True if authenticated.
830
+
831
+ If no session exists and `auto_prompt` is True, this runs the device flow
832
+ interactively (requires a TTY). When that succeeds it returns True.
833
+ When it cannot (no TTY, user cancelled, server unreachable), it returns
834
+ False — the caller should refuse to start CAT.
835
+
836
+ When `auto_prompt` is False it only *checks* and never prompts.
837
+
838
+ Persistent session: once `~/.fomoji/connectors/cat.json` is written, every
839
+ future `cat` launch reuses it (status()==connected) and never asks for a
840
+ code again until the server revokes/expires it.
841
+ """
842
+ if is_skip_enabled():
843
+ return True
844
+
845
+ # Auto-start server if needed (so `cat` never requires manual `npm start`)
846
+ # This is best-effort and respects FOMOJI_URL remote overrides.
847
+ had_to_start = False
848
+ if not check_server_reachable(timeout=2):
849
+ had_to_start = ensure_fomoji_server(auto_start=True, timeout=10)
850
+ if had_to_start and check_server_reachable(timeout=2):
851
+ try:
852
+ from . import theme
853
+ theme.enable_windows_ansi()
854
+ print(theme.green(f"\n ✓ Fomoji server auto-started at {get_fomoji_url()}", bold=True))
855
+ print(theme.dim(" (npm start in fomoji-updated/fomoji-server)"))
856
+ except Exception:
857
+ print(f"\n Fomoji server auto-started at {get_fomoji_url()}")
858
+ # Give it a moment to finish seeding DB
859
+ import time as _t
860
+ _t.sleep(0.5)
861
+
862
+ st = status()
863
+
864
+ if st == "connected":
865
+ if had_to_start:
866
+ try:
867
+ from . import theme as _th
868
+ print(_th.dim(" Session still valid — no code needed.\n"))
869
+ except Exception:
870
+ pass
871
+ return True
872
+
873
+ if st == "server_unreachable":
874
+ # One more auto-start attempt with longer timeout before giving up
875
+ if ensure_fomoji_server(auto_start=True, timeout=15) and check_server_reachable():
876
+ st = status()
877
+ if st == "connected":
878
+ return True
879
+ _print_unreachable_help()
880
+ return False
881
+
882
+ if st == "expired":
883
+ print("\n Your Fomoji session has expired. Re-authenticating...\n")
884
+
885
+ if not interactive or not sys.stdin.isatty():
886
+ _print_locked_message(reason=f"status={st} (non-interactive — run `cat --auth login` in a real terminal)")
887
+ return False
888
+
889
+ if not auto_prompt:
890
+ _print_locked_message(reason=f"status={st}")
891
+ return False
892
+
893
+ # Interactive device flow — ensure server still reachable (auto-start race)
894
+ if not check_server_reachable():
895
+ if not ensure_fomoji_server(auto_start=True, timeout=12) or not check_server_reachable():
896
+ _print_unreachable_help()
897
+ return False
898
+
899
+ try:
900
+ ident = device_login(permissions=permissions)
901
+ try:
902
+ from . import theme
903
+ print(theme.green(f"\n ✓ Authenticated as {ident.get('name')} ({ident.get('fomojiId')})", bold=True))
904
+ print(theme.dim(f" Permissions: {', '.join(ident.get('permissions', []))}"))
905
+ print(theme.green(" Session cached at ~/.fomoji/connectors/cat.json — next `cat` will start instantly, no code needed.", bold=True))
906
+ print()
907
+ except Exception:
908
+ print(f"\n Authenticated as {ident.get('name')} ({ident.get('fomojiId')})")
909
+ print(" Session cached — next `cat` will start instantly, no code needed.\n")
910
+ return True
911
+ except KeyboardInterrupt:
912
+ print("\n Authentication cancelled.\n")
913
+ return False
914
+ except FomojiAuthError as e:
915
+ _print_locked_message(reason=str(e))
916
+ return False
917
+ except Exception as e:
918
+ _print_locked_message(reason=f"{type(e).__name__}: {e}")
919
+ return False
920
+
921
+
922
+ def enforce_or_exit(
923
+ interactive: bool = True,
924
+ auto_prompt: bool = True,
925
+ permissions=None,
926
+ ) -> None:
927
+ """
928
+ Gate helper for cli.main(): if auth is required and missing, print a
929
+ clear locked message and exit the process with code 1. Does NOT return
930
+ on failure — the process never reaches bootstrap().
931
+ """
932
+ if require_auth(interactive=interactive, auto_prompt=auto_prompt, permissions=permissions):
933
+ return
934
+ # If skip is enabled we already returned True above, so reaching here
935
+ # means real enforcement. Exit hard — no fallback path.
936
+ raise SystemExit(1)
937
+
938
+
939
+ # ---------------------------------------------------------------------------
940
+ # CLI for `python -m calc_terminal.fomoji_auth` and `cat --auth ...`
941
+ # ---------------------------------------------------------------------------
942
+
943
+ def _auth_cli(argv=None) -> int:
944
+ """
945
+ Standalone auth CLI. Returns exit code.
946
+
947
+ Usage:
948
+ cat --auth status
949
+ cat --auth login [--permissions ID,PROFILE,...]
950
+ cat --auth logout
951
+ cat --auth whoami
952
+ python -m calc_terminal.fomoji_auth status|login|logout|whoami
953
+ """
954
+ import argparse
955
+
956
+ argv = list(argv if argv is not None else sys.argv[1:])
957
+ # Drop a leading --auth if present (so `cat --auth login` and
958
+ # direct `login` both work).
959
+ if argv and argv[0] == "--auth":
960
+ argv = argv[1:]
961
+
962
+ # No args -> status
963
+ if not argv:
964
+ argv = ["status"]
965
+
966
+ action = argv[0].lower() if argv else "status"
967
+ rest = argv[1:]
968
+
969
+ # Parse optional --permissions for login
970
+ perms = None
971
+ if "--permissions" in rest:
972
+ idx = rest.index("--permissions")
973
+ if idx + 1 < len(rest):
974
+ perms = [p.strip() for p in rest[idx + 1].split(",") if p.strip()]
975
+ rest = [a for a in rest if a not in ("--permissions", rest[idx + 1] if idx + 1 < len(rest) else "")]
976
+
977
+ if action in ("status", "check"):
978
+ st = status()
979
+ try:
980
+ from . import theme
981
+ theme.enable_windows_ansi()
982
+ if st == "connected":
983
+ ident = get_identity()
984
+ name = ident.get("name", "?") if ident else "?"
985
+ fid = ident.get("fomojiId", "?") if ident else "?"
986
+ print(theme.green(f" ✓ Connected as {name} ({fid})", bold=True))
987
+ print(theme.dim(f" Status: {st}"))
988
+ print(theme.dim(f" Token: {TOKEN_PATH}"))
989
+ if ident and ident.get("permissions"):
990
+ print(theme.dim(f" Permissions: {', '.join(ident['permissions'])}"))
991
+ elif st == "server_unreachable":
992
+ print(theme.red(f" ✗ Fomoji server unreachable", bold=True))
993
+ print(theme.dim(" Start it: cd fomoji-updated/fomoji-server && npm start"))
994
+ else:
995
+ print(theme.orange(f" ○ Not connected (status: {st})", bold=True))
996
+ print(theme.dim(f" Run: cat --auth login"))
997
+ except Exception:
998
+ print(st)
999
+ return 0 if st == "connected" else 1
1000
+
1001
+ if action in ("login", "connect", "auth"):
1002
+ if is_authenticated():
1003
+ ident = get_identity()
1004
+ name = ident.get("name", "?") if ident else "?"
1005
+ print(f" Already connected as {name}. Use `cat --auth logout` to switch identity.\n")
1006
+ return 0
1007
+ if not check_server_reachable():
1008
+ _print_unreachable_help()
1009
+ return 1
1010
+ try:
1011
+ ident = device_login(permissions=perms)
1012
+ print(f"\n ✓ Connected as {ident.get('name')} ({ident.get('fomojiId')})\n")
1013
+ return 0
1014
+ except KeyboardInterrupt:
1015
+ print("\n Cancelled.\n")
1016
+ return 1
1017
+ except FomojiAuthError as e:
1018
+ print(f"\n ✗ Login failed: {e}\n")
1019
+ return 1
1020
+
1021
+ if action in ("logout", "disconnect", "revoke"):
1022
+ if not _load_local():
1023
+ print(" Not connected — nothing to do.\n")
1024
+ return 0
1025
+ logout()
1026
+ print(" ✓ Disconnected. Run `cat --auth login` to sign in again.\n")
1027
+ return 0
1028
+
1029
+ if action in ("whoami", "identity", "me"):
1030
+ ident = get_identity()
1031
+ if not ident:
1032
+ print(f" Not connected (status: {status()}). Run `cat --auth login`.\n")
1033
+ return 1
1034
+ print(json.dumps(ident, indent=2))
1035
+ return 0
1036
+
1037
+ print(f" Unknown auth action: {action}")
1038
+ print(" Usage: cat --auth <status|login|logout|whoami>")
1039
+ return 2
1040
+
1041
+
1042
+ if __name__ == "__main__":
1043
+ raise SystemExit(_auth_cli())