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,232 @@
1
+ """Browser device login and private credential persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import platform
11
+ import re
12
+ import socket
13
+ import stat
14
+ import tempfile
15
+ import time
16
+ import webbrowser
17
+ from dataclasses import asdict, dataclass
18
+ from pathlib import Path
19
+ from typing import Any
20
+ from urllib.parse import quote
21
+
22
+ import httpx
23
+
24
+ from copper_pilot_cli.copper_config import api_base_url, paths
25
+
26
+ _STORE_VERSION = 1
27
+ _KEY_PREFIX = "cf_live_"
28
+ _FLOW_TOKEN_RE = re.compile(r"^[A-Za-z0-9._~-]{8,512}$")
29
+
30
+
31
+ class AuthenticationError(RuntimeError):
32
+ """Authentication could not be completed."""
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class DeviceCredential:
37
+ """Credential returned by CopperPilot's device login."""
38
+
39
+ api_key: str
40
+ fingerprint: str
41
+ base_url: str
42
+ issued_at: str | None = None
43
+ expires_at: str | None = None
44
+
45
+
46
+ def _total_memory() -> int:
47
+ if os.name == "posix":
48
+ with contextlib.suppress(ValueError, OSError, AttributeError):
49
+ return int(os.sysconf("SC_PAGE_SIZE")) * int(os.sysconf("SC_PHYS_PAGES"))
50
+ return 0
51
+
52
+
53
+ def safe_hostname() -> str:
54
+ """Return the same URL-safe hostname shape as the desktop client."""
55
+ value = re.sub(r"[^a-z0-9-]", "_", socket.gethostname().lower())
56
+ value = re.sub(r"_{2,}", "_", value).strip("_")[:50]
57
+ return value or "copper_pilot_client"
58
+
59
+
60
+ def build_fingerprint() -> str:
61
+ """Build a stable, non-secret device fingerprint."""
62
+ signature = "||".join(
63
+ [
64
+ platform.system().lower(),
65
+ platform.machine().lower(),
66
+ str(_total_memory()),
67
+ platform.processor() or "unknown",
68
+ ]
69
+ )
70
+ return f"{safe_hostname()}_{hashlib.sha256(signature.encode()).hexdigest()}"
71
+
72
+
73
+ def normalize_flow_token(value: object) -> str:
74
+ """Validate a device-login flow token before it enters a URL."""
75
+ token = str(value or "").strip()
76
+ if token.lower() in {"", "null", "undefined"} or not _FLOW_TOKEN_RE.fullmatch(token):
77
+ raise AuthenticationError("The device login flow token is invalid.")
78
+ return token
79
+
80
+
81
+ def _private_dir(path: Path) -> None:
82
+ path.mkdir(parents=True, exist_ok=True)
83
+ if os.name != "nt":
84
+ path.chmod(stat.S_IRWXU)
85
+
86
+
87
+ def save_credential(credential: DeviceCredential, target: Path | None = None) -> tuple[str, ...]:
88
+ """Atomically save a credential with dcode-equivalent file protections."""
89
+ destination = target or paths().auth
90
+ _private_dir(destination.parent)
91
+ payload = json.dumps(
92
+ {"version": _STORE_VERSION, "credential": asdict(credential)},
93
+ separators=(",", ":"),
94
+ ).encode()
95
+ warnings: list[str] = []
96
+ fd, temporary_name = tempfile.mkstemp(prefix="auth-", suffix=".tmp", dir=destination.parent)
97
+ temporary = Path(temporary_name)
98
+ try:
99
+ with os.fdopen(fd, "wb") as handle:
100
+ handle.write(payload)
101
+ if os.name != "nt":
102
+ temporary.chmod(stat.S_IRUSR | stat.S_IWUSR)
103
+ temporary.replace(destination)
104
+ if os.name != "nt":
105
+ destination.chmod(stat.S_IRUSR | stat.S_IWUSR)
106
+ except Exception:
107
+ with contextlib.suppress(OSError):
108
+ temporary.unlink()
109
+ raise
110
+ if os.name != "nt":
111
+ mode = stat.S_IMODE(destination.stat().st_mode)
112
+ if mode != 0o600:
113
+ warnings.append(f"Could not restrict {destination} to mode 0600.")
114
+ return tuple(warnings)
115
+
116
+
117
+ def load_credential(target: Path | None = None) -> DeviceCredential | None:
118
+ """Load and validate the stored device credential."""
119
+ destination = target or paths().auth
120
+ try:
121
+ data = json.loads(destination.read_text(encoding="utf-8"))
122
+ except FileNotFoundError:
123
+ return None
124
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
125
+ raise AuthenticationError(f"Credential store is unreadable: {destination}") from exc
126
+ if not isinstance(data, dict) or data.get("version") != _STORE_VERSION:
127
+ raise AuthenticationError("Credential store has an unsupported format.")
128
+ item = data.get("credential")
129
+ if not isinstance(item, dict):
130
+ raise AuthenticationError("Credential store has no device credential.")
131
+ credential = DeviceCredential(
132
+ api_key=str(item.get("api_key") or ""),
133
+ fingerprint=str(item.get("fingerprint") or ""),
134
+ base_url=str(item.get("base_url") or ""),
135
+ issued_at=item.get("issued_at"),
136
+ expires_at=item.get("expires_at"),
137
+ )
138
+ if not credential.api_key.startswith(_KEY_PREFIX) or not credential.fingerprint:
139
+ raise AuthenticationError("Stored CopperPilot credential is invalid.")
140
+ return credential
141
+
142
+
143
+ def clear_credential(target: Path | None = None) -> bool:
144
+ """Delete the stored credential."""
145
+ destination = target or paths().auth
146
+ try:
147
+ destination.unlink()
148
+ except FileNotFoundError:
149
+ return False
150
+ return True
151
+
152
+
153
+ async def validate_credential(credential: DeviceCredential) -> bool:
154
+ """Validate credential lifecycle status without logging secret content."""
155
+ async with httpx.AsyncClient(timeout=15) as client:
156
+ response = await client.post(
157
+ f"{credential.base_url}/api/copperpilot/analyze/token_limits",
158
+ headers={
159
+ "Authorization": f"Bearer {credential.api_key}",
160
+ "X-CopperPilot-Fingerprint": credential.fingerprint,
161
+ },
162
+ )
163
+ if response.status_code in {401, 403}:
164
+ return False
165
+ response.raise_for_status()
166
+ return True
167
+
168
+
169
+ class DeviceLogin:
170
+ """Run CopperPilot's browser-based device login flow."""
171
+
172
+ def __init__(self, base_url: str | None = None, client_version: str = "0.1.0") -> None:
173
+ self.base_url = (base_url or api_base_url()).rstrip("/")
174
+ self.client_version = client_version
175
+
176
+ async def login(self, *, open_browser: bool = True) -> DeviceCredential:
177
+ """Start a flow, open the browser, and poll until ready."""
178
+ fingerprint = build_fingerprint()
179
+ endpoint = f"{self.base_url}/api/copperpilot/auth/api-keys-device-login-flow"
180
+ async with httpx.AsyncClient(timeout=30) as client:
181
+ response = await client.post(
182
+ endpoint,
183
+ json={
184
+ "host_name": safe_hostname(),
185
+ "fingerprint": fingerprint,
186
+ "platform": platform.system().lower(),
187
+ "client_version": self.client_version,
188
+ },
189
+ )
190
+ response.raise_for_status()
191
+ body = response.json()
192
+ token = normalize_flow_token(body.get("flow_token"))
193
+ if open_browser:
194
+ webbrowser.open(f"{self.base_url}/createKey?flow_token={quote(token)}")
195
+ started = time.monotonic()
196
+ expiry_seconds = min(
197
+ max(int(body.get("expires_in_seconds") or body.get("expires_in") or 300), 1),
198
+ 900,
199
+ )
200
+ while time.monotonic() - started < expiry_seconds:
201
+ elapsed = time.monotonic() - started
202
+ delay = 0.3 if elapsed < 10 else 1.0
203
+ poll = await client.get(endpoint, params={"flow_token": token})
204
+ if poll.status_code == 200:
205
+ result: dict[str, Any] = poll.json()
206
+ key = str(result.get("api_key") or "")
207
+ if result.get("status") == "ready" and key.startswith(_KEY_PREFIX):
208
+ credential = DeviceCredential(
209
+ api_key=key,
210
+ fingerprint=fingerprint,
211
+ base_url=self.base_url,
212
+ issued_at=result.get("issued_at"),
213
+ expires_at=result.get("expires_at"),
214
+ )
215
+ save_credential(credential)
216
+ return credential
217
+ elif poll.status_code in {401, 410}:
218
+ raise AuthenticationError("Device login expired or was rejected.")
219
+ elif poll.status_code == 409:
220
+ pending = poll.json()
221
+ if pending.get("error_code") not in {None, "FLOW_PENDING"}:
222
+ raise AuthenticationError(
223
+ str(pending.get("message") or "Device login failed.")
224
+ )
225
+ with contextlib.suppress(ValueError, TypeError, json.JSONDecodeError):
226
+ retry = pending.get("retry_after_ms")
227
+ if retry is not None:
228
+ delay = max(0.2, min(float(retry) / 1000, 5))
229
+ else:
230
+ poll.raise_for_status()
231
+ await asyncio.sleep(delay)
232
+ raise AuthenticationError("Device login timed out.")
@@ -0,0 +1,62 @@
1
+ """CopperPilot CLI configuration and data paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from urllib.parse import urlparse
9
+
10
+ DEFAULT_API_URL = "https://copperpilot.ai"
11
+ APP_HOME_ENV = "COPPER_PILOT_CLI_HOME"
12
+ API_URL_ENV = "COPPER_PILOT_API_URL"
13
+
14
+
15
+ def normalize_base_url(value: str | None) -> str:
16
+ """Return a normalized HTTP(S) origin or an empty string."""
17
+ raw = (value or "").strip()
18
+ if not raw:
19
+ return ""
20
+ parsed = urlparse(raw)
21
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
22
+ return ""
23
+ return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
24
+
25
+
26
+ def app_home() -> Path:
27
+ """Return the private per-user CopperPilot CLI home."""
28
+ override = os.getenv(APP_HOME_ENV)
29
+ return Path(override).expanduser() if override else Path.home() / ".copper-pilot"
30
+
31
+
32
+ def api_base_url() -> str:
33
+ """Return the configured API URL, defaulting to production."""
34
+ return normalize_base_url(os.getenv(API_URL_ENV)) or DEFAULT_API_URL
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class AppPaths:
39
+ """Resolved application paths."""
40
+
41
+ home: Path
42
+ state: Path
43
+ auth: Path
44
+ sessions: Path
45
+ history: Path
46
+ logs: Path
47
+ recent_projects: Path
48
+
49
+
50
+ def paths() -> AppPaths:
51
+ """Resolve application paths at call time for testability."""
52
+ home = app_home()
53
+ state = home / ".state"
54
+ return AppPaths(
55
+ home=home,
56
+ state=state,
57
+ auth=state / "auth.json",
58
+ sessions=state / "sessions.db",
59
+ history=state / "history.jsonl",
60
+ logs=state / "logs",
61
+ recent_projects=state / "recent-projects.json",
62
+ )
@@ -0,0 +1,263 @@
1
+ """CopperPilot project skills, plans, attachments, and KiCad paste handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import httpx
11
+ import yaml
12
+
13
+ from copper_pilot_cli.copper_auth import DeviceCredential
14
+ from copper_pilot_cli.copper_protocol import PlanContext, PlanTodo
15
+ from copper_pilot_cli.copper_widgets import CompletionEntry
16
+
17
+ MAX_LOCAL_SKILLS = 32
18
+ MAX_SKILL_BYTES = 64 * 1024
19
+ MAX_INSTRUCTIONS = 8_000
20
+ MAX_DESCRIPTION = 500
21
+ MAX_METADATA = 300
22
+ MAX_SNIPPET_CHARS = 2_000_000
23
+ SKILL_ID = re.compile(r"^[a-z0-9][a-z0-9-]*$")
24
+ FRONTMATTER = re.compile(r"^---\r?\n([\s\S]*?)\r?\n---\r?\n?")
25
+ KICAD_PREFIXES = (
26
+ "(lib_symbols",
27
+ "(kicad_pcb",
28
+ "(footprint",
29
+ "(symbol",
30
+ "(text",
31
+ "(version",
32
+ "(generator",
33
+ "(wire",
34
+ "(junction",
35
+ "(global_label",
36
+ "(net ",
37
+ "(net\t",
38
+ "(net\n",
39
+ )
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class LocalSkill:
44
+ id: str
45
+ label: str
46
+ description: str
47
+ required_inputs: str
48
+ artifact_outputs: str
49
+ instructions: str
50
+ path: str
51
+
52
+ def manifest_entry(self) -> dict[str, Any]:
53
+ return {
54
+ "id": self.id,
55
+ "label": self.label,
56
+ "slash": f"/{self.id}",
57
+ "category": "local",
58
+ "description": self.description,
59
+ "required_inputs": self.required_inputs,
60
+ "artifact_outputs": self.artifact_outputs,
61
+ "instructions": self.instructions,
62
+ "source": {
63
+ "type": "local",
64
+ "path": self.path,
65
+ "folder": str(Path(self.path).parent),
66
+ },
67
+ }
68
+
69
+ def command_entry(self) -> CompletionEntry:
70
+ return CompletionEntry(
71
+ name=f"/{self.id}",
72
+ description=f"Skill · {self.description}",
73
+ display_name=f"/{self.id} [skill]",
74
+ )
75
+
76
+
77
+ def _paragraph(body: str) -> str:
78
+ objective = re.search(
79
+ r"^##\s+Objective\s*\n+([\s\S]*?)(?:\n##\s|\n#+\s|$)",
80
+ body,
81
+ re.IGNORECASE | re.MULTILINE,
82
+ )
83
+ value = objective.group(1) if objective else body
84
+ paragraphs = [
85
+ re.sub(r"\s+", " ", chunk).strip()
86
+ for chunk in re.split(r"\n\s*\n", value)
87
+ if chunk.strip() and not chunk.lstrip().startswith("#")
88
+ ]
89
+ return (paragraphs[0] if paragraphs else "Local CopperPilot skill")[:MAX_DESCRIPTION]
90
+
91
+
92
+ def scan_local_skills(workspace: Path) -> tuple[list[LocalSkill], list[str]]:
93
+ """Read bounded `.copperpilot/skills/<id>/SKILL.md` entries."""
94
+ root = workspace / ".copperpilot" / "skills"
95
+ skills: list[LocalSkill] = []
96
+ diagnostics: list[str] = []
97
+ if not root.is_dir():
98
+ return skills, diagnostics
99
+ for folder in sorted(root.iterdir())[:MAX_LOCAL_SKILLS]:
100
+ identifier = folder.name.lower()
101
+ file = folder / "SKILL.md"
102
+ if not folder.is_dir() or not SKILL_ID.fullmatch(identifier) or not file.is_file():
103
+ diagnostics.append(f"Ignored invalid local skill folder: {folder.name}")
104
+ continue
105
+ if file.stat().st_size > MAX_SKILL_BYTES:
106
+ diagnostics.append(f"Ignored oversized local skill: {identifier}")
107
+ continue
108
+ text = file.read_text(encoding="utf-8")
109
+ match = FRONTMATTER.match(text)
110
+ if not match:
111
+ diagnostics.append(f"Missing YAML frontmatter: {identifier}")
112
+ continue
113
+ try:
114
+ metadata = yaml.safe_load(match.group(1)) or {}
115
+ except yaml.YAMLError:
116
+ diagnostics.append(f"Invalid YAML frontmatter: {identifier}")
117
+ continue
118
+ if not isinstance(metadata, dict):
119
+ diagnostics.append(f"Invalid skill metadata: {identifier}")
120
+ continue
121
+ required = str(metadata.get("required_inputs") or "").strip()[:MAX_METADATA]
122
+ outputs = str(metadata.get("artifact_outputs") or "").strip()[:MAX_METADATA]
123
+ body = text[match.end() :].strip()
124
+ if not required or not outputs or not body:
125
+ diagnostics.append(f"Incomplete local skill: {identifier}")
126
+ continue
127
+ heading = re.search(r"^#\s+(.+)$", body, re.MULTILINE)
128
+ label = (
129
+ re.sub(r"^skill:\s*", "", heading.group(1), flags=re.IGNORECASE).strip()
130
+ if heading
131
+ else identifier.replace("-", " ").title()
132
+ )
133
+ skills.append(
134
+ LocalSkill(
135
+ id=identifier,
136
+ label=label,
137
+ description=_paragraph(body),
138
+ required_inputs=required,
139
+ artifact_outputs=outputs,
140
+ instructions=(
141
+ body + "\n\n## Local skill helper scripts\nHelper scripts must remain inside "
142
+ "this skill folder and use the normal file and shell tools."
143
+ )[:MAX_INSTRUCTIONS],
144
+ path=file.relative_to(workspace).as_posix(),
145
+ )
146
+ )
147
+ return skills, diagnostics
148
+
149
+
150
+ async def resolve_skill_manifest(
151
+ credential: DeviceCredential,
152
+ skills: list[LocalSkill],
153
+ *,
154
+ cache_key: str | None = None,
155
+ ) -> dict[str, Any]:
156
+ """Merge local skill metadata through the hosted manifest endpoint."""
157
+ async with httpx.AsyncClient(timeout=30) as client:
158
+ response = await client.post(
159
+ f"{credential.base_url}/api/copperpilot/skills/manifest",
160
+ headers={
161
+ "Authorization": f"Bearer {credential.api_key}",
162
+ "X-CopperPilot-Fingerprint": credential.fingerprint,
163
+ },
164
+ json={
165
+ "local_manifest": {
166
+ "version": 1,
167
+ "skills": [skill.manifest_entry() for skill in skills],
168
+ },
169
+ "client_cache_key": cache_key,
170
+ },
171
+ )
172
+ response.raise_for_status()
173
+ return response.json()
174
+
175
+
176
+ def parse_plan(path: Path, workspace: Path) -> PlanContext:
177
+ """Parse a `.copperpilot/plans/*.plan.md` artifact."""
178
+ resolved = path.resolve()
179
+ plans = (workspace / ".copperpilot" / "plans").resolve()
180
+ if plans not in resolved.parents or not resolved.name.endswith(".plan.md"):
181
+ raise ValueError("Plan must be a .copperpilot/plans/*.plan.md file.")
182
+ text = resolved.read_text(encoding="utf-8")
183
+ match = FRONTMATTER.match(text)
184
+ metadata: dict[str, Any] = {}
185
+ if match:
186
+ loaded = yaml.safe_load(match.group(1)) or {}
187
+ metadata = loaded if isinstance(loaded, dict) else {}
188
+ body = text[match.end() :].strip() if match else text.strip()
189
+ todos: list[PlanTodo] = []
190
+ for row in metadata.get("todos") or []:
191
+ if isinstance(row, dict) and row.get("id") and row.get("content"):
192
+ status = str(row.get("status") or "open").lower()
193
+ todos.append(
194
+ PlanTodo(
195
+ id=str(row["id"]),
196
+ content=str(row["content"]),
197
+ status=status if status in {"open", "running", "done"} else "open",
198
+ )
199
+ )
200
+ overall = str(metadata.get("overall_status") or "open").lower()
201
+ return PlanContext(
202
+ file_path=resolved.relative_to(workspace).as_posix(),
203
+ name=str(metadata.get("name") or resolved.name.removesuffix(".plan.md")),
204
+ overview=str(metadata.get("overview") or ""),
205
+ overall_status=overall if overall in {"open", "running", "done"} else "open",
206
+ todos=todos,
207
+ body=body,
208
+ )
209
+
210
+
211
+ def looks_like_kicad_snippet(text: str) -> bool:
212
+ """Validate a bounded KiCad S-expression in one pass."""
213
+ if not text or len(text) > MAX_SNIPPET_CHARS:
214
+ return False
215
+ stripped = text.strip()
216
+ if not stripped.startswith(KICAD_PREFIXES):
217
+ return False
218
+ depth = 0
219
+ quoted = False
220
+ escaped = False
221
+ for char in stripped:
222
+ if escaped:
223
+ escaped = False
224
+ elif char == "\\" and quoted:
225
+ escaped = True
226
+ elif char == '"':
227
+ quoted = not quoted
228
+ elif not quoted and char == "(":
229
+ depth += 1
230
+ elif not quoted and char == ")":
231
+ depth -= 1
232
+ if depth < 0:
233
+ return False
234
+ return depth == 0 and not quoted
235
+
236
+
237
+ def path_first_attachment(path: Path, workspace: Path) -> dict[str, Any]:
238
+ """Describe a workspace attachment without embedding file bytes."""
239
+ resolved = path.expanduser().resolve()
240
+ root = workspace.resolve()
241
+ if resolved != root and root not in resolved.parents:
242
+ raise ValueError("Attachment must be inside the workspace.")
243
+ if not resolved.is_file():
244
+ raise FileNotFoundError(resolved)
245
+ return {
246
+ "type": "file",
247
+ "path": resolved.relative_to(root).as_posix(),
248
+ "name": resolved.name,
249
+ "size": resolved.stat().st_size,
250
+ }
251
+
252
+
253
+ def encode_runtime_context(
254
+ *,
255
+ attachments: list[dict[str, Any]] | None = None,
256
+ snippet: str | None = None,
257
+ ) -> dict[str, Any] | None:
258
+ payload: dict[str, Any] = {}
259
+ if attachments:
260
+ payload["attachments"] = attachments
261
+ if snippet and looks_like_kicad_snippet(snippet):
262
+ payload["kicad_snippet"] = {"format": "sexpr", "content": snippet}
263
+ return payload or None