delos-cli 2.0.2__tar.gz → 2.1.0__tar.gz

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 (79) hide show
  1. delos_cli-2.1.0/.claude/settings.local.json +8 -0
  2. {delos_cli-2.0.2 → delos_cli-2.1.0}/PKG-INFO +2 -1
  3. delos_cli-2.1.0/delos_cli/_build.json +1 -0
  4. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/agent/session.py +13 -3
  5. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/apps/chat/render.py +22 -0
  6. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/auth/config.py +14 -2
  7. delos_cli-2.1.0/delos_cli/public_key.py +116 -0
  8. delos_cli-2.1.0/delos_cli/sentry_identity.py +31 -0
  9. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/serve/server.py +7 -0
  10. delos_cli-2.1.0/delos_cli/telemetry.py +75 -0
  11. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/tools/glob_tool.py +24 -3
  12. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/tools/grep.py +20 -2
  13. {delos_cli-2.0.2 → delos_cli-2.1.0}/pyproject.toml +9 -8
  14. {delos_cli-2.0.2 → delos_cli-2.1.0}/.gitignore +0 -0
  15. {delos_cli-2.0.2 → delos_cli-2.1.0}/README.md +0 -0
  16. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/__init__.py +0 -0
  17. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/agent/__init__.py +0 -0
  18. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/agent/tools.py +0 -0
  19. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/agent/transport.py +0 -0
  20. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/agent/turn.py +0 -0
  21. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/apps/__init__.py +0 -0
  22. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/apps/base.py +0 -0
  23. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/apps/chat/__init__.py +0 -0
  24. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/apps/chat/app.py +0 -0
  25. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/apps/chat/commands.py +0 -0
  26. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/apps/replay.py +0 -0
  27. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/apps/scribe/__init__.py +0 -0
  28. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/apps/scribe/app.py +0 -0
  29. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/apps/scribe/commands.py +0 -0
  30. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/apps/scribe/tools.py +0 -0
  31. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/auth/__init__.py +0 -0
  32. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/auth/mfa.py +0 -0
  33. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/auth/oauth.py +0 -0
  34. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/auth/token_manager.py +0 -0
  35. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/commands/__init__.py +0 -0
  36. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/commands/base.py +0 -0
  37. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/commands/builtin.py +0 -0
  38. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/commands/prompts.py +0 -0
  39. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/context_report.py +0 -0
  40. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/ctx.py +0 -0
  41. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/git_context.py +0 -0
  42. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/loop.py +0 -0
  43. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/main.py +0 -0
  44. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/project_context.py +0 -0
  45. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/serve/__init__.py +0 -0
  46. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/serve/app.py +0 -0
  47. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/serve/confirm.py +0 -0
  48. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/serve/protocol.py +0 -0
  49. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/serve/rpc.py +0 -0
  50. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/state.py +0 -0
  51. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/tools/__init__.py +0 -0
  52. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/tools/ask_user_input.py +0 -0
  53. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/tools/edit_content.py +0 -0
  54. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/tools/explore.py +0 -0
  55. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/tools/read.py +0 -0
  56. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/tools/run_shell.py +0 -0
  57. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/tools/task.py +0 -0
  58. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/tools/todo_write.py +0 -0
  59. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/tools/verify.py +0 -0
  60. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/tools/write_content.py +0 -0
  61. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/transport/__init__.py +0 -0
  62. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/transport/chats.py +0 -0
  63. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/transport/client.py +0 -0
  64. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/transport/documents.py +0 -0
  65. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/transport/integrations.py +0 -0
  66. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/transport/models.py +0 -0
  67. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/ui/__init__.py +0 -0
  68. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/ui/banner.py +0 -0
  69. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/ui/chat_picker.py +0 -0
  70. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/ui/collapse.py +0 -0
  71. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/ui/completer.py +0 -0
  72. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/ui/document_picker.py +0 -0
  73. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/ui/integrations_picker.py +0 -0
  74. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/ui/lexer.py +0 -0
  75. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/ui/model_picker.py +0 -0
  76. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/ui/output.py +0 -0
  77. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/ui/repl.py +0 -0
  78. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/ui/style.py +0 -0
  79. {delos_cli-2.0.2 → delos_cli-2.1.0}/delos_cli/usage_report.py +0 -0
