algo-cli-runtime 0.14.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 (237) hide show
  1. algo_cli/__init__.py +3 -0
  2. algo_cli/__main__.py +7 -0
  3. algo_cli/_internal/__init__.py +12 -0
  4. algo_cli/_internal/policy_chain.py +259 -0
  5. algo_cli/action_registry.py +1047 -0
  6. algo_cli/agent_blocks.py +550 -0
  7. algo_cli/agent_pipeline.py +1457 -0
  8. algo_cli/agent_threads.py +308 -0
  9. algo_cli/animations.py +316 -0
  10. algo_cli/cache_admission.py +209 -0
  11. algo_cli/capability_mask.py +66 -0
  12. algo_cli/chat_protocol.py +116 -0
  13. algo_cli/chatgpt_auth.py +510 -0
  14. algo_cli/chatgpt_client.py +657 -0
  15. algo_cli/code_rag.py +479 -0
  16. algo_cli/config.py +651 -0
  17. algo_cli/context_budget.py +679 -0
  18. algo_cli/credential_helpers.py +315 -0
  19. algo_cli/deliberation.py +29 -0
  20. algo_cli/display.py +1470 -0
  21. algo_cli/evals/__init__.py +21 -0
  22. algo_cli/evals/algorithm_effectiveness.py +560 -0
  23. algo_cli/evals/competitive_harness_rating.py +702 -0
  24. algo_cli/evals/cot_quality.py +220 -0
  25. algo_cli/evals/harness_retrieval_benchmark.py +401 -0
  26. algo_cli/evals/performance_regression.py +136 -0
  27. algo_cli/evals/scorecard_grading.py +308 -0
  28. algo_cli/evals/session_distribution.py +84 -0
  29. algo_cli/execution_guardrails.py +806 -0
  30. algo_cli/extensions_manifest.py +84 -0
  31. algo_cli/git_evidence.py +227 -0
  32. algo_cli/google_workspace.py +407 -0
  33. algo_cli/google_workspace_auth.py +523 -0
  34. algo_cli/harness.py +2587 -0
  35. algo_cli/identity.py +557 -0
  36. algo_cli/index_compute_lab.py +228 -0
  37. algo_cli/inference_harness.py +70 -0
  38. algo_cli/intelligence/__init__.py +1103 -0
  39. algo_cli/intelligence/acrobat_config.py +307 -0
  40. algo_cli/intelligence/acrobat_manifests.py +338 -0
  41. algo_cli/intelligence/acrobat_models.py +195 -0
  42. algo_cli/intelligence/acrobat_pipeline.py +295 -0
  43. algo_cli/intelligence/acrobat_runtime.py +302 -0
  44. algo_cli/intelligence/acrobat_security.py +261 -0
  45. algo_cli/intelligence/acrobat_workflows.py +226 -0
  46. algo_cli/intelligence/actionability.py +165 -0
  47. algo_cli/intelligence/adversarial_audit.py +136 -0
  48. algo_cli/intelligence/agent_arena.py +92 -0
  49. algo_cli/intelligence/agent_benchmark.py +236 -0
  50. algo_cli/intelligence/agent_runtime.py +171 -0
  51. algo_cli/intelligence/agents_as_tools.py +70 -0
  52. algo_cli/intelligence/artifact_binding.py +80 -0
  53. algo_cli/intelligence/autonomous_engineer.py +1976 -0
  54. algo_cli/intelligence/backpressure.py +99 -0
  55. algo_cli/intelligence/bloom_filter.py +186 -0
  56. algo_cli/intelligence/bonferroni.py +66 -0
  57. algo_cli/intelligence/boundary_compaction.py +98 -0
  58. algo_cli/intelligence/catalog_verifier.py +172 -0
  59. algo_cli/intelligence/cavecrew.py +118 -0
  60. algo_cli/intelligence/changelog.py +176 -0
  61. algo_cli/intelligence/checkpoint_resume.py +92 -0
  62. algo_cli/intelligence/circuit_breaker.py +88 -0
  63. algo_cli/intelligence/clarification_gate.py +101 -0
  64. algo_cli/intelligence/code_graph.py +180 -0
  65. algo_cli/intelligence/coderank.py +97 -0
  66. algo_cli/intelligence/consistent_hash.py +150 -0
  67. algo_cli/intelligence/consortium_synthesis.py +139 -0
  68. algo_cli/intelligence/construction/__init__.py +241 -0
  69. algo_cli/intelligence/construction/common.py +273 -0
  70. algo_cli/intelligence/construction/documents.py +496 -0
  71. algo_cli/intelligence/construction/labor_units.py +1395 -0
  72. algo_cli/intelligence/construction/payments.py +470 -0
  73. algo_cli/intelligence/construction/risk.py +784 -0
  74. algo_cli/intelligence/content_extractor.py +132 -0
  75. algo_cli/intelligence/context_adaptive.py +102 -0
  76. algo_cli/intelligence/context_ops.py +95 -0
  77. algo_cli/intelligence/count_min.py +145 -0
  78. algo_cli/intelligence/cow_state.py +103 -0
  79. algo_cli/intelligence/critic_loop.py +119 -0
  80. algo_cli/intelligence/cross_source.py +113 -0
  81. algo_cli/intelligence/daemon_mode.py +99 -0
  82. algo_cli/intelligence/dag_orchestration.py +151 -0
  83. algo_cli/intelligence/deep_research.py +155 -0
  84. algo_cli/intelligence/degenerate_detector.py +78 -0
  85. algo_cli/intelligence/delta_report.py +92 -0
  86. algo_cli/intelligence/discovery_event_log.py +92 -0
  87. algo_cli/intelligence/document_ingest.py +298 -0
  88. algo_cli/intelligence/dual_layer_validate.py +151 -0
  89. algo_cli/intelligence/echo_fidelity.py +73 -0
  90. algo_cli/intelligence/ema_tuning.py +104 -0
  91. algo_cli/intelligence/event_log.py +92 -0
  92. algo_cli/intelligence/evidence_graph.py +114 -0
  93. algo_cli/intelligence/extension_host.py +162 -0
  94. algo_cli/intelligence/extension_manifest.py +115 -0
  95. algo_cli/intelligence/falsification_suite.py +178 -0
  96. algo_cli/intelligence/finance/__init__.py +169 -0
  97. algo_cli/intelligence/finance/anomalies.py +135 -0
  98. algo_cli/intelligence/finance/ap_ar.py +351 -0
  99. algo_cli/intelligence/finance/cash.py +162 -0
  100. algo_cli/intelligence/finance/close.py +332 -0
  101. algo_cli/intelligence/finance/common.py +244 -0
  102. algo_cli/intelligence/finance/construction.py +135 -0
  103. algo_cli/intelligence/finance/controls.py +172 -0
  104. algo_cli/intelligence/finance/evidence.py +119 -0
  105. algo_cli/intelligence/finance/exceptions.py +157 -0
  106. algo_cli/intelligence/finance/reconciliations.py +254 -0
  107. algo_cli/intelligence/finance/revenue.py +109 -0
  108. algo_cli/intelligence/finance/tax.py +74 -0
  109. algo_cli/intelligence/finance/workpapers.py +111 -0
  110. algo_cli/intelligence/finding_record.py +120 -0
  111. algo_cli/intelligence/flow_dag.py +267 -0
  112. algo_cli/intelligence/gatherer.py +223 -0
  113. algo_cli/intelligence/golden_master.py +98 -0
  114. algo_cli/intelligence/graph_rag.py +195 -0
  115. algo_cli/intelligence/group_chat.py +143 -0
  116. algo_cli/intelligence/hash_dedup.py +145 -0
  117. algo_cli/intelligence/hyperloglog.py +128 -0
  118. algo_cli/intelligence/incremental_index.py +316 -0
  119. algo_cli/intelligence/index_store.py +16 -0
  120. algo_cli/intelligence/iteration_plan.py +133 -0
  121. algo_cli/intelligence/kernel_plugins.py +167 -0
  122. algo_cli/intelligence/lesson_catalog.py +135 -0
  123. algo_cli/intelligence/llm_fallback.py +169 -0
  124. algo_cli/intelligence/log2_histogram.py +267 -0
  125. algo_cli/intelligence/lsp_integration.py +147 -0
  126. algo_cli/intelligence/memory_evolution.py +117 -0
  127. algo_cli/intelligence/minhash_lsh.py +182 -0
  128. algo_cli/intelligence/multi_model_score.py +174 -0
  129. algo_cli/intelligence/multi_tier_grade.py +211 -0
  130. algo_cli/intelligence/negative_controls.py +113 -0
  131. algo_cli/intelligence/numeric_clamp.py +63 -0
  132. algo_cli/intelligence/occ_editor.py +66 -0
  133. algo_cli/intelligence/output_normalize.py +112 -0
  134. algo_cli/intelligence/parallel_delegation.py +98 -0
  135. algo_cli/intelligence/parallel_fanout.py +104 -0
  136. algo_cli/intelligence/permission_modes.py +105 -0
  137. algo_cli/intelligence/pre_push_gate.py +68 -0
  138. algo_cli/intelligence/prefetch.py +171 -0
  139. algo_cli/intelligence/process_framework.py +217 -0
  140. algo_cli/intelligence/project_graph.py +387 -0
  141. algo_cli/intelligence/query_expansion.py +146 -0
  142. algo_cli/intelligence/ralph_loop.py +117 -0
  143. algo_cli/intelligence/rate_limiter.py +153 -0
  144. algo_cli/intelligence/refactor_transaction.py +94 -0
  145. algo_cli/intelligence/research_workspace.py +108 -0
  146. algo_cli/intelligence/retraction_ledger.py +72 -0
  147. algo_cli/intelligence/saga_pattern.py +88 -0
  148. algo_cli/intelligence/session_fork.py +100 -0
  149. algo_cli/intelligence/shadow_editor.py +67 -0
  150. algo_cli/intelligence/shell_session.py +213 -0
  151. algo_cli/intelligence/source_registry.py +143 -0
  152. algo_cli/intelligence/spawn_scales.py +99 -0
  153. algo_cli/intelligence/stat_stability.py +104 -0
  154. algo_cli/intelligence/structural_validator.py +148 -0
  155. algo_cli/intelligence/subagent_spawner.py +111 -0
  156. algo_cli/intelligence/symmetric_verify.py +70 -0
  157. algo_cli/intelligence/task_classifier.py +129 -0
  158. algo_cli/intelligence/team_execution.py +122 -0
  159. algo_cli/intelligence/tiered_access.py +121 -0
  160. algo_cli/intelligence/utility_registry.py +159 -0
  161. algo_cli/intuition_engine.py +560 -0
  162. algo_cli/intuition_injector.py +82 -0
  163. algo_cli/kernels/__init__.py +5 -0
  164. algo_cli/kernels/manifest.py +763 -0
  165. algo_cli/main.py +3903 -0
  166. algo_cli/memory_candidates.py +541 -0
  167. algo_cli/memory_echo_veil.py +394 -0
  168. algo_cli/memory_runtime.py +112 -0
  169. algo_cli/model_info.py +548 -0
  170. algo_cli/model_profile.py +160 -0
  171. algo_cli/model_routing.py +74 -0
  172. algo_cli/oneshot.py +331 -0
  173. algo_cli/perf_telemetry.py +389 -0
  174. algo_cli/plugins.py +245 -0
  175. algo_cli/private_event_store.py +654 -0
  176. algo_cli/quantization/__init__.py +24 -0
  177. algo_cli/quantization/lloyd_max.py +98 -0
  178. algo_cli/quantization/turbo_quant.py +308 -0
  179. algo_cli/reasoning/__init__.py +46 -0
  180. algo_cli/reasoning/combinatorial.py +356 -0
  181. algo_cli/reasoning/graph_of_thought.py +297 -0
  182. algo_cli/reasoning/mcts.py +220 -0
  183. algo_cli/reasoning/neuro_symbolic.py +250 -0
  184. algo_cli/reasoning/react.py +246 -0
  185. algo_cli/reasoning/reflexion.py +225 -0
  186. algo_cli/reasoning/tree_of_thought.py +241 -0
  187. algo_cli/reasoning_bridge.py +150 -0
  188. algo_cli/reconciliation.py +284 -0
  189. algo_cli/reflex.py +385 -0
  190. algo_cli/resources/docs/ALGO.md +13958 -0
  191. algo_cli/resources/docs/algo-cli-algorithm-evidence-contract.md +60 -0
  192. algo_cli/resources/docs/algo-cli-execution-verification-contract.md +59 -0
  193. algo_cli/resources/docs/algo-cli-memory-lifecycle-contract.md +72 -0
  194. algo_cli/resources/docs/harness-extension-cleanup-recommendation.md +41 -0
  195. algo_cli/resources/docs/index-compute-lab-integration.md +32 -0
  196. algo_cli/resources/docs/inference-harness-loop-blueprint-2026-06.md +55 -0
  197. algo_cli/resources/docs/main-split-map.md +35 -0
  198. algo_cli/resources/docs/privacy-and-context.md +48 -0
  199. algo_cli/resources/docs/reflex-loop-v0.2.md +354 -0
  200. algo_cli/resources/skills/README.md +26 -0
  201. algo_cli/resources/skills/algo-cli.md +59 -0
  202. algo_cli/resources/skills/edit-file-precision.md +49 -0
  203. algo_cli/resources/skills/harness-search-first.md +47 -0
  204. algo_cli/resources/skills/memory-recall-ritual.md +51 -0
  205. algo_cli/resources/skills/qol-algorithms.md +224 -0
  206. algo_cli/resources/skills/smart-error-recovery.md +56 -0
  207. algo_cli/resources/skills/tool-selection-cheatsheet.md +65 -0
  208. algo_cli/retrieval_algorithms.py +127 -0
  209. algo_cli/runtime_qos.py +236 -0
  210. algo_cli/runtime_services.py +320 -0
  211. algo_cli/session_commands.py +95 -0
  212. algo_cli/session_mode.py +113 -0
  213. algo_cli/skills.py +430 -0
  214. algo_cli/slash_dispatch.py +1265 -0
  215. algo_cli/small_context.py +206 -0
  216. algo_cli/spawn_budget.py +89 -0
  217. algo_cli/task_ledger.py +84 -0
  218. algo_cli/task_router.py +197 -0
  219. algo_cli/tool_context.py +94 -0
  220. algo_cli/tool_contract.py +99 -0
  221. algo_cli/tool_policy.py +357 -0
  222. algo_cli/tool_runtime.py +647 -0
  223. algo_cli/tools.py +3056 -0
  224. algo_cli/url_scheme.py +174 -0
  225. algo_cli/verify.py +154 -0
  226. algo_cli/version_manifest.py +178 -0
  227. algo_cli/vision_screenshot_verify.py +76 -0
  228. algo_cli/workspace_resolver.py +68 -0
  229. algo_cli/x_account.py +209 -0
  230. algo_cli/xai_auth.py +374 -0
  231. algo_cli/xai_client.py +600 -0
  232. algo_cli_runtime-0.14.0.dist-info/METADATA +369 -0
  233. algo_cli_runtime-0.14.0.dist-info/RECORD +237 -0
  234. algo_cli_runtime-0.14.0.dist-info/WHEEL +4 -0
  235. algo_cli_runtime-0.14.0.dist-info/entry_points.txt +3 -0
  236. algo_cli_runtime-0.14.0.dist-info/licenses/LICENSE +21 -0
  237. ollama_cli/__init__.py +67 -0
