copper-pilot-cli 0.1.1__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 (39) hide show
  1. copper_pilot_cli/__init__.py +37 -0
  2. copper_pilot_cli/__main__.py +6 -0
  3. copper_pilot_cli/_upstream/__init__.py +0 -0
  4. copper_pilot_cli/_upstream/dcode_0_1_69/PROVENANCE.json +46 -0
  5. copper_pilot_cli/_upstream/dcode_0_1_69/__init__.py +0 -0
  6. copper_pilot_cli/_upstream/dcode_0_1_69/diff_utils.py +222 -0
  7. copper_pilot_cli/_version.py +32 -0
  8. copper_pilot_cli/clipboard.py +107 -0
  9. copper_pilot_cli/copper_api.py +24 -0
  10. copper_pilot_cli/copper_app.py +1071 -0
  11. copper_pilot_cli/copper_auth.py +232 -0
  12. copper_pilot_cli/copper_config.py +62 -0
  13. copper_pilot_cli/copper_features.py +263 -0
  14. copper_pilot_cli/copper_graph.py +178 -0
  15. copper_pilot_cli/copper_hooks.py +49 -0
  16. copper_pilot_cli/copper_main.py +345 -0
  17. copper_pilot_cli/copper_preferences.py +85 -0
  18. copper_pilot_cli/copper_presentation.py +325 -0
  19. copper_pilot_cli/copper_protocol.py +421 -0
  20. copper_pilot_cli/copper_theme.py +34 -0
  21. copper_pilot_cli/copper_tools.py +23 -0
  22. copper_pilot_cli/copper_update.py +32 -0
  23. copper_pilot_cli/copper_widgets.py +1224 -0
  24. copper_pilot_cli/copper_workspace.py +151 -0
  25. copper_pilot_cli/deepagents_tools.py +556 -0
  26. copper_pilot_cli/diagnostics.py +55 -0
  27. copper_pilot_cli/langchain.py +215 -0
  28. copper_pilot_cli/media_utils.py +626 -0
  29. copper_pilot_cli/py.typed +0 -0
  30. copper_pilot_cli/sessions.py +1578 -0
  31. copper_pilot_cli/textual_patches.py +35 -0
  32. copper_pilot_cli-0.1.1.data/data/share/doc/copper-pilot-cli/NOTICE +13 -0
  33. copper_pilot_cli-0.1.1.data/data/share/doc/copper-pilot-cli/UPSTREAM.md +44 -0
  34. copper_pilot_cli-0.1.1.dist-info/METADATA +211 -0
  35. copper_pilot_cli-0.1.1.dist-info/RECORD +39 -0
  36. copper_pilot_cli-0.1.1.dist-info/WHEEL +4 -0
  37. copper_pilot_cli-0.1.1.dist-info/entry_points.txt +3 -0
  38. copper_pilot_cli-0.1.1.dist-info/licenses/LICENSE +22 -0
  39. copper_pilot_cli-0.1.1.dist-info/licenses/NOTICE +13 -0