@@ -0,0 +1,8 @@
1
+ {
2
+ "disabledMcpjsonServers": [
3
+ "sentry",
4
+ "agent-analysis-localhost",
5
+ "agent-analysis-staging",
6
+ "agent-analysis-prod"
7
+ ]
8
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: delos-cli
3
- Version: 2.0.2
3
+ Version: 2.1.0
4
4
  Summary: Terminal REPL for the Delos agent — the Claude-Code-style CLI for Delos.
5
5
  Project-URL: Homepage, https://delos.so
6
6
  Project-URL: Repository, https://github.com/Delos-Intelligence/cosmos-saas
@@ -26,6 +26,7 @@ Requires-Dist: questionary>=2.0.1
26
26
  Requires-Dist: rich>=13.8.0
27
27
  Requires-Dist: ripgrep<15,>=14; sys_platform == 'darwin' and platform_machine == 'arm64'
28
28
  Requires-Dist: ripgrep<15,>=14; sys_platform == 'linux' and platform_machine == 'x86_64'
29
+ Requires-Dist: sentry-sdk<3,>=2.44.0
29
30
  Requires-Dist: typer>=0.12.5
30
31
  Description-Content-Type: text/markdown
31
32
 
@@ -0,0 +1 @@
1
+ {}
@@ -56,8 +56,9 @@ class AgentSession:
56
56
  terminates with unresolved tool calls, treats them as client-side,
57
57
  invokes the registered handler, posts the result via
58
58
  ``/tool-result``, and re-streams a continuation request with
59
- ``tool_continuation=true`` and ``messages=[]``. Loops until no
60
- pending calls remain or the user explicitly stopped.
59
+ ``tool_continuation=true``, ``messages=[]`` and no ``git_context``
60
+ (the backend replays the snapshot it froze on the user message).
61
+ Loops until no pending calls remain or the user explicitly stopped.
61
62
 
62
63
  The backend never emits ``tool-output-available`` for client-side
63
64
  tools — with ``emit_tool_output=True``, a synthetic one is yielded
@@ -128,7 +129,16 @@ class AgentSession:
128
129
  "output": result,
129
130
  }
130
131
 
131
- current_body = {**body, "messages": [], "tool_continuation": True}
132
+ # ``git_context`` is dropped: the backend froze the turn's snapshot
133
+ # onto the user message on the first request and replays it from
134
+ # there, so re-sending a fresh one would be ignored at best.
135
+ # ``custom_instructions`` stays — the system prompt is re-rendered
136
+ # on every request and reads it.
137
+ current_body = {
138
+ **{k: v for k, v in body.items() if k != "git_context"},
139
+ "messages": [],
140
+ "tool_continuation": True,
141
+ }
132
142
 
133
143
  async def stop(self) -> None:
134
144
  """Set the backend stop signal. Idempotent."""
@@ -109,6 +109,12 @@ class V6Renderer:
109
109
  # inline between literal tag deltas. Reasoning is swallowed (the
110
110
  # spinner keeps running) so it never lands in scrollback.
111
111
  self._in_think = False
112
+ # Prompt-token totals across every LLM call of this user turn (one
113
+ # renderer per turn, N HTTP requests inside it). Summed rather than
114
+ # rendered per call: the interesting number is what the whole turn
115
+ # paid, and a line per continuation would drown the transcript.
116
+ self._input_tokens = 0
117
+ self._cached_tokens = 0
112
118
 
113
119
  def apply(self, event: dict[str, Any]) -> None:
114
120
  """Apply a single v6 event to the output buffer."""
@@ -194,6 +200,12 @@ class V6Renderer:
194
200
  ),
195
201
  )
196
202
 
203
+ elif etype == "data-usage":
204
+ # Accumulated, not drawn: ``close()`` renders the turn total.
205
+ payload = event.get("data") or {}
206
+ self._input_tokens += int(payload.get("inputTokens") or 0)
207
+ self._cached_tokens += int(payload.get("cachedTokens") or 0)
208
+
197
209
  elif etype == "data-compaction-skipped":
198
210
  # Backend tried to compact but couldn't (LLM failure, etc.).
199
211
  # Surface it so the user understands why the agent may still
@@ -235,6 +247,16 @@ class V6Renderer:
235
247
  self._output.commit_live()
236
248
  else:
237
249
  self._output.discard_live()