algo_cli/x_account.py ADDED
@@ -0,0 +1,209 @@
1
+ """X account integration through the official xurl CLI.
2
+
3
+ This module is intentionally separate from xai_auth/xai_client. xAI OAuth
4
+ tokens are for Grok on api.x.ai; account actions use X API OAuth through xurl
5
+ on api.x.com.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+ from urllib.parse import quote
17
+
18
+
19
+ FORBIDDEN_XURL_FLAGS = {
20
+ "--bearer-token",
21
+ "--consumer-key",
22
+ "--consumer-secret",
23
+ "--access-token",
24
+ "--token-secret",
25
+ "--client-id",
26
+ "--client-secret",
27
+ }
28
+ CONFIRMED_POST_ACTIONS = {
29
+ "delete",
30
+ "like",
31
+ "unlike",
32
+ "repost",
33
+ "unrepost",
34
+ "bookmark",
35
+ "unbookmark",
36
+ }
37
+ POST_ID_RE = re.compile(r"(?:status|statuses)/(\d{1,20})(?:\D|$)|^(\d{1,20})$")
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class XAccountResult:
42
+ ok: bool
43
+ action: str
44
+ message: str
45
+ data: dict[str, Any] | None = None
46
+
47
+ def to_json(self) -> str:
48
+ payload: dict[str, Any] = {
49
+ "ok": self.ok,
50
+ "action": self.action,
51
+ "message": self.message,
52
+ }
53
+ if self.data:
54
+ payload["data"] = self.data
55
+ return json.dumps(payload, indent=2)
56
+
57
+
58
+ def xurl_path() -> str | None:
59
+ """Return the xurl executable path when it is available."""
60
+ return shutil.which("xurl")
61
+
62
+
63
+ def normalize_post_id(value: str) -> str:
64
+ """Extract an X post id from a numeric id or x.com status URL."""
65
+ raw = (value or "").strip()
66
+ match = POST_ID_RE.search(raw)
67
+ if not match:
68
+ raise ValueError("expected a post id or x.com status URL")
69
+ return match.group(1) or match.group(2) or ""
70
+
71
+
72
+ def compose_post_url(text: str) -> str:
73
+ return "https://x.com/compose/post?text=" + quote(text or "", safe="")
74
+
75
+
76
+ def reply_url(post: str) -> str:
77
+ post_id = normalize_post_id(post)
78
+ return f"https://x.com/intent/tweet?in_reply_to={post_id}"
79
+
80
+
81
+ def _validate_xurl_args(args: list[str]) -> None:
82
+ for arg in args:
83
+ if arg.split("=", 1)[0] in FORBIDDEN_XURL_FLAGS:
84
+ raise ValueError(f"refusing inline credential flag: {arg.split('=', 1)[0]}")
85
+ if arg in {"-v", "--verbose"}:
86
+ raise ValueError("refusing verbose xurl mode because it can expose auth headers")
87
+
88
+
89
+ def _run_xurl(args: list[str], *, timeout: int = 30) -> XAccountResult:
90
+ _validate_xurl_args(args)
91
+ exe = xurl_path()
92
+ if not exe:
93
+ return XAccountResult(
94
+ False,
95
+ "xurl",
96
+ "xurl is not installed or not on PATH. Install xdevplatform/xurl and run `xurl auth status`.",
97
+ )
98
+ try:
99
+ proc = subprocess.run(
100
+ [exe, *args],
101
+ text=True,
102
+ capture_output=True,
103
+ timeout=timeout,
104
+ check=False,
105
+ )
106
+ except subprocess.TimeoutExpired:
107
+ return XAccountResult(False, "xurl", f"xurl timed out after {timeout}s.")
108
+ except Exception as exc:
109
+ return XAccountResult(False, "xurl", f"could not run xurl: {exc}")
110
+
111
+ output = (proc.stdout or proc.stderr or "").strip()
112
+ if len(output) > 8000:
113
+ output = output[:8000] + "\n...[truncated]"
114
+ return XAccountResult(
115
+ proc.returncode == 0,
116
+ "xurl",
117
+ output or f"xurl exited with code {proc.returncode}.",
118
+ {"exit_code": proc.returncode},
119
+ )
120
+
121
+
122
+ def status() -> XAccountResult:
123
+ """Check xurl auth state without reading token files."""
124
+ return _run_xurl(["auth", "status"], timeout=15)
125
+
126
+
127
+ def draft_post(text: str) -> XAccountResult:
128
+ text = (text or "").strip()
129
+ if not text:
130
+ return XAccountResult(False, "draft_post", "post text is empty")
131
+ return XAccountResult(
132
+ True,
133
+ "draft_post",
134
+ "Draft URL generated. Review in browser before posting.",
135
+ {"url": compose_post_url(text), "text": text},
136
+ )
137
+
138
+
139
+ def draft_reply(post: str, text: str) -> XAccountResult:
140
+ text = (text or "").strip()
141
+ if not text:
142
+ return XAccountResult(False, "draft_reply", "reply text is empty")
143
+ try:
144
+ post_id = normalize_post_id(post)
145
+ except ValueError as exc:
146
+ return XAccountResult(False, "draft_reply", str(exc))
147
+ return XAccountResult(
148
+ True,
149
+ "draft_reply",
150
+ "Reply draft URL generated. Review in browser before posting.",
151
+ {"url": reply_url(post_id), "text": text, "post_id": post_id},
152
+ )
153
+
154
+
155
+ def post(text: str, *, confirm: bool = False) -> XAccountResult:
156
+ text = (text or "").strip()
157
+ if not text:
158
+ return XAccountResult(False, "post", "post text is empty")
159
+ if not confirm:
160
+ draft = draft_post(text)
161
+ return XAccountResult(
162
+ False,
163
+ "post",
164
+ "Blocked write: pass confirm=True only after the user explicitly approves this exact post.",
165
+ draft.data,
166
+ )
167
+ return _run_xurl(["post", text], timeout=45)
168
+
169
+
170
+ def reply(post_id_or_url: str, text: str, *, confirm: bool = False) -> XAccountResult:
171
+ text = (text or "").strip()
172
+ if not text:
173
+ return XAccountResult(False, "reply", "reply text is empty")
174
+ try:
175
+ post_id = normalize_post_id(post_id_or_url)
176
+ except ValueError as exc:
177
+ return XAccountResult(False, "reply", str(exc))
178
+ if not confirm:
179
+ draft = draft_reply(post_id, text)
180
+ return XAccountResult(
181
+ False,
182
+ "reply",
183
+ "Blocked write: pass confirm=True only after the user explicitly approves this exact reply.",
184
+ draft.data,
185
+ )
186
+ return _run_xurl(["reply", post_id, text], timeout=45)
187
+
188
+
189
+ def post_action(action: str, post_id_or_url: str, *, confirm: bool = False) -> XAccountResult:
190
+ action = (action or "").strip().lower()
191
+ if action not in CONFIRMED_POST_ACTIONS:
192
+ return XAccountResult(
193
+ False,
194
+ "post_action",
195
+ f"unsupported action: {action or '(empty)'}",
196
+ {"supported_actions": sorted(CONFIRMED_POST_ACTIONS)},
197
+ )
198
+ try:
199
+ post_id = normalize_post_id(post_id_or_url)
200
+ except ValueError as exc:
201
+ return XAccountResult(False, action, str(exc))
202
+ if not confirm:
203
+ return XAccountResult(
204
+ False,
205
+ action,
206
+ f"Blocked write: pass confirm=True only after the user explicitly approves {action} for this post.",
207
+ {"post_id": post_id},
208
+ )
209
+ return _run_xurl([action, post_id], timeout=45)
algo_cli/xai_auth.py ADDED
@@ -0,0 +1,374 @@
1
+ """Optional xAI OAuth 2.0 + PKCE authentication.
2
+
3
+ Public client, Authorization Code + PKCE (S256), loopback redirect. Tokens
4
+ persist to CONFIG_DIR/xai_auth.json with POSIX 0600 permissions. This is the
5
+ subscription OAuth path.
6
+
7
+ API-key fallback is intentionally disabled elsewhere.
8
+
9
+ Algo CLI deliberately does not bundle an OAuth client identity. Users who
10
+ enable this optional provider must supply a client id they are authorized to
11
+ use through ``XAI_CLIENT_ID``.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import hashlib
17
+ import http.server
18
+ import json
19
+ import os
20
+ import secrets
21
+ import socket
22
+ import time
23
+ import urllib.parse
24
+ import urllib.request
25
+ import webbrowser
26
+ from typing import Any
27
+
28
+ from .config import CONFIG_DIR, _atomic_write_text
29
+
30
+ XAI_CLIENT_ID_ENV = "XAI_CLIENT_ID"
31
+ XAI_AUTHORIZE_URL = "https://auth.x.ai/oauth2/authorize"
32
+ XAI_TOKEN_URL = "https://auth.x.ai/oauth2/token"
33
+ XAI_REDIRECT_HOST = "127.0.0.1"
34
+ XAI_REDIRECT_PORT = 56121
35
+ # We'll try a range of ports if the default is in use
36
+ XAI_REDIRECT_PORT_RANGE = range(56121, 56151)
37
+ XAI_REDIRECT_URI = f"http://{XAI_REDIRECT_HOST}:{XAI_REDIRECT_PORT}/callback"
38
+ XAI_DEFAULT_SCOPE = "openid offline_access api:access"
39
+ XAI_API_BASE = "https://api.x.ai/v1"
40
+
41
+ AUTH_FILE = CONFIG_DIR / "xai_auth.json"
42
+ _REFRESH_WINDOW_SECONDS = 60
43
+ _CALLBACK_TIMEOUT_SECONDS = 300.0
44
+
45
+
46
+ def resolve_client_id() -> str:
47
+ """Return the user-supplied xAI OAuth client id, or an empty string."""
48
+ return os.environ.get(XAI_CLIENT_ID_ENV, "").strip()
49
+
50
+
51
+ def client_id_configured() -> bool:
52
+ """Whether optional xAI OAuth has a user-provided client identity."""
53
+ return bool(resolve_client_id())
54
+
55
+
56
+ def require_client_id() -> str:
57
+ """Resolve the xAI client id or fail before starting an OAuth flow."""
58
+ client_id = resolve_client_id()
59
+ if not client_id:
60
+ raise RuntimeError(
61
+ "xAI subscription OAuth is optional and is not configured. "
62
+ "Set XAI_CLIENT_ID to an OAuth client id you are authorized to use, "
63
+ "then retry /xai-login. Algo CLI does not bundle a client id."
64
+ )
65
+ return client_id
66
+
67
+
68
+ def safe_error_message(error: BaseException | str) -> str:
69
+ """Format an OAuth error without echoing the configured client identity."""
70
+ message = str(error)
71
+ client_id = resolve_client_id()
72
+ if client_id:
73
+ message = message.replace(client_id, "[redacted-client-id]")
74
+ encoded = urllib.parse.quote_plus(client_id)
75
+ if encoded != client_id:
76
+ message = message.replace(encoded, "[redacted-client-id]")
77
+ return message
78
+
79
+
80
+ def _b64url(data: bytes) -> str:
81
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
82
+
83
+
84
+ def generate_pkce_pair() -> tuple[str, str]:
85
+ """Return (code_verifier, code_challenge) per RFC 7636 S256."""
86
+ verifier = _b64url(secrets.token_bytes(32))
87
+ challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
88
+ return verifier, challenge
89
+
90
+
91
+ def redirect_uri_for_port(port: int = XAI_REDIRECT_PORT) -> str:
92
+ return f"http://{XAI_REDIRECT_HOST}:{port}/callback"
93
+
94
+
95
+ def build_authorize_url(
96
+ *,
97
+ state: str,
98
+ code_challenge: str,
99
+ scope: str = XAI_DEFAULT_SCOPE,
100
+ redirect_uri: str | None = None,
101
+ ) -> str:
102
+ client_id = require_client_id()
103
+ params = {
104
+ "response_type": "code",
105
+ "client_id": client_id,
106
+ "redirect_uri": redirect_uri or XAI_REDIRECT_URI,
107
+ "scope": scope,
108
+ "state": state,
109
+ "code_challenge": code_challenge,
110
+ "code_challenge_method": "S256",
111
+ }
112
+ return f"{XAI_AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
113
+
114
+
115
+ class _CallbackHandler(http.server.BaseHTTPRequestHandler):
116
+ received: dict[str, str] = {}
117
+
118
+ def do_GET(self) -> None: # noqa: N802
119
+ parsed = urllib.parse.urlparse(self.path)
120
+ if parsed.path != "/callback":
121
+ self.send_response(404)
122
+ self.end_headers()
123
+ return
124
+ qs = urllib.parse.parse_qs(parsed.query)
125
+ type(self).received = {k: v[0] for k, v in qs.items() if v}
126
+ self.send_response(200)
127
+ self.send_header("Content-Type", "text/html; charset=utf-8")
128
+ self.end_headers()
129
+ ok = "code" in type(self).received and "error" not in type(self).received
130
+ body = (
131
+ "<html><body style='font-family:sans-serif;padding:2em'>"
132
+ "<h2>xAI login complete.</h2>"
133
+ "<p>You can close this tab and return to algo-cli.</p></body></html>"
134
+ if ok
135
+ else "<html><body style='font-family:sans-serif;padding:2em'>"
136
+ "<h2>xAI login failed.</h2>"
137
+ "<p>Return to algo-cli for details.</p></body></html>"
138
+ )
139
+ self.wfile.write(body.encode("utf-8"))
140
+
141
+ def log_message(self, format: str, *args: Any) -> None:
142
+ return
143
+
144
+
145
+ def run_loopback_capture(
146
+ *,
147
+ timeout: float = _CALLBACK_TIMEOUT_SECONDS,
148
+ redirect_port: int = XAI_REDIRECT_PORT,
149
+ ) -> dict[str, str]:
150
+ """Run a single-shot HTTP listener on the selected loopback redirect port.
151
+
152
+ Returns the parsed query string from the first /callback request, or an
153
+ empty dict on timeout/bind failure. Port selection happens before the auth
154
+ URL is built so the browser redirect URI and token exchange stay aligned.
155
+ """
156
+ received: dict[str, str] = {}
157
+ Handler = type("_LoopbackCallbackHandler", (_CallbackHandler,), {"received": received})
158
+
159
+ try:
160
+ server = http.server.HTTPServer((XAI_REDIRECT_HOST, redirect_port), Handler)
161
+ except OSError:
162
+ return {}
163
+ server.timeout = 1.0
164
+ deadline = time.time() + timeout
165
+ try:
166
+ while not Handler.received and time.time() < deadline:
167
+ server.handle_request()
168
+ finally:
169
+ server.server_close()
170
+ return dict(Handler.received)
171
+
172
+
173
+ def _post_token_endpoint(form: dict[str, str]) -> dict[str, Any]:
174
+ data = urllib.parse.urlencode(form).encode("ascii")
175
+ req = urllib.request.Request(
176
+ XAI_TOKEN_URL,
177
+ data=data,
178
+ headers={
179
+ "Content-Type": "application/x-www-form-urlencoded",
180
+ "Accept": "application/json",
181
+ },
182
+ method="POST",
183
+ )
184
+ with urllib.request.urlopen(req, timeout=30) as resp:
185
+ body = resp.read().decode("utf-8")
186
+ return json.loads(body)
187
+
188
+
189
+ def exchange_code(code: str, code_verifier: str, *, redirect_uri: str | None = None) -> dict[str, Any]:
190
+ client_id = require_client_id()
191
+ payload = _post_token_endpoint(
192
+ {
193
+ "grant_type": "authorization_code",
194
+ "client_id": client_id,
195
+ "code": code,
196
+ "redirect_uri": redirect_uri or XAI_REDIRECT_URI,
197
+ "code_verifier": code_verifier,
198
+ }
199
+ )
200
+ return _normalize_token_response(payload)
201
+
202
+
203
+ def refresh_access_token(refresh_token: str, *, scope: str = XAI_DEFAULT_SCOPE) -> dict[str, Any]:
204
+ client_id = require_client_id()
205
+ payload = _post_token_endpoint(
206
+ {
207
+ "grant_type": "refresh_token",
208
+ "client_id": client_id,
209
+ "refresh_token": refresh_token,
210
+ "scope": scope,
211
+ }
212
+ )
213
+ return _normalize_token_response(payload, fallback_refresh=refresh_token)
214
+
215
+
216
+ def _normalize_token_response(
217
+ payload: dict[str, Any], *, fallback_refresh: str | None = None
218
+ ) -> dict[str, Any]:
219
+ if "access_token" not in payload:
220
+ # Token responses may include provider diagnostics or credentials. Do
221
+ # not reflect the payload into terminal output or logs.
222
+ raise RuntimeError("xAI OAuth token response did not include an access token.")
223
+ now = int(time.time())
224
+ try:
225
+ expires_in = int(payload.get("expires_in", 3600))
226
+ except (TypeError, ValueError):
227
+ expires_in = 3600
228
+ return {
229
+ "access_token": str(payload["access_token"]),
230
+ "refresh_token": payload.get("refresh_token") or fallback_refresh,
231
+ "token_type": payload.get("token_type", "Bearer"),
232
+ "expires_at": now + expires_in,
233
+ "scope": payload.get("scope", ""),
234
+ "obtained_at": now,
235
+ }
236
+
237
+
238
+ def save_tokens(tokens: dict[str, Any]) -> None:
239
+ AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
240
+ _atomic_write_text(AUTH_FILE, json.dumps(tokens, indent=2))
241
+ try:
242
+ os.chmod(AUTH_FILE, 0o600)
243
+ except (OSError, NotImplementedError):
244
+ pass
245
+
246
+
247
+ def load_tokens() -> dict[str, Any] | None:
248
+ if not AUTH_FILE.exists():
249
+ return None
250
+ try:
251
+ return json.loads(AUTH_FILE.read_text(encoding="utf-8"))
252
+ except (OSError, json.JSONDecodeError):
253
+ return None
254
+
255
+
256
+ def clear_tokens() -> bool:
257
+ if not AUTH_FILE.exists():
258
+ return False
259
+ try:
260
+ AUTH_FILE.unlink()
261
+ return True
262
+ except OSError:
263
+ return False
264
+
265
+
266
+ def is_token_expired(tokens: dict[str, Any], *, window: int = _REFRESH_WINDOW_SECONDS) -> bool:
267
+ return int(tokens.get("expires_at", 0)) - window <= int(time.time())
268
+
269
+
270
+ def get_valid_token() -> str | None:
271
+ """Return a fresh access token, refreshing silently if expiry is near.
272
+
273
+ Returns None when the optional client identity is not configured, when no
274
+ tokens are stored, when refresh is required but no refresh_token exists,
275
+ or when the refresh call fails.
276
+ """
277
+ if not client_id_configured():
278
+ return None
279
+ tokens = load_tokens()
280
+ if not tokens:
281
+ return None
282
+ if not is_token_expired(tokens):
283
+ return tokens.get("access_token")
284
+ refresh = tokens.get("refresh_token")
285
+ if not refresh:
286
+ return None
287
+ try:
288
+ new_tokens = refresh_access_token(refresh)
289
+ except Exception:
290
+ return None
291
+ save_tokens(new_tokens)
292
+ return new_tokens.get("access_token")
293
+
294
+
295
+ def auth_status() -> dict[str, Any]:
296
+ tokens = load_tokens()
297
+ client_configured = client_id_configured()
298
+ if not tokens:
299
+ return {
300
+ "authenticated": False,
301
+ "client_configured": client_configured,
302
+ "token_present": False,
303
+ }
304
+ expires_at = int(tokens.get("expires_at", 0))
305
+ return {
306
+ "authenticated": client_configured,
307
+ "client_configured": client_configured,
308
+ "token_present": True,
309
+ "expires_at": expires_at,
310
+ "expires_in": max(0, expires_at - int(time.time())),
311
+ "scope": tokens.get("scope", ""),
312
+ "has_refresh_token": bool(tokens.get("refresh_token")),
313
+ "token_type": tokens.get("token_type", "Bearer"),
314
+ }
315
+
316
+
317
+ def port_is_free(port: int = XAI_REDIRECT_PORT) -> bool:
318
+ sock = socket.socket()
319
+ sock.settimeout(0.2)
320
+ try:
321
+ result = sock.connect_ex((XAI_REDIRECT_HOST, port))
322
+ return result != 0
323
+ except OSError:
324
+ return False
325
+ finally:
326
+ sock.close()
327
+
328
+
329
+ def select_redirect_port() -> int | None:
330
+ for port in XAI_REDIRECT_PORT_RANGE:
331
+ if port_is_free(port):
332
+ return port
333
+ return None
334
+
335
+
336
+ def begin_login(*, no_browser: bool = False, redirect_port: int = XAI_REDIRECT_PORT) -> dict[str, str]:
337
+ # Resolve before generating state, opening a browser, or starting a
338
+ # callback listener so an unconfigured install fails closed and cleanly.
339
+ require_client_id()
340
+ state = secrets.token_urlsafe(32)
341
+ verifier, challenge = generate_pkce_pair()
342
+ redirect_uri = redirect_uri_for_port(redirect_port)
343
+
344
+ url = build_authorize_url(state=state, code_challenge=challenge, redirect_uri=redirect_uri)
345
+ if not no_browser:
346
+ opened = webbrowser.open(url)
347
+ if not opened:
348
+ no_browser = True
349
+ ssh_tunnel_cmd = f"ssh -N -L {redirect_port}:{XAI_REDIRECT_HOST}:{redirect_port} you@remote-host"
350
+ return {
351
+ "state": state,
352
+ "code_verifier": verifier,
353
+ "auth_url": url,
354
+ "redirect_uri": redirect_uri,
355
+ "redirect_port": str(redirect_port),
356
+ "ssh_tunnel_cmd": ssh_tunnel_cmd,
357
+ "browser_opened": not no_browser,
358
+ }
359
+
360
+
361
+ def complete_login(code_verifier: str, state: str, callback: dict[str, str] | None) -> dict[str, Any]:
362
+ if not callback:
363
+ raise RuntimeError("Timed out waiting for OAuth callback.")
364
+ if "error" in callback:
365
+ detail = callback.get("error_description", callback["error"])
366
+ raise RuntimeError(f"OAuth error: {safe_error_message(detail)}")
367
+ if callback.get("state") != state:
368
+ raise RuntimeError("OAuth state mismatch - possible CSRF attack.")
369
+ code = callback.get("code")
370
+ if not code:
371
+ raise RuntimeError("No authorization code in callback.")
372
+ tokens = exchange_code(code, code_verifier, redirect_uri=callback.get("redirect_uri"))
373
+ save_tokens(tokens)
374
+ return tokens