@@ -0,0 +1,421 @@
1
+ """Typed CopperPilot client protocol derived from the desktop harness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import json
8
+ import logging
9
+ import re
10
+ import ssl
11
+ from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
12
+ from dataclasses import dataclass
13
+ from enum import StrEnum
14
+ from typing import Any, Literal
15
+ from urllib.parse import urlparse, urlunparse
16
+
17
+ import certifi
18
+ from markdownify import markdownify
19
+ from pydantic import BaseModel, ConfigDict, Field
20
+ from websockets.asyncio.client import connect
21
+ from websockets.exceptions import ConnectionClosed
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ MAX_CHAT_START_BYTES = 12 * 1024 * 1024
26
+ STRICT_CHAT_START_BYTES = 8 * 1024 * 1024
27
+ MAX_TOOL_RESULT_BYTES = 8 * 1024 * 1024
28
+ MAX_CONTEXT_MESSAGE_BYTES = 64 * 1024
29
+ MAX_CONTEXT_TOTAL_BYTES = 4 * 1024 * 1024
30
+ MAX_CONTEXT_ENTRIES = 2_000
31
+ MAX_STREAM_BYTES = 64 * 1024 * 1024
32
+
33
+
34
+ class ProtocolError(RuntimeError):
35
+ """The hosted protocol was malformed or incomplete."""
36
+
37
+
38
+ class CopperMode(StrEnum):
39
+ """CopperPilot chat modes."""
40
+
41
+ AGENT = "agent"
42
+ PLAN = "plan"
43
+ ASK = "ask"
44
+
45
+
46
+ class ChatMessage(BaseModel):
47
+ """Portable context message."""
48
+
49
+ role: Literal["system", "user", "assistant"]
50
+ content: str
51
+
52
+
53
+ class PlanTodo(BaseModel):
54
+ id: str
55
+ content: str
56
+ status: Literal["open", "running", "done"] = "open"
57
+
58
+
59
+ class PlanContext(BaseModel):
60
+ file_path: str
61
+ name: str | None = None
62
+ overview: str | None = None
63
+ overall_status: Literal["open", "running", "done"] = "open"
64
+ todos: list[PlanTodo] = Field(default_factory=list)
65
+ body: str | None = None
66
+
67
+
68
+ class ChatRequest(BaseModel):
69
+ """Path-first hosted chat request."""
70
+
71
+ model_config = ConfigDict(extra="allow")
72
+
73
+ query: str
74
+ context: list[ChatMessage] = Field(default_factory=list)
75
+ document_context: list[dict[str, Any]] = Field(default_factory=list)
76
+ project_data: dict[str, Any] = Field(default_factory=dict)
77
+ workspace_path: str
78
+ conversation_id: str
79
+ initial_file_tree: str | None = None
80
+ mode: CopperMode = CopperMode.AGENT
81
+ agentic_mode: bool = False
82
+ schematic_path: str | None = None
83
+ pcb_path: str | None = None
84
+ schematic_info: dict[str, Any] | None = None
85
+ pcb_info: dict[str, Any] | None = None
86
+ workflow: str | None = None
87
+ plan_context: PlanContext | None = None
88
+ skill_manifest_id: str | None = None
89
+ runtime_context: dict[str, Any] | None = None
90
+ enable_computer_use: Literal[False] = False
91
+
92
+
93
+ class ToolRequest(BaseModel):
94
+ tool_call_id: str
95
+ tool_name: str
96
+ arguments: dict[str, Any] = Field(default_factory=dict)
97
+ timeout_ms: int | None = None
98
+
99
+
100
+ class ChatResponse(BaseModel):
101
+ model_config = ConfigDict(extra="allow")
102
+
103
+ response: str | None = None
104
+ status_update: str | None = None
105
+ status_event: dict[str, Any] | None = None
106
+ reasoning_update: str | None = None
107
+ reasoning_title: str | None = None
108
+ tool_request: ToolRequest | None = None
109
+ usage: dict[str, Any] | None = None
110
+ success: bool | None = None
111
+ error: str | None = None
112
+ is_final: bool = False
113
+ suggestions: list[Any] | None = None
114
+ turn_id: str | None = None
115
+ sequence: int | None = None
116
+ resume_attempt: int | None = None
117
+
118
+
119
+ class EventKind(StrEnum):
120
+ TEXT = "text"
121
+ REASONING = "reasoning"
122
+ STATUS = "status"
123
+ STATUS_EVENT = "status_event"
124
+ TOOL_REQUEST = "tool_request"
125
+ TOOL_RESULT = "tool_result"
126
+ USAGE = "usage"
127
+ FINAL = "final"
128
+ ERROR = "error"
129
+
130
+
131
+ @dataclass(frozen=True, slots=True)
132
+ class CopperEvent:
133
+ kind: EventKind
134
+ data: Any
135
+ title: str | None = None
136
+ turn_id: str | None = None
137
+ sequence: int | None = None
138
+ resume_attempt: int | None = None
139
+
140
+
141
+ _DATA_URI = re.compile(r"data:([^;,]+)?(?:;[^,]*)?,([A-Za-z0-9+/=\\s]+)")
142
+ _TERMINAL_ESCAPE = re.compile(r"\x1b(?:\][^\x07]*(?:\x07|\x1b\\)|[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
143
+
144
+
145
+ def _strip_data_uris(text: str) -> str:
146
+ def replace(match: re.Match[str]) -> str:
147
+ digest = hashlib.sha256(match.group(0).encode()).hexdigest()[:12]
148
+ return f"[inline attachment omitted:{digest}]"
149
+
150
+ return _DATA_URI.sub(replace, text)
151
+
152
+
153
+ def _truncate_utf8(text: str, limit: int) -> str:
154
+ encoded = text.encode()
155
+ if len(encoded) <= limit:
156
+ return text
157
+ return encoded[:limit].decode(errors="ignore") + "\n[truncated]"
158
+
159
+
160
+ def bound_context(messages: Sequence[ChatMessage]) -> list[ChatMessage]:
161
+ """Apply the desktop client's bounded newest-first history policy."""
162
+ bounded: list[ChatMessage] = []
163
+ total = 0
164
+ for message in reversed(messages[-MAX_CONTEXT_ENTRIES:]):
165
+ content = _truncate_utf8(_strip_data_uris(message.content), MAX_CONTEXT_MESSAGE_BYTES)
166
+ size = len(content.encode())
167
+ if bounded and total + size > MAX_CONTEXT_TOTAL_BYTES:
168
+ break
169
+ bounded.append(ChatMessage(role=message.role, content=content))
170
+ total += size
171
+ bounded.reverse()
172
+ if len(bounded) < len(messages):
173
+ bounded.insert(
174
+ 0,
175
+ ChatMessage(
176
+ role="system",
177
+ content=f"[{len(messages) - len(bounded)} older messages omitted]",
178
+ ),
179
+ )
180
+ return bounded
181
+
182
+
183
+ def make_chat_start(request: ChatRequest) -> dict[str, Any]:
184
+ """Serialize and size-check a protocol-v1 chat start frame."""
185
+ payload = request.model_copy(update={"context": bound_context(request.context)})
186
+ frame = {
187
+ "type": "chat_start",
188
+ "protocol_version": 1,
189
+ "request": payload.model_dump(mode="json", exclude_none=True),
190
+ }
191
+ size = len(json.dumps(frame, separators=(",", ":")).encode())
192
+ if size > MAX_CHAT_START_BYTES:
193
+ raise ProtocolError(f"Chat start frame is too large ({size} bytes).")
194
+ return frame
195
+
196
+
197
+ def websocket_url(base_url: str, *, agentic: bool = True) -> str:
198
+ parsed = urlparse(base_url)
199
+ scheme = "wss" if parsed.scheme == "https" else "ws"
200
+ path = "/api/copperpilot/analyze/agent/ws" if agentic else "/api/copperpilot/analyze/chat/ws"
201
+ return urlunparse((scheme, parsed.netloc, path, "", "", ""))
202
+
203
+
204
+ def sanitize_status(value: str) -> str:
205
+ """Convert hosted status HTML to bounded terminal-safe plain markdown."""
206
+ safe = re.sub(
207
+ r"<(?:script|style)\b[^>]*>[\s\S]*?</(?:script|style)>",
208
+ "",
209
+ value,
210
+ flags=re.IGNORECASE,
211
+ )
212
+ text = (
213
+ markdownify(safe, escape_underscores=False).strip()
214
+ if re.search(r"</?[A-Za-z][^>]*>", safe)
215
+ else safe.strip()
216
+ )
217
+ text = _TERMINAL_ESCAPE.sub("", text)
218
+ text = "".join(character for character in text if character in "\n\t" or ord(character) >= 32)
219
+ return _truncate_utf8(text, 16 * 1024)
220
+
221
+
222
+ ToolHandler = Callable[[ToolRequest], Awaitable[Any]]
223
+
224
+
225
+ class HostedChatClient:
226
+ """One hosted CopperPilot chat stream."""
227
+
228
+ def __init__(self, base_url: str, api_key: str, fingerprint: str) -> None:
229
+ self.base_url = base_url.rstrip("/")
230
+ self.api_key = api_key
231
+ self.fingerprint = fingerprint
232
+ self._socket: Any = None
233
+ self._owner: str | None = None
234
+ self.first_frame_timeout = 60.0
235
+ self.idle_frame_timeout = 300.0
236
+ self.frame_budget = 10_000
237
+
238
+ async def cancel(self) -> None:
239
+ if self._socket is not None:
240
+ await self._socket.send(json.dumps({"type": "cancel"}))
241
+
242
+ async def stream(
243
+ self,
244
+ request: ChatRequest,
245
+ tool_handler: ToolHandler,
246
+ ) -> AsyncIterator[CopperEvent]:
247
+ """Retry transient connection failures only before any event is observed."""
248
+ for attempt in range(2):
249
+ observed = False
250
+ try:
251
+ async for event in self._stream_once(request, tool_handler):
252
+ observed = True
253
+ yield event
254
+ return
255
+ except (OSError, TimeoutError, ConnectionClosed) as exc:
256
+ logger.warning(
257
+ "Hosted chat transport failed before completion (%s, observed=%s, attempt=%s)",
258
+ type(exc).__name__,
259
+ observed,
260
+ attempt + 1,
261
+ )
262
+ if observed or attempt == 1:
263
+ raise
264
+ await asyncio.sleep(0.25)
265
+
266
+ async def _stream_once(
267
+ self,
268
+ request: ChatRequest,
269
+ tool_handler: ToolHandler,
270
+ ) -> AsyncIterator[CopperEvent]:
271
+ """Stream normalized events and fulfill local tool requests."""
272
+ if self._socket is not None:
273
+ raise ProtocolError("A chat stream is already active.")
274
+ headers = {
275
+ "Authorization": f"Bearer {self.api_key}",
276
+ "User-Agent": "CopperPilot CLI/0.1.0",
277
+ "X-CopperPilot-Fingerprint": self.fingerprint,
278
+ }
279
+ saw_final = False
280
+ url = websocket_url(self.base_url, agentic=request.agentic_mode)
281
+ ssl_context = (
282
+ ssl.create_default_context(cafile=certifi.where()) if url.startswith("wss://") else None
283
+ )
284
+ logger.info(
285
+ "Opening hosted chat transport (host=%s, mode=%s)",
286
+ urlparse(url).netloc,
287
+ request.mode.value,
288
+ )
289
+ async with connect(
290
+ url,
291
+ additional_headers=headers,
292
+ open_timeout=30,
293
+ max_size=16 * 1024 * 1024,
294
+ ssl=ssl_context,
295
+ ) as socket:
296
+ self._socket = socket
297
+ self._owner = request.conversation_id
298
+ await socket.send(json.dumps(make_chat_start(request), separators=(",", ":")))
299
+ try:
300
+ frame_count = 0
301
+ stream_bytes = 0
302
+ while not saw_final:
303
+ timeout = (
304
+ self.first_frame_timeout if frame_count == 0 else self.idle_frame_timeout
305
+ )
306
+ try:
307
+ raw = await asyncio.wait_for(socket.recv(), timeout)
308
+ except TimeoutError as exc:
309
+ phase = "first frame" if frame_count == 0 else "next frame"
310
+ message = f"Timed out waiting for the hosted chat {phase}."
311
+ raise ProtocolError(message) from exc
312
+ except ConnectionClosed:
313
+ break
314
+ frame_count += 1
315
+ stream_bytes += len(raw.encode() if isinstance(raw, str) else raw)
316
+ if frame_count > self.frame_budget:
317
+ raise ProtocolError("Chat exceeded the client frame budget.")
318
+ if stream_bytes > MAX_STREAM_BYTES:
319
+ raise ProtocolError("Chat exceeded the client stream byte budget.")
320
+ try:
321
+ frame = json.loads(raw)
322
+ except (json.JSONDecodeError, TypeError) as exc:
323
+ raise ProtocolError("Hosted chat returned invalid JSON.") from exc
324
+ if not isinstance(frame, dict):
325
+ raise ProtocolError("Hosted chat frame must be a JSON object.")
326
+ frame_type = frame.get("type")
327
+ if frame_type == "error":
328
+ message = frame.get("public_message") or frame.get("message")
329
+ safe_message = sanitize_status(str(message or "Hosted chat failed."))
330
+ yield CopperEvent(EventKind.ERROR, safe_message)
331
+ raise ProtocolError(safe_message)
332
+ if frame_type not in {"event", "final"}:
333
+ raise ProtocolError(f"Unknown frame type: {frame_type!r}")
334
+ try:
335
+ response = ChatResponse.model_validate(frame.get("chat_response") or {})
336
+ except ValueError as exc:
337
+ raise ProtocolError("Hosted chat response has an invalid shape.") from exc
338
+ metadata: dict[str, Any] = {
339
+ "turn_id": response.turn_id,
340
+ "sequence": response.sequence,
341
+ "resume_attempt": response.resume_attempt,
342
+ }
343
+ if response.response:
344
+ yield CopperEvent(
345
+ EventKind.TEXT, sanitize_status(response.response), **metadata
346
+ )
347
+ if response.reasoning_update:
348
+ yield CopperEvent(
349
+ EventKind.REASONING,
350
+ sanitize_status(response.reasoning_update),
351
+ sanitize_status(response.reasoning_title)
352
+ if response.reasoning_title
353
+ else None,
354
+ **metadata,
355
+ )
356
+ if response.status_update:
357
+ yield CopperEvent(
358
+ EventKind.STATUS, sanitize_status(response.status_update), **metadata
359
+ )
360
+ if response.status_event:
361
+ yield CopperEvent(EventKind.STATUS_EVENT, response.status_event, **metadata)
362
+ usage = response.usage
363
+ envelope_usage = frame.get("usage_details")
364
+ if isinstance(envelope_usage, dict):
365
+ usage = {**(usage or {}), **envelope_usage}
366
+ if usage:
367
+ yield CopperEvent(EventKind.USAGE, usage, **metadata)
368
+ if response.tool_request:
369
+ yield CopperEvent(EventKind.TOOL_REQUEST, response.tool_request, **metadata)
370
+ try:
371
+ result = await tool_handler(response.tool_request)
372
+ except Exception as exc:
373
+ result = {
374
+ "error": f"{type(exc).__name__}: {exc}",
375
+ "rejected": type(exc).__name__ == "ToolRejected",
376
+ }
377
+ result_frame = {
378
+ "type": "tool_result",
379
+ "conversation_id": request.conversation_id,
380
+ "tool_call_id": response.tool_request.tool_call_id,
381
+ "result": result,
382
+ }
383
+ encoded = json.dumps(result_frame, separators=(",", ":")).encode()
384
+ if len(encoded) > MAX_TOOL_RESULT_BYTES:
385
+ result_frame["result"] = {
386
+ "error": "Tool result exceeded the client frame limit."
387
+ }
388
+ if self._owner != request.conversation_id:
389
+ raise ProtocolError("Chat socket owner changed during tool execution.")
390
+ await socket.send(json.dumps(result_frame, separators=(",", ":")))
391
+ yield CopperEvent(
392
+ EventKind.TOOL_RESULT,
393
+ {
394
+ "tool_call_id": response.tool_request.tool_call_id,
395
+ "tool_name": response.tool_request.tool_name,
396
+ "result": result_frame["result"],
397
+ },
398
+ **metadata,
399
+ )
400
+ if frame_type == "final" or response.is_final:
401
+ saw_final = True
402
+ yield CopperEvent(
403
+ EventKind.FINAL,
404
+ {
405
+ "success": response.success,
406
+ "error": response.error,
407
+ "is_final": True,
408
+ "usage": response.usage,
409
+ "suggestions": response.suggestions,
410
+ "usage_details": envelope_usage
411
+ if isinstance(envelope_usage, dict)
412
+ else None,
413
+ },
414
+ **metadata,
415
+ )
416
+ break
417
+ finally:
418
+ self._socket = None
419
+ self._owner = None
420
+ if not saw_final:
421
+ raise ProtocolError("Chat socket closed before a final response.")
@@ -0,0 +1,34 @@
1
+ """CopperPilot semantic terminal colors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class ThemeColors:
10
+ background: str = "#11121D"
11
+ surface: str = "#1A1B2E"
12
+ panel: str = "#25283B"
13
+ text: str = "#C0CAF5"
14
+ muted: str = "#7A83A8"
15
+ primary: str = "#EA580C"
16
+ active: str = "#FB923C"
17
+ success: str = "#9ECE6A"
18
+ warning: str = "#F97316"
19
+ error: str = "#F7768E"
20
+
21
+
22
+ COLORS = ThemeColors()
23
+
24
+
25
+ def css_variable_defaults() -> dict[str, str]:
26
+ return {
27
+ "mode-bash": COLORS.error,
28
+ "mode-command": COLORS.active,
29
+ "mode-incognito": COLORS.muted,
30
+ "skill": COLORS.primary,
31
+ "skill-hover": COLORS.active,
32
+ "tool": COLORS.primary,
33
+ "tool-hover": COLORS.active,
34
+ }
@@ -0,0 +1,23 @@
1
+ """Compatibility exports for CopperPilot's Deep Agents tool bridge."""
2
+
3
+ from copper_pilot_cli.deepagents_tools import (
4
+ ApprovalMode,
5
+ ApprovalRequest,
6
+ LocalToolBroker,
7
+ ToolRejected,
8
+ canonical_tool_name,
9
+ dangerous_shell_command,
10
+ normalized_tool_names,
11
+ routine_action,
12
+ )
13
+
14
+ __all__ = [
15
+ "ApprovalMode",
16
+ "ApprovalRequest",
17
+ "LocalToolBroker",
18
+ "ToolRejected",
19
+ "canonical_tool_name",
20
+ "dangerous_shell_command",
21
+ "normalized_tool_names",
22
+ "routine_action",
23
+ ]
@@ -0,0 +1,32 @@
1
+ """PyPI-backed CopperPilot update checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from dataclasses import dataclass
7
+
8
+ import httpx
9
+ from packaging.version import Version
10
+
11
+ from copper_pilot_cli._version import PYPI_URL, __version__
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class UpdateInfo:
16
+ current: str
17
+ latest: str
18
+ available: bool
19
+ install_command: str
20
+
21
+
22
+ async def check_for_update() -> UpdateInfo:
23
+ async with httpx.AsyncClient(timeout=10) as client:
24
+ response = await client.get(PYPI_URL)
25
+ response.raise_for_status()
26
+ latest = str(response.json()["info"]["version"])
27
+ return UpdateInfo(
28
+ current=__version__,
29
+ latest=latest,
30
+ available=Version(latest) > Version(__version__),
31
+ install_command=f"{sys.executable} -m pip install --upgrade copper-pilot-cli",
32
+ )