250
+ self._render_usage()
251
+
252
+ def _render_usage(self) -> None:
253
+ """One dim line with the turn's prompt tokens and how many were cached."""
254
+ if self._input_tokens <= 0:
255
+ return
256
+ hit = round(100 * self._cached_tokens / self._input_tokens)
257
+ self._output.append_block(
258
+ Text(f"⚡ cache {hit}% · {_fmt_tokens(self._input_tokens)} in", style="dim"),
259
+ )
238
260
 
239
261
  @property
240
262
  def assistant_text(self) -> str:
@@ -25,6 +25,8 @@ from pathlib import Path
25
25
  from typing import IO, TYPE_CHECKING, Any, TypedDict
26
26
 
27
27
  from delos_cli import _editable_source_dir
28
+ from delos_cli.public_key import is_public_key, resolve_public_key
29
+ from delos_cli.telemetry import applied_config
28
30
 
29
31
  if TYPE_CHECKING:
30
32
  from collections.abc import Iterator
@@ -187,8 +189,16 @@ class Config:
187
189
 
188
190
  @property
189
191
  def supabase_anon_key(self) -> str:
190
- """Public Supabase anon key for this region."""
191
- return (PUBLIC_REGIONS | INTERNAL_REGIONS)[self.region]["supabase_anon_key"]
192
+ """Public key for this database; discovery never changes session tokens."""
193
+ if override := os.environ.get("DELOS_SUPABASE_PUBLISHABLE_KEY"):
194
+ if not is_public_key(override):
195
+ message = "DELOS_SUPABASE_PUBLISHABLE_KEY must be a public Supabase key"
196
+ raise ValueError(message)
197
+ return override
198
+ bundled = (PUBLIC_REGIONS | INTERNAL_REGIONS)[self.region]["supabase_anon_key"]
199
+ if self.region == "dev" and self.supabase_url == INTERNAL_REGIONS["dev"]["supabase_url"]:
200
+ return bundled
201
+ return resolve_public_key(self.web_url, self.supabase_url, bundled, CONFIG_DIR / "public-keys")
192
202
 
193
203
  @property
194
204
  def is_signed_in(self) -> bool:
@@ -232,6 +242,7 @@ def set_region(cfg: Config, region: str) -> None:
232
242
  cfg.user_id = adopted.user_id
233
243
  cfg.client_id = adopted.client_id
234
244
  cfg.tokens = adopted.tokens
245
+ applied_config(cfg)
235
246
 
236
247
 
237
248
  def load() -> Config:
@@ -268,6 +279,7 @@ def load() -> Config:
268
279
  if cfg.region not in available_regions():
269
280
  set_region(cfg, DEFAULT_REGION)
270
281
 
282
+ applied_config(cfg)
271
283
  return cfg
272
284
 
273
285
 
