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.
- copper_pilot_cli/__init__.py +37 -0
- copper_pilot_cli/__main__.py +6 -0
- copper_pilot_cli/_upstream/__init__.py +0 -0
- copper_pilot_cli/_upstream/dcode_0_1_69/PROVENANCE.json +46 -0
- copper_pilot_cli/_upstream/dcode_0_1_69/__init__.py +0 -0
- copper_pilot_cli/_upstream/dcode_0_1_69/diff_utils.py +222 -0
- copper_pilot_cli/_version.py +32 -0
- copper_pilot_cli/clipboard.py +107 -0
- copper_pilot_cli/copper_api.py +24 -0
- copper_pilot_cli/copper_app.py +1071 -0
- copper_pilot_cli/copper_auth.py +232 -0
- copper_pilot_cli/copper_config.py +62 -0
- copper_pilot_cli/copper_features.py +263 -0
- copper_pilot_cli/copper_graph.py +178 -0
- copper_pilot_cli/copper_hooks.py +49 -0
- copper_pilot_cli/copper_main.py +345 -0
- copper_pilot_cli/copper_preferences.py +85 -0
- copper_pilot_cli/copper_presentation.py +325 -0
- copper_pilot_cli/copper_protocol.py +421 -0
- copper_pilot_cli/copper_theme.py +34 -0
- copper_pilot_cli/copper_tools.py +23 -0
- copper_pilot_cli/copper_update.py +32 -0
- copper_pilot_cli/copper_widgets.py +1224 -0
- copper_pilot_cli/copper_workspace.py +151 -0
- copper_pilot_cli/deepagents_tools.py +556 -0
- copper_pilot_cli/diagnostics.py +55 -0
- copper_pilot_cli/langchain.py +215 -0
- copper_pilot_cli/media_utils.py +626 -0
- copper_pilot_cli/py.typed +0 -0
- copper_pilot_cli/sessions.py +1578 -0
- copper_pilot_cli/textual_patches.py +35 -0
- copper_pilot_cli-0.1.1.data/data/share/doc/copper-pilot-cli/NOTICE +13 -0
- copper_pilot_cli-0.1.1.data/data/share/doc/copper-pilot-cli/UPSTREAM.md +44 -0
- copper_pilot_cli-0.1.1.dist-info/METADATA +211 -0
- copper_pilot_cli-0.1.1.dist-info/RECORD +39 -0
- copper_pilot_cli-0.1.1.dist-info/WHEEL +4 -0
- copper_pilot_cli-0.1.1.dist-info/entry_points.txt +3 -0
- copper_pilot_cli-0.1.1.dist-info/licenses/LICENSE +22 -0
- 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
|