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,37 @@
1
+ """CopperPilot hosted agent, terminal client, and LangChain adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from copper_pilot_cli._version import __version__
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Callable
11
+
12
+ __all__ = [
13
+ "__version__",
14
+ "cli_main", # noqa: F822 # resolved lazily by __getattr__
15
+ ]
16
+
17
+
18
+ def __getattr__(name: str) -> Callable[[], None]:
19
+ """Lazy import for `cli_main` to avoid loading `main.py` at package import.
20
+
21
+ `copper_main.py` pulls in Textual and startup machinery
22
+ that isn't needed when submodules like `config` or `widgets` are
23
+ imported directly.
24
+
25
+ Returns:
26
+ The requested callable.
27
+
28
+ Raises:
29
+ AttributeError: If *name* is not a lazily-provided attribute.
30
+
31
+ """
32
+ if name == "cli_main":
33
+ from copper_pilot_cli.copper_main import cli_main
34
+
35
+ return cli_main
36
+ msg = f"module {__name__!r} has no attribute {name!r}"
37
+ raise AttributeError(msg)
@@ -0,0 +1,6 @@
1
+ """Allow running the CLI as: `python -m copper_pilot_cli`."""
2
+
3
+ from copper_pilot_cli import cli_main
4
+
5
+ if __name__ == "__main__":
6
+ cli_main()
File without changes
@@ -0,0 +1,46 @@
1
+ {
2
+ "project": "Deep Agents Code",
3
+ "repository": "https://github.com/langchain-ai/deepagents",
4
+ "package_version": "0.1.69",
5
+ "revision": "1d3232c0852c47af09119edea10eeec887e4f0da",
6
+ "license": "MIT",
7
+ "files": [
8
+ {
9
+ "local_path": "diff_utils.py",
10
+ "upstream_path": "libs/code/deepagents_code/diff_utils.py",
11
+ "sha256": "3085c4f249a22b06244c34e5f115ae005cb56456eecefd7f34c92d47861e8f8c",
12
+ "patch_status": "unchanged"
13
+ }
14
+ ],
15
+ "adapted_files": [
16
+ {
17
+ "local_path": "../../clipboard.py",
18
+ "upstream_paths": [
19
+ "libs/code/deepagents_code/clipboard.py",
20
+ "libs/code/deepagents_code/app.py"
21
+ ]
22
+ },
23
+ {
24
+ "local_path": "../../textual_patches.py",
25
+ "upstream_paths": [
26
+ "libs/code/deepagents_code/_textual_patches.py"
27
+ ]
28
+ },
29
+ {
30
+ "local_path": "../../copper_widgets.py",
31
+ "upstream_paths": [
32
+ "libs/code/deepagents_code/tui/widgets/chat_input.py",
33
+ "libs/code/deepagents_code/tui/widgets/autocomplete.py"
34
+ ]
35
+ }
36
+ ],
37
+ "intentional_differences": [
38
+ "CopperPilot branding and hosted authentication",
39
+ "CopperPilot hosted WebSocket transport instead of a local provider runtime",
40
+ "No LangSmith, MCP, plugins, sandboxes, local model setup, or computer use",
41
+ "CLI-owned deterministic Auto policy with independent normal and dangerous shell settings",
42
+ "Provider context-window metrics are omitted because the hosted protocol has no truthful values",
43
+ "Successful tool calls use dcode-style compact groups driven by CopperPilot hosted events",
44
+ "Uninterrupted successful reasoning and tools collapse with CopperPilot desktop Worked-for timing"
45
+ ]
46
+ }
File without changes
@@ -0,0 +1,222 @@
1
+ r"""Shared unified-diff helpers.
2
+
3
+ Every diff passing through this module is `"\n"`-joined from lines that came
4
+ from `splitlines()` or `split("\n")`, so no element can contain a line boundary.
5
+ That is what makes `split_diff_lines` the exact inverse and `splitlines()` wrong
6
+ here — see its docstring for what breaks. Check any helper added to this module,
7
+ and any new producer of a diff it reads, against that invariant.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import dataclass
14
+ from typing import Final
15
+
16
+ HUNK_RE: Final[re.Pattern[str]] = re.compile(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))?")
17
+ """Matches a hunk header.
18
+
19
+ Captures, in order: old start line, old line count, new start line, new line
20
+ count. Either count is absent for a single-line range, where it defaults to 1.
21
+ """
22
+
23
+
24
+ DIFF_TRUNCATION_MARKER: Final[str] = "..."
25
+ """Stand-in line marking where a diff body was clipped for display.
26
+
27
+ Written by `compute_unified_diff` and rendered as a `truncated` row. It is also
28
+ the signal that any counts recomputed from the body would be short — see
29
+ `DiffMessage._recount`, which returns `None` rather than a known-low number.
30
+
31
+ Match it with `is_truncation_marker` rather than by hand: the renderer and the
32
+ recount must agree, or a body that renders "diff truncated" is also counted as
33
+ if it were complete.
34
+ """
35
+
36
+
37
+ def is_truncation_marker(line: str) -> bool:
38
+ """Return whether a diff line is the truncation marker.
39
+
40
+ One predicate for both readers, which previously disagreed: the renderer
41
+ stripped, the recount compared exactly.
42
+
43
+ Exact, deliberately. `compute_unified_diff` writes the marker bare, while
44
+ every real diff line carries a `+`, `-`, or space prefix — so an exact match
45
+ cannot collide with file content, and a source line whose own text is `...`
46
+ arrives here as `" ..."` and stays a context row. Stripping would classify
47
+ that line as a clipped body and suppress the change counts for a diff that
48
+ is complete.
49
+
50
+ Args:
51
+ line: A single unified-diff line.
52
+
53
+ Returns:
54
+ Whether the line marks a clipped body.
55
+ """
56
+ return line == DIFF_TRUNCATION_MARKER
57
+
58
+
59
+ @dataclass(frozen=True, kw_only=True)
60
+ class DiffStats:
61
+ """Line counts for a change, named so the pair cannot be swapped silently.
62
+
63
+ Keyword-only and frozen so that claim holds at construction as well as in
64
+ transit: a positional pair is exactly the transposition this type exists to
65
+ rule out, and the counts are read long after they are computed.
66
+
67
+ Attributes:
68
+ additions: Added change lines, excluding file headers, counted before
69
+ any truncation of the body for display.
70
+ deletions: Removed change lines, on the same terms.
71
+ """
72
+
73
+ additions: int
74
+ deletions: int
75
+
76
+ def __post_init__(self) -> None:
77
+ """Reject negative counts.
78
+
79
+ Both in-repo producers derive from `difflib`, so this only guards direct
80
+ construction — but the type is public and reaches a delete prompt, where
81
+ the number gates destroying a file.
82
+
83
+ Raises:
84
+ ValueError: If either count is negative.
85
+ """
86
+ if self.additions < 0 or self.deletions < 0:
87
+ msg = (
88
+ f"DiffStats counts cannot be negative, got additions="
89
+ f"{self.additions}, deletions={self.deletions}"
90
+ )
91
+ raise ValueError(msg)
92
+
93
+
94
+ def split_diff_lines(diff: str) -> list[str]:
95
+ r"""Split a unified diff back into the lines it was assembled from.
96
+
97
+ Deliberately not `splitlines()`. Every diff reaching this function is
98
+ `"\n"`-joined from lines that themselves came from `splitlines()` or
99
+ `split("\n")`, so no element can contain a line boundary and `"\n"` is the
100
+ exact inverse. `splitlines()` also breaks on `\r`, `\v`, `\f`, U+2028,
101
+ U+2029 and U+0085, which splits a single diff line into fragments. The tail
102
+ fragment carries no `+`/`-` marker, so it would render as an unmarked note —
103
+ on the approval prompt that means changed content shown as neutral metadata.
104
+
105
+ Check any new producer against that invariant rather than against a list of
106
+ the current ones.
107
+
108
+ Args:
109
+ diff: Unified diff string.
110
+
111
+ Returns:
112
+ The diff's lines, without a trailing empty entry for a terminating
113
+ newline.
114
+ """
115
+ lines = diff.split("\n")
116
+ if lines and not lines[-1]:
117
+ lines.pop()
118
+ return lines
119
+
120
+
121
+ def file_header_indexes(lines: list[str]) -> set[int]:
122
+ """Locate paired file headers immediately preceding a hunk.
123
+
124
+ A `---`/`+++` pair is only a file header when it appears *outside* a hunk
125
+ body — a diff of a file that itself contains such lines would otherwise
126
+ have its content mistaken for metadata. That is why this walks the hunks'
127
+ declared old/new line budgets instead of just matching on the prefix.
128
+
129
+ Args:
130
+ lines: Unified-diff lines. Handles multi-file diffs, where headers
131
+ recur between hunks.
132
+
133
+ Returns:
134
+ Indexes of file-header lines.
135
+ """
136
+ indexes: set[int] = set()
137
+ old_remaining = new_remaining = 0
138
+ inside_hunk = False
139
+ for index, line in enumerate(lines):
140
+ if match := HUNK_RE.match(line):
141
+ old_remaining = int(match.group(2) or 1)
142
+ new_remaining = int(match.group(4) or 1)
143
+ inside_hunk = old_remaining > 0 or new_remaining > 0
144
+ continue
145
+ if inside_hunk:
146
+ if _opens_file_header(lines, index):
147
+ # The budget says this hunk is still running, but a full
148
+ # `---`/`+++`/`@@` sequence starts here — so the budget
149
+ # over-declared and the next file has begun. Without this the
150
+ # `--- a/y.py` is consumed as a deletion, the following headers
151
+ # render as source rows, and the change counts include them.
152
+ # The three-line shape is what makes this safe: a removed line
153
+ # can read `--- something`, but not while the two lines after
154
+ # it also form a header pair and a hunk header.
155
+ inside_hunk = False
156
+ elif line.startswith("\\"):
157
+ # "" annotates the line before it and
158
+ # belongs to neither budget.
159
+ continue
160
+ elif line.startswith("-"):
161
+ old_remaining -= 1
162
+ elif line.startswith("+"):
163
+ new_remaining -= 1
164
+ elif line.startswith(" "):
165
+ old_remaining -= 1
166
+ new_remaining -= 1
167
+ else:
168
+ # Not a hunk body line: the declared budget over-counts, or the
169
+ # producer is not `difflib`. End the hunk here and re-read this
170
+ # line as metadata below. Staying inside would consume the rest
171
+ # of the diff — including a following file's `---`/`+++` pair —
172
+ # as body, which counts those headers as a change and renders
173
+ # them as source rows.
174
+ inside_hunk = False
175
+ if inside_hunk:
176
+ # Only lines that consumed budget skip the header check. A
177
+ # removed line may legitimately read `--- something`, and
178
+ # treating it as metadata is the misreading this walk prevents.
179
+ inside_hunk = old_remaining > 0 or new_remaining > 0
180
+ continue
181
+ if _opens_file_header(lines, index):
182
+ indexes.update((index, index + 1))
183
+ return indexes
184
+
185
+
186
+ def _opens_file_header(lines: list[str], index: int) -> bool:
187
+ """Whether a file-header pair immediately preceding a hunk starts here.
188
+
189
+ Args:
190
+ lines: Unified-diff lines.
191
+ index: Position of the candidate `---` line.
192
+
193
+ Returns:
194
+ Whether `lines[index:index + 3]` is `---`, `+++`, and a hunk header.
195
+ """
196
+ return (
197
+ index + 2 < len(lines)
198
+ and lines[index].startswith("--- ")
199
+ and lines[index + 1].startswith("+++ ")
200
+ and HUNK_RE.match(lines[index + 2]) is not None
201
+ )
202
+
203
+
204
+ def count_diff_change_lines(lines: list[str]) -> DiffStats:
205
+ """Count added and removed lines in unified-diff lines.
206
+
207
+ Args:
208
+ lines: Unified-diff lines.
209
+
210
+ Returns:
211
+ Additions and deletions, excluding file headers.
212
+ """
213
+ headers = file_header_indexes(lines)
214
+ additions = deletions = 0
215
+ for index, line in enumerate(lines):
216
+ if index in headers:
217
+ continue
218
+ if line.startswith("+"):
219
+ additions += 1
220
+ elif line.startswith("-"):
221
+ deletions += 1
222
+ return DiffStats(additions=additions, deletions=deletions)
@@ -0,0 +1,32 @@
1
+ """CopperPilot CLI version and update endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+ from pathlib import Path
7
+
8
+ try:
9
+ __version__ = version("copper-pilot-cli")
10
+ except PackageNotFoundError:
11
+ __version__ = (
12
+ Path(__file__).resolve().parents[2].joinpath("VERSION").read_text(encoding="utf-8").strip()
13
+ )
14
+
15
+ DOCS_URL = "https://copperpilot.ai"
16
+ """URL for CopperPilot documentation."""
17
+
18
+ PYPI_URL = "https://pypi.org/pypi/copper-pilot-cli/json"
19
+ """PyPI JSON API endpoint for version checks."""
20
+
21
+ SDK_PYPI_URL = "https://pypi.org/pypi/deepagents/json"
22
+ """PyPI JSON API endpoint for reading `deepagents` SDK release metadata.
23
+
24
+ The CLI only reads release-age metadata from this endpoint; it never
25
+ performs SDK update checks.
26
+ """
27
+
28
+ CHANGELOG_URL = "https://github.com/CopperPilot/copper-pilot-cli/releases"
29
+ """URL for the full changelog."""
30
+
31
+ USER_AGENT = f"copper-pilot-cli/{__version__} update-check"
32
+ """User-Agent header sent with PyPI requests."""
@@ -0,0 +1,107 @@
1
+ """Clipboard helpers adapted from Deep Agents Code 0.1.69."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import logging
7
+ import os
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ import pyperclip
12
+ from textual.dom import NoScreen
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Callable
16
+
17
+ from textual.app import App
18
+ from textual.screen import Screen
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ _PREVIEW_MAX_LENGTH = 40
23
+
24
+
25
+ def _copy_osc52(text: str) -> None:
26
+ """Copy text with OSC 52 for remote terminals and tmux."""
27
+ encoded = base64.b64encode(text.encode("utf-8")).decode("ascii")
28
+ sequence = f"\033]52;c;{encoded}\a"
29
+ if os.environ.get("TMUX"):
30
+ sequence = f"\033Ptmux;\033{sequence}\033\\"
31
+ with Path("/dev/tty").open("w", encoding="utf-8") as tty:
32
+ tty.write(sequence)
33
+ tty.flush()
34
+
35
+
36
+ def _shorten_preview(texts: list[str]) -> str:
37
+ dense_text = "↵".join(texts).replace("\n", "↵")
38
+ if len(dense_text) > _PREVIEW_MAX_LENGTH:
39
+ return f"{dense_text[: _PREVIEW_MAX_LENGTH - 1]}…"
40
+ return dense_text
41
+
42
+
43
+ def copy_text_to_clipboard(app: App[object], text: str) -> tuple[bool, str | None]:
44
+ """Try the same clipboard backends as dcode, in reliability order."""
45
+ methods: list[Callable[[str], object]] = [
46
+ pyperclip.copy,
47
+ app.copy_to_clipboard,
48
+ _copy_osc52,
49
+ ]
50
+ last_error: str | None = None
51
+ for copy in methods:
52
+ try:
53
+ copy(text)
54
+ except (OSError, RuntimeError, TypeError, ValueError) as exc:
55
+ last_error = str(exc) or type(exc).__name__
56
+ logger.debug(
57
+ "Clipboard method %s failed: %s",
58
+ getattr(copy, "__name__", repr(copy)),
59
+ exc,
60
+ exc_info=True,
61
+ )
62
+ else:
63
+ return True, None
64
+ return False, last_error
65
+
66
+
67
+ def copy_selection_to_clipboard(app: App[object], *, screen: Screen[object]) -> None:
68
+ """Copy all non-empty selections owned by the screen that received mouse-up."""
69
+ selected_texts: list[str] = []
70
+ for widget in screen.query("*"):
71
+ if not getattr(widget, "is_attached", False):
72
+ continue
73
+ try:
74
+ selection = widget.text_selection
75
+ except (NoScreen, AttributeError) as exc:
76
+ logger.debug("Skipping detached selection widget: %s", exc)
77
+ continue
78
+ if not selection:
79
+ continue
80
+ try:
81
+ result = widget.get_selection(selection)
82
+ except (AttributeError, TypeError, ValueError, IndexError) as exc:
83
+ logger.debug("Unable to extract widget selection: %s", exc, exc_info=True)
84
+ continue
85
+ if result:
86
+ selected_text, _ = result
87
+ if selected_text.strip():
88
+ selected_texts.append(selected_text)
89
+
90
+ if not selected_texts:
91
+ return
92
+
93
+ success, _ = copy_text_to_clipboard(app, "\n".join(selected_texts))
94
+ if success:
95
+ app.notify(
96
+ f'"{_shorten_preview(selected_texts)}" copied',
97
+ severity="information",
98
+ timeout=2,
99
+ markup=False,
100
+ )
101
+ else:
102
+ app.notify(
103
+ "Failed to copy - no clipboard method available",
104
+ severity="warning",
105
+ timeout=3,
106
+ markup=False,
107
+ )
@@ -0,0 +1,24 @@
1
+ """Small authenticated HTTP surface used by the CopperPilot TUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from copper_pilot_cli.copper_auth import DeviceCredential
10
+
11
+
12
+ async def token_limits(credential: DeviceCredential) -> dict[str, Any]:
13
+ """Fetch the account's authoritative token/usage limits."""
14
+ async with httpx.AsyncClient(timeout=15) as client:
15
+ response = await client.post(
16
+ f"{credential.base_url}/api/copperpilot/analyze/token_limits",
17
+ headers={
18
+ "Authorization": f"Bearer {credential.api_key}",
19
+ "X-CopperPilot-Fingerprint": credential.fingerprint,
20
+ },
21
+ )
22
+ response.raise_for_status()
23
+ value = response.json()
24
+ return value if isinstance(value, dict) else {}