@@ -0,0 +1,116 @@
1
+ """Public key discovery; keep in sync with cvn/backend/cvn/public_key.py.
2
+
3
+ Only public API keys are cached. Database URLs, user JWTs and refresh tokens
4
+ are never changed. A process uses one snapshot until it restarts.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import contextlib
11
+ import hashlib
12
+ import json
13
+ import re
14
+ import tempfile
15
+ from functools import lru_cache
16
+ from pathlib import Path
17
+ from urllib.parse import urlsplit
18
+
19
+ import httpx
20
+
21
+ MAX_PUBLIC_KEY_LENGTH = 8192
22
+ JWT_PARTS = 3
23
+
24
+
25
+ def is_public_key(value: object) -> bool:
26
+ """A disclosure guard, not JWT signature verification."""
27
+ if not isinstance(value, str) or len(value) > MAX_PUBLIC_KEY_LENGTH:
28
+ return False
29
+ if re.fullmatch(r"sb_publishable_[A-Za-z0-9_-]+", value):
30
+ return True
31
+ parts = value.split(".")
32
+ if len(parts) != JWT_PARTS or any(not re.fullmatch(r"[A-Za-z0-9_-]+", part) for part in parts):
33
+ return False
34
+ try:
35
+ payload = json.loads(base64.urlsafe_b64decode(parts[1] + "=" * (-len(parts[1]) % 4)))
36
+ return isinstance(payload, dict) and payload.get("role") == "anon"
37
+ except ValueError, UnicodeError:
38
+ return False
39
+
40
+
41
+ def public_url(value: str) -> str:
42
+ """Reject credentials, fragments and non-HTTPS remote discovery."""
43
+ url = urlsplit(value)
44
+ local = url.hostname in {"localhost", "127.0.0.1", "::1"}
45
+ if (url.scheme != "https" and not (local and url.scheme == "http")) or not url.hostname:
46
+ message = "Invalid public configuration URL"
47
+ raise ValueError(message)
48
+ if url.username or url.password or url.query or url.fragment:
49
+ message = "Invalid public configuration URL"
50
+ raise ValueError(message)
51
+ return url.geturl().rstrip("/")
52
+
53
+
54
+ @lru_cache(maxsize=32)
55
+ def resolve_public_key(web_url: str, supabase_url: str, bundled_key: str, cache_dir: Path) -> str:
56
+ """Resolve once per configured target/process; fall back on discovery failure.
57
+
58
+ No request contains a Supabase API key or a user/session credential.
59
+ The response must name the exact configured database before its key is used.
60
+ """
61
+ key = bundled_key if is_public_key(bundled_key) else ""
62
+ try:
63
+ web = urlsplit(public_url(web_url))
64
+ origin = f"{web.scheme}://{web.netloc}"
65
+ database = public_url(supabase_url)
66
+ except ValueError:
67
+ return key
68
+ cache_id = hashlib.sha256(f"{origin}|{database}".encode()).hexdigest()
69
+ cache_path = cache_dir / f"{cache_id}.json"
70
+ try:
71
+ cached = json.loads(cache_path.read_text())
72
+ if is_public_key(cached):
73
+ key = cached
74
+ except OSError, ValueError:
75
+ pass
76
+ try:
77
+ # No redirects: the configured origin owns key discovery.
78
+ response = httpx.get(f"{origin}/api/addin-config", timeout=3.0, follow_redirects=False)
79
+ response.raise_for_status()
80
+ data = response.json()
81
+ if not isinstance(data, dict) or not isinstance(data.get("supabaseUrl"), str):
82
+ return key
83
+ candidate = data.get("anonKey")
84
+ if public_url(data["supabaseUrl"]) != database or not is_public_key(candidate):
85
+ return key
86
+ key = str(candidate)
87
+ except httpx.HTTPError, ValueError:
88
+ return key
89
+ # Atomic persistence is best-effort and separate from applied memory.
90
+ temporary: Path | None = None
91
+ try:
92
+ cache_dir.mkdir(parents=True, exist_ok=True)
93
+ with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=cache_dir, delete=False) as output:
94
+ temporary = Path(output.name)
95
+ json.dump(key, output)
96
+ temporary.replace(cache_path)
97
+ except OSError:
98
+ pass
99
+ finally:
100
+ if temporary:
101
+ with contextlib.suppress(OSError):
102
+ temporary.unlink(missing_ok=True)
103
+ return key
104
+
105
+
106
+ def public_key_tags(key: str) -> dict[str, str]:
107
+ """Match @kit/supabase/public-key; no user JWT or secret is fingerprinted."""
108
+ if not is_public_key(key):
109
+ return {"supabase.key_kind": "unknown", "supabase.key_revision": "unknown"}
110
+ fingerprint = 5381
111
+ for char in key:
112
+ fingerprint = ((fingerprint * 33) ^ ord(char)) & 0xFFFFFFFF
113
+ return {
114
+ "supabase.key_kind": "publishable" if key.startswith("sb_publishable_") else "legacy",
115
+ "supabase.key_revision": f"djb2:{fingerprint:x}",
116
+ }
@@ -0,0 +1,31 @@
1
+ """Public deployment identity for a separately distributed client."""
2
+
3
+ from urllib.parse import urlsplit
4
+
5
+ # Keep aligned with packages/monitoring/core/src/identity.ts (parity tested).
6
+ _HOSTS = {
7
+ "app.delos.so": "eu-prod",
8
+ "db.app.delos.so": "eu-prod",
9
+ "us.delos.so": "us-prod",
10
+ "db.app.us.delos.so": "us-prod",
11
+ "ae.delos.so": "uae-prod",
12
+ "db.ae.delos.so": "uae-prod",
13
+ "bouygues.delos.so": "bouygues-prod",
14
+ "db.bouygues.delos.so": "bouygues-prod",
15
+ "beta.delos.so": "eu-staging",
16
+ "db.beta.delos.so": "eu-staging",
17
+ "localhost": "local",
18
+ "127.0.0.1": "local",
19
+ "::1": "local",
20
+ }
21
+
22
+
23
+ def endpoint_identity(endpoint: str) -> str:
24
+ """Never transmit custom URLs, credentials or configuration payloads."""
25
+ try:
26
+ parsed = urlsplit(endpoint)
27
+ if parsed.scheme in {"http", "https"} and parsed.hostname:
28
+ return _HOSTS.get(parsed.hostname, "custom")
29
+ except ValueError:
30
+ pass
31
+ return "unknown"
@@ -201,6 +201,7 @@ class StdioServer:
201
201
  has_project_instructions=bool(self.custom_instructions),
202
202
  )
203
203
  model = message.get("model")
204
+ reasoning_effort = message.get("reasoningEffort")
204
205
  # Uploaded-document refs (Import from Computer / Drive) attached by the
205
206
  # frontend; forwarded to the backend so the agent can read_document them.
206
207
  raw_files = message.get("messageFiles")
@@ -244,6 +245,7 @@ class StdioServer:
244
245
  mode,
245
246
  model if isinstance(model, str) else None,
246
247
  message_files,
248
+ reasoning_effort=reasoning_effort if isinstance(reasoning_effort, str) and reasoning_effort else None,
247
249
  interrupted_content=interrupted_content,
248
250
  edit_turn=edit_turn,
249
251
  ),
@@ -258,6 +260,7 @@ class StdioServer:
258
260
  mode: str,
259
261
  model: str | None,
260
262
  message_files: list[dict[str, Any]],
263
+ reasoning_effort: str | None = None,
261
264
  interrupted_content: str | None = None,
262
265
  edit_turn: int | None = None,
263
266
  ) -> None:
@@ -305,8 +308,12 @@ class StdioServer:
305
308
  # the agent doesn't burn its first tool call on `git status`.
306
309
  if (git_ctx := await collect_git_context(self.workspace)) is not None:
307
310
  body["git_context"] = git_ctx
311
+ if self.custom_instructions:
312
+ body["custom_instructions"] = self.custom_instructions
308
313
  if model:
309
314
  body["options"] = {"model": model}
315
+ if reasoning_effort:
316
+ body.setdefault("options", {})["reasoning_effort"] = reasoning_effort
310
317
  if edit_turn is not None:
311
318
  body["edit_turn"] = edit_turn
312
319
  if message_files:
@@ -0,0 +1,75 @@
1
+ """Telemetry entrypoint; captures unexpected failures and flushes before exit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ import sentry_sdk
11
+
12
+ from delos_cli import __version__, _editable_source_dir
13
+ from delos_cli.public_key import public_key_tags
14
+ from delos_cli.sentry_identity import endpoint_identity
15
+
16
+ if TYPE_CHECKING:
17
+ from sentry_sdk.client import BaseClient
18
+
19
+ from delos_cli.auth.config import Config
20
+
21
+ _cli_client: BaseClient | None = None
22
+
23
+
24
+ def build_identity() -> dict[str, str]:
25
+ """Read metadata shipped with the wheel, never the caller shell environment."""
26
+ metadata = json.loads(Path(__file__).with_name("_build.json").read_text())
27
+ if metadata.get("version") == __version__ and _editable_source_dir() is None:
28
+ return metadata
29
+ return {"release": f"cosmos-cli@{__version__}"}
30
+
31
+
32
+ def applied_config(cfg: Config) -> None:
33
+ """Reads only the endpoints the active config object will use."""
34
+ if _cli_client is None or sentry_sdk.get_client() is not _cli_client:
35
+ return
36
+ sentry_sdk.set_tags(public_key_tags(cfg.supabase_anon_key))
37
+ sentry_sdk.set_tag("backend.deployment", endpoint_identity(cfg.api_url))
38
+ sentry_sdk.set_tag("database.deployment", endpoint_identity(cfg.supabase_url))
39
+
40
+
41
+ def main() -> None:
42
+ """Keep CLI exit codes and exceptions intact; never collect CLI arguments."""
43
+ global _cli_client # noqa: PLW0603 -- identify our client in a shared process
44
+ build = build_identity()
45
+ editable = _editable_source_dir() is not None
46
+ disabled = editable or os.environ.get("DELOS_TELEMETRY_DISABLED") == "1"
47
+ sentry_sdk.init(
48
+ dsn=""
49
+ if disabled
50
+ else "https://03a98437d8f510283186ce47f55866d7@o4507940398825472.ingest.de.sentry.io/4512067093463120",
51
+ release=build["release"],
52
+ environment="development" if editable else "production",
53
+ default_integrations=False,
54
+ auto_enabling_integrations=False,
55
+ send_default_pii=False,
56
+ include_local_variables=False,
57
+ max_request_body_size="never",
58
+ )
59
+ _cli_client = sentry_sdk.get_client()
60
+ sentry_sdk.set_tag("service", "cli")
61
+ sentry_sdk.set_tag("delos.release", build["release"])
62
+ sentry_sdk.set_tag("app.version", __version__)
63
+ if sha := build.get("sha"):
64
+ sentry_sdk.set_tag("build.sha", sha)
65
+ sentry_sdk.start_session(session_mode="application")
66
+ try:
67
+ from delos_cli.main import app # noqa: PLC0415
68
+
69
+ app()
70
+ except Exception as exc:
71
+ sentry_sdk.capture_exception(exc)
72
+ raise
73
+ finally:
74
+ sentry_sdk.end_session()
75
+ sentry_sdk.flush(timeout=2)
@@ -51,6 +51,7 @@ async def handle_glob(tool_input: dict[str, Any], ctx: Ctx) -> str: # noqa: RUF
51
51
  return "Error: 'pattern' is required and must be a non-empty string."
