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.
- calc_terminal/__init__.py +14 -0
- calc_terminal/__main__.py +14 -0
- calc_terminal/activity.py +1334 -0
- calc_terminal/agent.py +3387 -0
- calc_terminal/agent_runtime.py +519 -0
- calc_terminal/ai_context.py +447 -0
- calc_terminal/ai_modes.py +752 -0
- calc_terminal/ai_personalization.py +286 -0
- calc_terminal/ai_preview_feedback.py +213 -0
- calc_terminal/aicore.py +2572 -0
- calc_terminal/anim.py +367 -0
- calc_terminal/app.py +3685 -0
- calc_terminal/art.py +639 -0
- calc_terminal/atomsim.py +368 -0
- calc_terminal/attachments.py +743 -0
- calc_terminal/benchmark_system.py +414 -0
- calc_terminal/browser/__init__.py +36 -0
- calc_terminal/browser/browser_state.py +346 -0
- calc_terminal/browser/devserver.py +176 -0
- calc_terminal/browser/engine.py +494 -0
- calc_terminal/browser/navigation.py +84 -0
- calc_terminal/browser/preview.py +429 -0
- calc_terminal/browser/preview_entry.py +95 -0
- calc_terminal/browser/project_detector.py +144 -0
- calc_terminal/browser/server.py +449 -0
- calc_terminal/browser/state.py +75 -0
- calc_terminal/browser/watcher.py +99 -0
- calc_terminal/browser_gui/__init__.py +1 -0
- calc_terminal/browser_gui/__main__.py +3 -0
- calc_terminal/browser_gui/launcher.py +173 -0
- calc_terminal/browser_gui/playwright_browser.py +117 -0
- calc_terminal/browser_gui/qt_browser.py +1501 -0
- calc_terminal/browser_gui/webview_browser.py +57 -0
- calc_terminal/capabilities/__init__.py +35 -0
- calc_terminal/capabilities/adapters/__init__.py +33 -0
- calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
- calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
- calc_terminal/capabilities/adapters/filesystem.py +206 -0
- calc_terminal/capabilities/adapters/git_adapter.py +202 -0
- calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
- calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
- calc_terminal/capabilities/adapters/platforms.py +200 -0
- calc_terminal/capabilities/adapters/python_exec.py +93 -0
- calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
- calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
- calc_terminal/capabilities/adapters/structural_bio.py +161 -0
- calc_terminal/capabilities/adapters/terminal.py +99 -0
- calc_terminal/capabilities/bus.py +178 -0
- calc_terminal/capabilities/discovery.py +207 -0
- calc_terminal/capabilities/schema.py +221 -0
- calc_terminal/cat.ico +0 -0
- calc_terminal/cat_browser.py +2018 -0
- calc_terminal/chat_store.py +703 -0
- calc_terminal/cli.py +1178 -0
- calc_terminal/code_editor.py +640 -0
- calc_terminal/collaboration.py +723 -0
- calc_terminal/commands_data.py +139 -0
- calc_terminal/compatibility_engine.py +352 -0
- calc_terminal/compute/__init__.py +31 -0
- calc_terminal/compute/fabric.py +350 -0
- calc_terminal/config.py +227 -0
- calc_terminal/core/__init__.py +41 -0
- calc_terminal/core/checkpoint.py +156 -0
- calc_terminal/core/input/__init__.py +45 -0
- calc_terminal/core/mode_registry.py +300 -0
- calc_terminal/core/project_graph.py +172 -0
- calc_terminal/core/recovery.py +129 -0
- calc_terminal/core/security_layer.py +112 -0
- calc_terminal/core/task_graph.py +202 -0
- calc_terminal/core/unified_runtime.py +184 -0
- calc_terminal/core/verification.py +257 -0
- calc_terminal/customization.py +1566 -0
- calc_terminal/derivations.py +153 -0
- calc_terminal/device_control.py +263 -0
- calc_terminal/diagnostics/__init__.py +27 -0
- calc_terminal/diagnostics/doctor_engine.py +382 -0
- calc_terminal/diagnostics/self_test.py +247 -0
- calc_terminal/doctor.py +519 -0
- calc_terminal/easter_eggs.py +274 -0
- calc_terminal/editor/__init__.py +1 -0
- calc_terminal/editor/actions.py +263 -0
- calc_terminal/editor/commands.py +160 -0
- calc_terminal/editor/shortcuts.py +226 -0
- calc_terminal/engine.py +259 -0
- calc_terminal/errors.py +120 -0
- calc_terminal/event_stream.py +146 -0
- calc_terminal/eventbus.py +133 -0
- calc_terminal/extensions.py +733 -0
- calc_terminal/fallback_cli.py +1321 -0
- calc_terminal/first_run.py +265 -0
- calc_terminal/fomoji_auth.py +1043 -0
- calc_terminal/formulas.py +82 -0
- calc_terminal/fs_cache.py +121 -0
- calc_terminal/fs_watcher.py +277 -0
- calc_terminal/game.py +193 -0
- calc_terminal/gen1.py +5 -0
- calc_terminal/generators.py +245 -0
- calc_terminal/gestures/__init__.py +42 -0
- calc_terminal/gestures/bindings.py +175 -0
- calc_terminal/gestures/manager.py +477 -0
- calc_terminal/goodbye.py +363 -0
- calc_terminal/gpu3d.py +290 -0
- calc_terminal/graphs.py +358 -0
- calc_terminal/hardware_analyzer.py +440 -0
- calc_terminal/host/__init__.py +30 -0
- calc_terminal/host/browser_manager.py +187 -0
- calc_terminal/host/desktop.py +1386 -0
- calc_terminal/host/launcher.py +395 -0
- calc_terminal/host/terminal.py +279 -0
- calc_terminal/identity.py +216 -0
- calc_terminal/input/__init__.py +54 -0
- calc_terminal/input/capabilities.py +258 -0
- calc_terminal/input/focus.py +87 -0
- calc_terminal/input/gestures.py +64 -0
- calc_terminal/input/pointer.py +114 -0
- calc_terminal/input/touch.py +345 -0
- calc_terminal/keys.py +84 -0
- calc_terminal/live_automation.py +165 -0
- calc_terminal/mathtext.py +433 -0
- calc_terminal/mcp.py +386 -0
- calc_terminal/memory.py +337 -0
- calc_terminal/memory_v2.py +479 -0
- calc_terminal/metrics.py +333 -0
- calc_terminal/mode_detection.py +146 -0
- calc_terminal/model.py +2431 -0
- calc_terminal/model_router.py +665 -0
- calc_terminal/models/__init__.py +0 -0
- calc_terminal/models/active_state.py +187 -0
- calc_terminal/models/dynamic_registry.py +584 -0
- calc_terminal/models/manager.py +781 -0
- calc_terminal/models/model_metadata.json +3526 -0
- calc_terminal/models/profiles.py +194 -0
- calc_terminal/models/registry.py +265 -0
- calc_terminal/models/schema.py +197 -0
- calc_terminal/models/validator.py +287 -0
- calc_terminal/models/verification_engine.py +368 -0
- calc_terminal/native_picker.py +215 -0
- calc_terminal/ollama_catalog.py +279 -0
- calc_terminal/ollama_download.py +233 -0
- calc_terminal/orchestrator.py +304 -0
- calc_terminal/package_research.py +322 -0
- calc_terminal/packages.py +1024 -0
- calc_terminal/pc_specs.py +116 -0
- calc_terminal/permissions.py +334 -0
- calc_terminal/pet.py +106 -0
- calc_terminal/pipeline.py +505 -0
- calc_terminal/platform/__init__.py +491 -0
- calc_terminal/platform/desktop.py +491 -0
- calc_terminal/platform/web.py +781 -0
- calc_terminal/preview/__init__.py +1 -0
- calc_terminal/preview/dev_server.py +303 -0
- calc_terminal/preview/diagnostics.py +131 -0
- calc_terminal/preview/live_reload.py +66 -0
- calc_terminal/preview/manager.py +129 -0
- calc_terminal/project_stats.py +209 -0
- calc_terminal/projects.py +328 -0
- calc_terminal/providers/__init__.py +0 -0
- calc_terminal/providers/adapters/__init__.py +80 -0
- calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
- calc_terminal/providers/adapters/base.py +106 -0
- calc_terminal/providers/adapters/chinese_adapters.py +420 -0
- calc_terminal/providers/adapters/gemini_adapter.py +101 -0
- calc_terminal/providers/adapters/ollama_adapter.py +83 -0
- calc_terminal/providers/adapters/openai_adapter.py +159 -0
- calc_terminal/providers/adapters/other_adapters.py +246 -0
- calc_terminal/providers/anthropic_provider.py +172 -0
- calc_terminal/providers/auto_update.py +416 -0
- calc_terminal/providers/base_provider.py +105 -0
- calc_terminal/providers/discovery_manager.py +207 -0
- calc_terminal/providers/gemini_provider.py +178 -0
- calc_terminal/providers/lifecycle.py +767 -0
- calc_terminal/providers/ollama_adapter.py +707 -0
- calc_terminal/providers/openai_provider.py +254 -0
- calc_terminal/providers/provider_manager.py +1827 -0
- calc_terminal/providers/providers.json +4075 -0
- calc_terminal/reactionsim.py +279 -0
- calc_terminal/registry.py +337 -0
- calc_terminal/report.py +162 -0
- calc_terminal/research/__init__.py +45 -0
- calc_terminal/research/artifact_intel.py +126 -0
- calc_terminal/research/data_lineage.py +123 -0
- calc_terminal/research/experiment_ledger.py +303 -0
- calc_terminal/research/reproducibility.py +131 -0
- calc_terminal/resilience/__init__.py +47 -0
- calc_terminal/resilience/agent_state.py +121 -0
- calc_terminal/resilience/capability_matcher.py +174 -0
- calc_terminal/resilience/circuit_breaker.py +158 -0
- calc_terminal/resilience/failover_engine.py +230 -0
- calc_terminal/resilience/health_monitor.py +192 -0
- calc_terminal/resilience/ollama_adapter.py +125 -0
- calc_terminal/resilience/orchestrator.py +312 -0
- calc_terminal/resilience/types.py +134 -0
- calc_terminal/sandbox.py +98 -0
- calc_terminal/scires.py +558 -0
- calc_terminal/security_scanner.py +126 -0
- calc_terminal/session.py +294 -0
- calc_terminal/sim3d.py +206 -0
- calc_terminal/solver.py +276 -0
- calc_terminal/sound.py +127 -0
- calc_terminal/task_reports.py +287 -0
- calc_terminal/terminal_host.py +201 -0
- calc_terminal/terminal_identity.py +411 -0
- calc_terminal/test_ai_mode_reliability.py +344 -0
- calc_terminal/test_browser.py +368 -0
- calc_terminal/test_code_editor_upgrade.py +485 -0
- calc_terminal/test_customization.py +1148 -0
- calc_terminal/test_customization_ui.py +612 -0
- calc_terminal/test_dynamic_registry.py +304 -0
- calc_terminal/test_extensions.py +436 -0
- calc_terminal/test_overhaul.py +557 -0
- calc_terminal/test_project_detect.py +255 -0
- calc_terminal/test_root_cause_fix.py +527 -0
- calc_terminal/test_stability.py +532 -0
- calc_terminal/test_terminal_identity.py +132 -0
- calc_terminal/test_v079_speed.py +460 -0
- calc_terminal/theme.py +1107 -0
- calc_terminal/timeline.py +139 -0
- calc_terminal/todos.py +246 -0
- calc_terminal/tool_call_normalizer.py +419 -0
- calc_terminal/tui.py +104 -0
- calc_terminal/ui/__init__.py +8 -0
- calc_terminal/ui/activity_panel.py +231 -0
- calc_terminal/ui/activity_stream_panel.py +238 -0
- calc_terminal/ui/animations.py +122 -0
- calc_terminal/ui/app.py +7271 -0
- calc_terminal/ui/attach_panel.py +597 -0
- calc_terminal/ui/attachments.py +424 -0
- calc_terminal/ui/backup_panel.py +810 -0
- calc_terminal/ui/browser_shell.py +887 -0
- calc_terminal/ui/cat_agent.py +357 -0
- calc_terminal/ui/chats_panel.py +899 -0
- calc_terminal/ui/command_palette.py +125 -0
- calc_terminal/ui/command_palette_modal.py +166 -0
- calc_terminal/ui/composer.py +1141 -0
- calc_terminal/ui/context_menu.py +197 -0
- calc_terminal/ui/conversation.py +1435 -0
- calc_terminal/ui/customization_panel.py +1229 -0
- calc_terminal/ui/dashboard.py +404 -0
- calc_terminal/ui/design_system.py +557 -0
- calc_terminal/ui/diff_panel.py +213 -0
- calc_terminal/ui/editor.py +2102 -0
- calc_terminal/ui/empty_state.py +302 -0
- calc_terminal/ui/events.py +487 -0
- calc_terminal/ui/extensions_panel.py +815 -0
- calc_terminal/ui/footer.py +166 -0
- calc_terminal/ui/gestures_panel.py +383 -0
- calc_terminal/ui/goodbye_screen.py +100 -0
- calc_terminal/ui/header.py +1034 -0
- calc_terminal/ui/help_panel.py +254 -0
- calc_terminal/ui/live_activities.py +914 -0
- calc_terminal/ui/mcp_panel.py +570 -0
- calc_terminal/ui/memory_center.py +524 -0
- calc_terminal/ui/mode_colors_panel.py +525 -0
- calc_terminal/ui/nav_screens.py +747 -0
- calc_terminal/ui/ollama_panel.py +536 -0
- calc_terminal/ui/palette.py +221 -0
- calc_terminal/ui/permission_panel.py +269 -0
- calc_terminal/ui/personalization_panel.py +517 -0
- calc_terminal/ui/personalize_center.py +1568 -0
- calc_terminal/ui/preview_panel.py +441 -0
- calc_terminal/ui/resizers.py +402 -0
- calc_terminal/ui/sidebar.py +1285 -0
- calc_terminal/ui/statusbar.py +168 -0
- calc_terminal/ui/theme_css.py +1396 -0
- calc_terminal/ui/thinking.py +226 -0
- calc_terminal/ui/timeline_panel.py +102 -0
- calc_terminal/ui/todo_panel.py +193 -0
- calc_terminal/ui/viewport.py +136 -0
- calc_terminal/ui/vision_panel.py +489 -0
- calc_terminal/ui/welcome_modal.py +343 -0
- calc_terminal/ui/widgets.py +160 -0
- calc_terminal/ui/workspace.py +831 -0
- calc_terminal/viewers/__init__.py +1 -0
- calc_terminal/viewers/document_viewer.py +252 -0
- calc_terminal/viewers/image_viewer.py +241 -0
- calc_terminal/viewers/pdf_viewer.py +203 -0
- calc_terminal/viewers/presentation_viewer.py +164 -0
- calc_terminal/viewers/registry.py +120 -0
- calc_terminal/viewers/spreadsheet_viewer.py +204 -0
- calc_terminal/vision/__init__.py +89 -0
- calc_terminal/vision/analysis.py +194 -0
- calc_terminal/vision/annotations.py +297 -0
- calc_terminal/vision/capture.py +171 -0
- calc_terminal/vision/context.py +231 -0
- calc_terminal/vision/correlation.py +169 -0
- calc_terminal/vision/cursor.py +258 -0
- calc_terminal/vision/events.py +66 -0
- calc_terminal/vision/frame_pipeline.py +259 -0
- calc_terminal/vision/priority.py +218 -0
- calc_terminal/vision/provider.py +180 -0
- calc_terminal/vision/safety.py +149 -0
- calc_terminal/vision/session.py +281 -0
- calc_terminal/vision/verify.py +162 -0
- calc_terminal/vision.py +514 -0
- calc_terminal/vscode_integration.py +113 -0
- calc_terminal/web/__init__.py +8 -0
- calc_terminal/web/cat_runtime.py +710 -0
- calc_terminal/web/server.py +2891 -0
- calc_terminal/web/static/css/app.css +3152 -0
- calc_terminal/web/static/icons/badge-72.png +0 -0
- calc_terminal/web/static/icons/cat.ico +0 -0
- calc_terminal/web/static/icons/icon-128.png +0 -0
- calc_terminal/web/static/icons/icon-144.png +0 -0
- calc_terminal/web/static/icons/icon-152.png +0 -0
- calc_terminal/web/static/icons/icon-192.png +0 -0
- calc_terminal/web/static/icons/icon-384.png +0 -0
- calc_terminal/web/static/icons/icon-512.png +0 -0
- calc_terminal/web/static/icons/icon-72.png +0 -0
- calc_terminal/web/static/icons/icon-96.png +0 -0
- calc_terminal/web/static/icons/icon.svg +34 -0
- calc_terminal/web/static/icons/new-project.png +0 -0
- calc_terminal/web/static/icons/open-project.png +0 -0
- calc_terminal/web/static/index.html +734 -0
- calc_terminal/web/static/js/app.js +2403 -0
- calc_terminal/web/static/manifest.json +88 -0
- calc_terminal/web/static/sw.js +230 -0
- calc_terminal/workflow_engine.py +769 -0
- calc_terminal/workspace.py +593 -0
- calc_terminal/workspace_index.py +385 -0
- cct_cli-0.7.9.0.dist-info/METADATA +210 -0
- cct_cli-0.7.9.0.dist-info/RECORD +325 -0
- cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
- cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
- cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
- 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())
|