52
52
 
53
53
  raw_path = (tool_input.get("path") or ".").strip() or "."
54
+ include_ignored = bool(tool_input.get("include_ignored"))
54
55
  base = Path(raw_path).expanduser() # noqa: ASYNC240 — one-shot stat is fine
55
56
  if not base.is_absolute():
56
57
  base = Path.cwd() / base
@@ -62,12 +63,29 @@ async def handle_glob(tool_input: dict[str, Any], ctx: Ctx) -> str: # noqa: RUF
62
63
 
63
64
  # ``Path.glob`` handles ``**`` recursive segments natively.
64
65
  try:
65
- candidates = [p for p in base.glob(pattern) if not _IGNORED_DIR_SEGMENTS.intersection(p.parts) and p.is_file()]
66
+ matches = (
67
+ base.glob(pattern, recurse_symlinks=True)
68
+ if include_ignored
69
+ else base.glob(pattern)
70
+ )
71
+ candidates = [
72
+ p
73
+ for p in matches
74
+ if (include_ignored or not _IGNORED_DIR_SEGMENTS.intersection(p.parts)) and p.is_file()
75
+ ]
66
76
  except (OSError, ValueError) as e:
67
77
  return f"Error walking {base} with pattern {pattern!r}: {e}"
68
78
 
69
79
  if not candidates:
70
- return f"No files matching {pattern!r} in {base} (dependency/build trees like node_modules are excluded)."
80
+ suffix = (
81
+ ""
82
+ if include_ignored
83
+ else (
84
+ " (dependency/build trees like node_modules are excluded — "
85
+ "pass include_ignored=true to search them)"
86
+ )
87
+ )
88
+ return f"No files matching {pattern!r} in {base}{suffix}."
71
89
 
72
90
  # Sort by mtime desc; fall back to path on stat errors so a single
73
91
  # vanished file doesn't poison the whole result.
@@ -101,4 +119,7 @@ def render_glob(event: dict[str, Any]) -> RenderableType | None:
101
119
  if not pattern:
102
120
  return None
103
121
  path = inputs.get("path") or "."
104
- return tool_line("→", "List files", f'"{pattern}"', color="cyan", suffix=f"({path})")
122
+ suffix = f"({path})"
123
+ if inputs.get("include_ignored"):
124
+ suffix += " incl. ignored"
125
+ return tool_line("→", "List files", f'"{pattern}"', color="cyan", suffix=suffix)
@@ -93,6 +93,7 @@ async def handle_grep(tool_input: dict[str, Any], ctx: Ctx) -> str:
93
93
  type_ = tool_input.get("type")
94
94
  output_mode = (tool_input.get("output_mode") or "content").strip()
95
95
  case_insensitive = bool(tool_input.get("case_insensitive"))
96
+ include_ignored = bool(tool_input.get("include_ignored"))
96
97
  head_limit = _coerce_int(
97
98
  tool_input.get("head_limit"),
98
99
  default=_DEFAULT_HEAD_LIMIT,
@@ -114,6 +115,7 @@ async def handle_grep(tool_input: dict[str, Any], ctx: Ctx) -> str:
114
115
  output_mode=output_mode,
115
116
  case_insensitive=case_insensitive,
116
117
  context_lines=context_lines,
118
+ include_ignored=include_ignored,
117
119
  )
118
120
  else:
119
121
  argv = _build_grep_argv(
@@ -123,6 +125,7 @@ async def handle_grep(tool_input: dict[str, Any], ctx: Ctx) -> str:
123
125
  output_mode=output_mode,
124
126
  case_insensitive=case_insensitive,
125
127
  context_lines=context_lines,
128
+ include_ignored=include_ignored,
126
129
  )
127
130
 
128
131
  try:
@@ -176,6 +179,8 @@ def render_grep(event: dict[str, Any]) -> RenderableType | None:
176
179
  path = inputs.get("path") or "."
177
180
  glob = inputs.get("glob")
178
181
  suffix = f"({path})"
182
+ if inputs.get("include_ignored"):
183
+ suffix += " incl. ignored"
179
184
  if glob:
180
185
  suffix += f" glob: {glob}"
181
186
  return tool_line("→", "Search in files", f'"{pattern}"', color="cyan", suffix=suffix)
@@ -191,9 +196,20 @@ def _build_rg_argv(
191
196
  output_mode: str,
192
197
  case_insensitive: bool,
193
198
  context_lines: int,
199
+ include_ignored: bool,
194
200
  ) -> list[str]:
195
201
  """Compose a ripgrep argv reflecting the tool inputs."""
196
- argv: list[str] = [rg, "--color=never", "--no-heading", "--line-number"]
202
+ argv: list[str] = [
203
+ rg,
204
+ "--color=never",
205
+ "--no-heading",
206
+ "--line-number",
207
+ "--max-columns",
208
+ "500",
209
+ "--max-columns-preview",
210
+ ]
211
+ if include_ignored:
212
+ argv.append("--no-ignore")
197
213
  if case_insensitive:
198
214
  argv.append("-i")
199
215
  if isinstance(glob, str) and glob:
@@ -220,10 +236,12 @@ def _build_grep_argv(
220
236
  output_mode: str,
221
237
  case_insensitive: bool,
222
238
  context_lines: int,
239
+ include_ignored: bool,
223
240
  ) -> list[str]:
224
241
  """Compose a POSIX-grep argv as a fallback when ripgrep isn't installed."""
225
242
  argv: list[str] = ["grep", "-r", "-n", "-I"]
226
- argv.extend(f"--exclude-dir={d}" for d in _EXCLUDED_DIRS)
243
+ excluded_dirs = (".git",) if include_ignored else _EXCLUDED_DIRS
244
+ argv.extend(f"--exclude-dir={d}" for d in excluded_dirs)
227
245
  if case_insensitive:
228
246
  argv.append("-i")
229
247
  if isinstance(glob, str) and glob:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "delos-cli"
3
- version = "2.0.2"
3
+ version = "2.1.0"
4
4
  description = "Terminal REPL for the Delos agent — the Claude-Code-style CLI for Delos."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -21,6 +21,7 @@ classifiers = [
21
21
  "Topic :: Utilities",
22
22
  ]
23
23
  dependencies = [
24
+ "sentry-sdk>=2.44.0,<3",
24
25
  "typer>=0.12.5",
25
26
  "rich>=13.8.0",
26
27
  "prompt_toolkit>=3.0.47",
@@ -39,7 +40,7 @@ dependencies = [
39
40
  ]
40
41
 
41
42
  [project.scripts]
42
- delos = "delos_cli.main:app"
43
+ delos = "delos_cli.telemetry:main"
43
44
 
44
45
  [project.urls]
45
46
  Homepage = "https://delos.so"
@@ -50,11 +51,11 @@ Issues = "https://github.com/Delos-Intelligence/cosmos-saas/issues"
50
51
  requires = ["hatchling"]
51
52
  build-backend = "hatchling.build"
52
53
 
54
+ [tool.hatch.build]
55
+ exclude = [
56
+ "/bench/.toolchain",
57
+ "/bench/fixture/.ruff_cache",
58
+ ]
59
+
53
60
  [tool.hatch.build.targets.wheel]
54
61
  packages = ["delos_cli"]
55
-
56
- [tool.hatch.build.targets.sdist]
57
- # hatchling only reads the .gitignore next to this pyproject, not the repo
58
- # root's — so bench/.toolchain (a venv with absolute symlinks) would end up in
59
- # the sdist and break `uv build`. List what ships instead.
60
- include = ["delos_cli", "README.md", "pyproject.toml"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes