agent-code 1.0.0__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.
- agent_code/__init__.py +2 -0
- agent_code/__main__.py +3 -0
- agent_code/agent.py +1542 -0
- agent_code/cli.py +69 -0
- agent_code/data/skills/filesystem/skill.md +0 -0
- agent_code/data/skills/filesystem/skill.py +133 -0
- agent_code/data/skills/public_api/skill.md +42 -0
- agent_code/data/skills/public_api/skill.py +105 -0
- agent_code/data/skills/temp_mail/skill.md +51 -0
- agent_code/data/skills/temp_mail/skill.py +716 -0
- agent_code/loader.py +217 -0
- agent_code/paths.py +54 -0
- agent_code/skill_manager.py +1171 -0
- agent_code-1.0.0.dist-info/METADATA +434 -0
- agent_code-1.0.0.dist-info/RECORD +20 -0
- agent_code-1.0.0.dist-info/WHEEL +5 -0
- agent_code-1.0.0.dist-info/entry_points.txt +2 -0
- agent_code-1.0.0.dist-info/licenses/LICENSE +21 -0
- agent_code-1.0.0.dist-info/licenses/LICENSE-GPL +674 -0
- agent_code-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1171 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
skill-manager.py — GUI skill store (tkinter) with server fallback.
|
|
4
|
+
|
|
5
|
+
Server fallback: HTTPS domain primary, raw IP on failure.
|
|
6
|
+
|
|
7
|
+
Config (~/.skill-manager.json):
|
|
8
|
+
token, nickname, user_id, last_login, my_skills, cached_at
|
|
9
|
+
|
|
10
|
+
Skill folders must contain skill.py + skill.md.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
import json
|
|
17
|
+
import time
|
|
18
|
+
import base64
|
|
19
|
+
import shutil
|
|
20
|
+
import zipfile
|
|
21
|
+
import threading
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.request
|
|
24
|
+
import urllib.parse
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from tkinter import ttk, messagebox, filedialog
|
|
27
|
+
import tkinter as tk
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Config
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
CONFIG_FILE = Path.home() / ".skill-manager.json"
|
|
35
|
+
|
|
36
|
+
# Server chain: primary HTTPS, IP fallback.
|
|
37
|
+
SKILL_SERVER_PRIMARY = "https://skills-manager.freesrv.com"
|
|
38
|
+
SKILL_SERVER_FALLBACK = "http://78.154.103.43:9074"
|
|
39
|
+
SKILL_SERVERS = [SKILL_SERVER_PRIMARY, SKILL_SERVER_FALLBACK]
|
|
40
|
+
DEFAULT_SERVER = SKILL_SERVER_PRIMARY
|
|
41
|
+
|
|
42
|
+
from . import paths
|
|
43
|
+
SKILLS_DIR = paths.project_skills_dir()
|
|
44
|
+
|
|
45
|
+
DEFAULT_CONFIG = {
|
|
46
|
+
"server": DEFAULT_SERVER,
|
|
47
|
+
"token": None,
|
|
48
|
+
"nickname": None,
|
|
49
|
+
"user_id": None,
|
|
50
|
+
"last_login": None,
|
|
51
|
+
"my_skills": [],
|
|
52
|
+
"cached_at": None,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# A browser-like UA to avoid Cloudflare's bot detection (error 1010)
|
|
56
|
+
USER_AGENT = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
57
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
58
|
+
"Chrome/125.0.0.0 Safari/537.36")
|
|
59
|
+
|
|
60
|
+
CF_MARKERS = (
|
|
61
|
+
"cloudflare", "error 1010", "error 1020", "error 1003",
|
|
62
|
+
"access denied", "just a moment", "cf-ray", "attention required",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_config() -> dict:
|
|
67
|
+
cfg = dict(DEFAULT_CONFIG)
|
|
68
|
+
if CONFIG_FILE.exists():
|
|
69
|
+
try:
|
|
70
|
+
cfg.update(json.loads(CONFIG_FILE.read_text(encoding="utf-8")))
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
cfg["server"] = DEFAULT_SERVER
|
|
74
|
+
return cfg
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def save_config(cfg: dict):
|
|
78
|
+
out = {k: cfg.get(k) for k in DEFAULT_CONFIG.keys()}
|
|
79
|
+
out["server"] = DEFAULT_SERVER
|
|
80
|
+
CONFIG_FILE.write_text(json.dumps(out, indent=2), encoding="utf-8")
|
|
81
|
+
try:
|
|
82
|
+
CONFIG_FILE.chmod(0o600)
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def clear_auth(cfg: dict):
|
|
88
|
+
cfg["token"] = None
|
|
89
|
+
cfg["nickname"] = None
|
|
90
|
+
cfg["user_id"] = None
|
|
91
|
+
cfg["last_login"] = None
|
|
92
|
+
cfg["my_skills"] = []
|
|
93
|
+
cfg["cached_at"] = None
|
|
94
|
+
save_config(cfg)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# skill.md parser
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n?", re.DOTALL)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_skill_md(text: str) -> dict:
|
|
105
|
+
m = FRONTMATTER_RE.match(text)
|
|
106
|
+
if not m:
|
|
107
|
+
return {}
|
|
108
|
+
meta = {}
|
|
109
|
+
for line in m.group(1).splitlines():
|
|
110
|
+
line = line.strip()
|
|
111
|
+
if not line or line.startswith("#") or ":" not in line:
|
|
112
|
+
continue
|
|
113
|
+
key, _, val = line.partition(":")
|
|
114
|
+
meta[key.strip().lower()] = val.strip().strip('"').strip("'")
|
|
115
|
+
return meta
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def read_skill_md(folder: Path) -> dict:
|
|
119
|
+
md_path = folder / "skill.md"
|
|
120
|
+
if not md_path.exists():
|
|
121
|
+
return {}
|
|
122
|
+
try:
|
|
123
|
+
return parse_skill_md(md_path.read_text(encoding="utf-8"))
|
|
124
|
+
except Exception:
|
|
125
|
+
return {}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def write_skill_md(folder: Path, name: str, description: str,
|
|
129
|
+
version: str = "1.0.0", author: str = "", body: str = ""):
|
|
130
|
+
md_path = folder / "skill.md"
|
|
131
|
+
text = (
|
|
132
|
+
"---\n"
|
|
133
|
+
f"name: {name}\n"
|
|
134
|
+
f"description: {description}\n"
|
|
135
|
+
f"version: {version}\n"
|
|
136
|
+
f"author: {author}\n"
|
|
137
|
+
"---\n\n"
|
|
138
|
+
f"# {name}\n\n"
|
|
139
|
+
f"{description}\n"
|
|
140
|
+
)
|
|
141
|
+
if body:
|
|
142
|
+
text += "\n" + body.strip() + "\n"
|
|
143
|
+
md_path.write_text(text, encoding="utf-8")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# HTTP client with server fallback
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
class ApiError(Exception):
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _do_request(server: str, method: str, path: str, cfg: dict,
|
|
155
|
+
body: dict | None, auth: bool) -> dict:
|
|
156
|
+
"""Single server request. Raises on any failure."""
|
|
157
|
+
url = server.rstrip("/") + path
|
|
158
|
+
data = None
|
|
159
|
+
headers = {
|
|
160
|
+
"Accept": "application/json",
|
|
161
|
+
"User-Agent": USER_AGENT,
|
|
162
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if body is not None:
|
|
166
|
+
data = json.dumps(body).encode("utf-8")
|
|
167
|
+
headers["Content-Type"] = "application/json"
|
|
168
|
+
if auth:
|
|
169
|
+
token = cfg.get("token")
|
|
170
|
+
if not token:
|
|
171
|
+
raise ApiError("not signed in")
|
|
172
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
173
|
+
|
|
174
|
+
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
178
|
+
return json.loads(r.read().decode("utf-8"))
|
|
179
|
+
except urllib.error.HTTPError as e:
|
|
180
|
+
try:
|
|
181
|
+
payload = e.read().decode("utf-8", errors="replace")
|
|
182
|
+
except Exception:
|
|
183
|
+
payload = ""
|
|
184
|
+
|
|
185
|
+
lower = payload.lower()
|
|
186
|
+
if any(m in lower for m in CF_MARKERS):
|
|
187
|
+
# Signal fallback: CF blocked
|
|
188
|
+
raise _FallbackNeeded(f"Cloudflare block (HTTP {e.code})")
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
err = json.loads(payload)
|
|
192
|
+
msg = err.get("error", payload)
|
|
193
|
+
except Exception:
|
|
194
|
+
msg = payload or f"HTTP {e.code}"
|
|
195
|
+
|
|
196
|
+
# Real server responses — do NOT fallback
|
|
197
|
+
if e.code == 401:
|
|
198
|
+
raise ApiError(f"AUTH_EXPIRED: {msg}")
|
|
199
|
+
if e.code == 403:
|
|
200
|
+
raise ApiError(
|
|
201
|
+
f"Not allowed: {msg}\n\n"
|
|
202
|
+
f"If this concerns a skill, it was published by a different "
|
|
203
|
+
f"account. Sign in as that account to modify it.")
|
|
204
|
+
if e.code == 404:
|
|
205
|
+
raise ApiError(f"not found: {msg}")
|
|
206
|
+
if e.code == 400:
|
|
207
|
+
raise ApiError(f"bad request: {msg}")
|
|
208
|
+
if e.code >= 500:
|
|
209
|
+
raise ApiError(f"server error ({e.code}): {msg}")
|
|
210
|
+
raise ApiError(msg)
|
|
211
|
+
|
|
212
|
+
except (urllib.error.URLError, OSError, TimeoutError) as e:
|
|
213
|
+
raise _FallbackNeeded(str(e))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class _FallbackNeeded(Exception):
|
|
217
|
+
"""Internal — signals that another server should be tried."""
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def api(method: str, path: str, cfg: dict, body: dict | None = None,
|
|
222
|
+
auth: bool = False) -> dict:
|
|
223
|
+
"""Try each server in order until one returns a valid response."""
|
|
224
|
+
errors = []
|
|
225
|
+
for server in SKILL_SERVERS:
|
|
226
|
+
try:
|
|
227
|
+
result = _do_request(server, method, path, cfg, body, auth)
|
|
228
|
+
if server != SKILL_SERVERS[0]:
|
|
229
|
+
print(f" [fallback] responded via {server}")
|
|
230
|
+
return result
|
|
231
|
+
except _FallbackNeeded as e:
|
|
232
|
+
errors.append(f"{server}: {e}")
|
|
233
|
+
continue
|
|
234
|
+
# ApiError propagates directly — no fallback
|
|
235
|
+
|
|
236
|
+
raise ApiError("all servers failed:\n " + "\n ".join(errors))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
# Helpers
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def extract_zip_safe(blob: bytes, target: Path):
|
|
244
|
+
import io
|
|
245
|
+
target = target.resolve()
|
|
246
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
247
|
+
with zipfile.ZipFile(io.BytesIO(blob)) as zf:
|
|
248
|
+
for member in zf.namelist():
|
|
249
|
+
if member.startswith(("/", "\\")) or ".." in Path(member).parts:
|
|
250
|
+
raise ApiError(f"unsafe zip entry: {member}")
|
|
251
|
+
dest = (target / member).resolve()
|
|
252
|
+
if not str(dest).startswith(str(target)):
|
|
253
|
+
raise ApiError(f"zip entry escapes target: {member}")
|
|
254
|
+
zf.extractall(target)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def is_installed(name: str) -> bool:
|
|
258
|
+
return (SKILLS_DIR / name / "skill.py").exists()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def fmt_time(ts):
|
|
262
|
+
if not ts:
|
|
263
|
+
return "never"
|
|
264
|
+
try:
|
|
265
|
+
return time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
|
|
266
|
+
except Exception:
|
|
267
|
+
return str(ts)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ---------------------------------------------------------------------------
|
|
271
|
+
# Theme
|
|
272
|
+
# ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
BG = "#0e1117"
|
|
275
|
+
BG_PANEL = "#161b22"
|
|
276
|
+
BG_CARD = "#1c2128"
|
|
277
|
+
BG_HOVER = "#21262d"
|
|
278
|
+
FG = "#c9d1d9"
|
|
279
|
+
FG_DIM = "#8b949e"
|
|
280
|
+
ACCENT = "#58a6ff"
|
|
281
|
+
GREEN = "#3fb950"
|
|
282
|
+
YELLOW = "#d29922"
|
|
283
|
+
RED = "#f85149"
|
|
284
|
+
BORDER = "#30363d"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def apply_theme(root):
|
|
288
|
+
style = ttk.Style(root)
|
|
289
|
+
try:
|
|
290
|
+
style.theme_use("clam")
|
|
291
|
+
except tk.TclError:
|
|
292
|
+
pass
|
|
293
|
+
root.configure(bg=BG)
|
|
294
|
+
style.configure("TFrame", background=BG)
|
|
295
|
+
style.configure("Panel.TFrame", background=BG_PANEL)
|
|
296
|
+
style.configure("Card.TFrame", background=BG_CARD)
|
|
297
|
+
style.configure("TLabel", background=BG, foreground=FG)
|
|
298
|
+
style.configure("Panel.TLabel", background=BG_PANEL, foreground=FG)
|
|
299
|
+
style.configure("Dim.TLabel", background=BG, foreground=FG_DIM)
|
|
300
|
+
style.configure("PanelDim.TLabel", background=BG_PANEL, foreground=FG_DIM)
|
|
301
|
+
style.configure("Header.TLabel", background=BG_PANEL,
|
|
302
|
+
foreground=ACCENT, font=("Segoe UI", 15, "bold"))
|
|
303
|
+
style.configure("Sub.TLabel", background=BG_PANEL,
|
|
304
|
+
foreground=FG_DIM, font=("Segoe UI", 9))
|
|
305
|
+
style.configure("User.TLabel", background=BG_PANEL,
|
|
306
|
+
foreground=GREEN, font=("Segoe UI", 9, "bold"))
|
|
307
|
+
style.configure("TEntry",
|
|
308
|
+
fieldbackground=BG_CARD, foreground=FG,
|
|
309
|
+
insertcolor=FG, bordercolor=BORDER,
|
|
310
|
+
lightcolor=BORDER, darkcolor=BORDER, padding=6)
|
|
311
|
+
style.configure("TButton",
|
|
312
|
+
background=BG_CARD, foreground=FG,
|
|
313
|
+
borderwidth=1, focusthickness=0, padding=(12, 6),
|
|
314
|
+
font=("Segoe UI", 10))
|
|
315
|
+
style.map("TButton",
|
|
316
|
+
background=[("active", BG_HOVER), ("disabled", BG_CARD)],
|
|
317
|
+
foreground=[("disabled", FG_DIM)])
|
|
318
|
+
style.configure("Toggle.TButton",
|
|
319
|
+
background=BG_CARD, foreground=FG_DIM,
|
|
320
|
+
borderwidth=1, focusthickness=0, padding=(12, 6),
|
|
321
|
+
font=("Segoe UI", 9))
|
|
322
|
+
style.map("Toggle.TButton", background=[("active", BG_HOVER)])
|
|
323
|
+
style.configure("ToggleOn.TButton",
|
|
324
|
+
background=ACCENT, foreground="#ffffff",
|
|
325
|
+
borderwidth=0, focusthickness=0, padding=(12, 6),
|
|
326
|
+
font=("Segoe UI", 9, "bold"))
|
|
327
|
+
style.map("ToggleOn.TButton", background=[("active", "#79b8ff")])
|
|
328
|
+
style.configure("Accent.TButton",
|
|
329
|
+
background=ACCENT, foreground="#ffffff",
|
|
330
|
+
borderwidth=0, focusthickness=0, padding=(16, 8),
|
|
331
|
+
font=("Segoe UI", 10, "bold"))
|
|
332
|
+
style.map("Accent.TButton",
|
|
333
|
+
background=[("active", "#79b8ff"), ("disabled", "#30363d")],
|
|
334
|
+
foreground=[("disabled", FG_DIM)])
|
|
335
|
+
style.configure("Green.TButton",
|
|
336
|
+
background=GREEN, foreground="#ffffff",
|
|
337
|
+
borderwidth=0, focusthickness=0, padding=(16, 8),
|
|
338
|
+
font=("Segoe UI", 10, "bold"))
|
|
339
|
+
style.map("Green.TButton",
|
|
340
|
+
background=[("active", "#56d364"), ("disabled", "#30363d")],
|
|
341
|
+
foreground=[("disabled", FG_DIM)])
|
|
342
|
+
style.configure("Danger.TButton",
|
|
343
|
+
background="#8b1a1a", foreground="#ffffff",
|
|
344
|
+
borderwidth=0, focusthickness=0, padding=(12, 6),
|
|
345
|
+
font=("Segoe UI", 10, "bold"))
|
|
346
|
+
style.map("Danger.TButton",
|
|
347
|
+
background=[("active", "#b02121"), ("disabled", "#30363d")],
|
|
348
|
+
foreground=[("disabled", FG_DIM)])
|
|
349
|
+
style.configure("Vertical.TScrollbar",
|
|
350
|
+
background=BG_PANEL, troughcolor=BG,
|
|
351
|
+
bordercolor=BG, arrowcolor=FG_DIM)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# ---------------------------------------------------------------------------
|
|
355
|
+
# Login dialog
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
class LoginDialog(tk.Toplevel):
|
|
359
|
+
def __init__(self, parent, cfg):
|
|
360
|
+
super().__init__(parent)
|
|
361
|
+
self.cfg = cfg
|
|
362
|
+
self.result = None
|
|
363
|
+
self.title("Sign in")
|
|
364
|
+
self.configure(bg=BG)
|
|
365
|
+
self.geometry("400x360")
|
|
366
|
+
self.resizable(False, False)
|
|
367
|
+
self.transient(parent)
|
|
368
|
+
self.grab_set()
|
|
369
|
+
apply_theme(self)
|
|
370
|
+
|
|
371
|
+
pad = ttk.Frame(self, style="Panel.TFrame")
|
|
372
|
+
pad.pack(fill="both", expand=True)
|
|
373
|
+
|
|
374
|
+
ttk.Label(pad, text="SKILL STORE", style="Header.TLabel").pack(
|
|
375
|
+
anchor="w", padx=24, pady=(20, 4))
|
|
376
|
+
ttk.Label(pad, text=DEFAULT_SERVER, style="Sub.TLabel").pack(
|
|
377
|
+
anchor="w", padx=24, pady=(0, 4))
|
|
378
|
+
ttk.Label(pad, text=f"(fallback: {SKILL_SERVER_FALLBACK})",
|
|
379
|
+
style="Sub.TLabel").pack(anchor="w", padx=24, pady=(0, 14))
|
|
380
|
+
|
|
381
|
+
body = tk.Frame(pad, bg=BG_PANEL)
|
|
382
|
+
body.pack(fill="x", padx=24)
|
|
383
|
+
|
|
384
|
+
ttk.Label(body, text="Nickname", style="Panel.TLabel").pack(anchor="w")
|
|
385
|
+
self.nick_entry = ttk.Entry(body, width=32, font=("Segoe UI", 11))
|
|
386
|
+
self.nick_entry.pack(fill="x", pady=(2, 12))
|
|
387
|
+
self.nick_entry.focus_set()
|
|
388
|
+
if cfg.get("nickname"):
|
|
389
|
+
self.nick_entry.insert(0, cfg["nickname"])
|
|
390
|
+
|
|
391
|
+
ttk.Label(body, text="Password", style="Panel.TLabel").pack(anchor="w")
|
|
392
|
+
self.pw_entry = ttk.Entry(body, show="•", font=("Segoe UI", 11))
|
|
393
|
+
self.pw_entry.pack(fill="x", pady=(2, 12))
|
|
394
|
+
self.pw_entry.bind("<Return>", lambda ev: self._login())
|
|
395
|
+
|
|
396
|
+
self.hint_lbl = ttk.Label(
|
|
397
|
+
body,
|
|
398
|
+
text="New here? Just pick a nickname — the account is created automatically.",
|
|
399
|
+
style="PanelDim.TLabel", wraplength=340, justify="left")
|
|
400
|
+
self.hint_lbl.pack(anchor="w", pady=(0, 12))
|
|
401
|
+
|
|
402
|
+
row = tk.Frame(pad, bg=BG_PANEL)
|
|
403
|
+
row.pack(fill="x", padx=24, pady=(8, 20))
|
|
404
|
+
ttk.Button(row, text="Cancel", command=self._cancel).pack(side="right")
|
|
405
|
+
self.go_btn = ttk.Button(row, text="Sign in", style="Accent.TButton",
|
|
406
|
+
command=self._login)
|
|
407
|
+
self.go_btn.pack(side="right", padx=(0, 8))
|
|
408
|
+
self.bind("<Escape>", lambda ev: self._cancel())
|
|
409
|
+
|
|
410
|
+
def _login(self):
|
|
411
|
+
nick = self.nick_entry.get().strip()
|
|
412
|
+
pw = self.pw_entry.get()
|
|
413
|
+
if not nick or len(pw) < 4:
|
|
414
|
+
messagebox.showerror("Sign in",
|
|
415
|
+
"Nickname required, password ≥ 4 chars",
|
|
416
|
+
parent=self)
|
|
417
|
+
return
|
|
418
|
+
|
|
419
|
+
self.go_btn.configure(state="disabled", text="…")
|
|
420
|
+
self.hint_lbl.configure(text="Contacting server…")
|
|
421
|
+
|
|
422
|
+
def work():
|
|
423
|
+
try:
|
|
424
|
+
try:
|
|
425
|
+
r = api("POST", "/login", self.cfg,
|
|
426
|
+
{"nickname": nick, "password": pw})
|
|
427
|
+
kind = "logged in"
|
|
428
|
+
except ApiError as ex:
|
|
429
|
+
msg = str(ex).lower()
|
|
430
|
+
if "invalid" in msg or "not found" in msg or "401" in msg:
|
|
431
|
+
r = api("POST", "/register", self.cfg,
|
|
432
|
+
{"nickname": nick, "password": pw})
|
|
433
|
+
kind = "registered"
|
|
434
|
+
else:
|
|
435
|
+
raise
|
|
436
|
+
self.result = (r["token"], r["nickname"], r["user_id"], kind)
|
|
437
|
+
self.after(0, self.destroy)
|
|
438
|
+
except ApiError as ex:
|
|
439
|
+
err_msg = str(ex)
|
|
440
|
+
self.after(0, lambda m=err_msg: messagebox.showerror(
|
|
441
|
+
"Sign in", m, parent=self))
|
|
442
|
+
self.after(0, lambda: self.go_btn.configure(
|
|
443
|
+
state="normal", text="Sign in"))
|
|
444
|
+
self.after(0, lambda: self.hint_lbl.configure(
|
|
445
|
+
text="New here? Just pick a nickname."))
|
|
446
|
+
|
|
447
|
+
threading.Thread(target=work, daemon=True).start()
|
|
448
|
+
|
|
449
|
+
def _cancel(self):
|
|
450
|
+
self.result = None
|
|
451
|
+
self.destroy()
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
# ---------------------------------------------------------------------------
|
|
455
|
+
# Publish dialog
|
|
456
|
+
# ---------------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
class PublishDialog(tk.Toplevel):
|
|
459
|
+
def __init__(self, parent, cfg):
|
|
460
|
+
super().__init__(parent)
|
|
461
|
+
self.cfg = cfg
|
|
462
|
+
self.result = None
|
|
463
|
+
self._loaded_folder = None
|
|
464
|
+
|
|
465
|
+
self.title("Publish skill")
|
|
466
|
+
self.configure(bg=BG)
|
|
467
|
+
self.geometry("560x500")
|
|
468
|
+
self.resizable(False, False)
|
|
469
|
+
self.transient(parent)
|
|
470
|
+
self.grab_set()
|
|
471
|
+
apply_theme(self)
|
|
472
|
+
|
|
473
|
+
pad = ttk.Frame(self, style="Panel.TFrame")
|
|
474
|
+
pad.pack(fill="both", expand=True)
|
|
475
|
+
|
|
476
|
+
ttk.Label(pad, text="PUBLISH SKILL", style="Header.TLabel").pack(
|
|
477
|
+
anchor="w", padx=24, pady=(20, 14))
|
|
478
|
+
|
|
479
|
+
body = tk.Frame(pad, bg=BG_PANEL)
|
|
480
|
+
body.pack(fill="x", padx=24)
|
|
481
|
+
|
|
482
|
+
ttk.Label(body, text="Folder (must contain skill.py + skill.md)",
|
|
483
|
+
style="Panel.TLabel").pack(anchor="w")
|
|
484
|
+
pfr = tk.Frame(body, bg=BG_PANEL)
|
|
485
|
+
pfr.pack(fill="x", pady=(2, 10))
|
|
486
|
+
self.path_entry = ttk.Entry(pfr, font=("Segoe UI", 10))
|
|
487
|
+
self.path_entry.pack(side="left", fill="x", expand=True)
|
|
488
|
+
ttk.Button(pfr, text="Browse…", command=self._browse).pack(
|
|
489
|
+
side="right", padx=(8, 0))
|
|
490
|
+
|
|
491
|
+
self.banner = ttk.Label(body, text="", style="PanelDim.TLabel",
|
|
492
|
+
wraplength=480, justify="left")
|
|
493
|
+
self.banner.pack(anchor="w", pady=(0, 12))
|
|
494
|
+
|
|
495
|
+
ttk.Label(body, text="Name", style="Panel.TLabel").pack(anchor="w")
|
|
496
|
+
self.name_entry = ttk.Entry(body, font=("Segoe UI", 11))
|
|
497
|
+
self.name_entry.pack(fill="x", pady=(2, 12))
|
|
498
|
+
|
|
499
|
+
ttk.Label(body, text="Description", style="Panel.TLabel").pack(anchor="w")
|
|
500
|
+
self.desc_entry = ttk.Entry(body, font=("Segoe UI", 11))
|
|
501
|
+
self.desc_entry.pack(fill="x", pady=(2, 12))
|
|
502
|
+
|
|
503
|
+
ttk.Label(body, text="Version", style="Panel.TLabel").pack(anchor="w")
|
|
504
|
+
self.ver_entry = ttk.Entry(body, font=("Segoe UI", 11))
|
|
505
|
+
self.ver_entry.insert(0, "1.0.0")
|
|
506
|
+
self.ver_entry.pack(fill="x", pady=(2, 12))
|
|
507
|
+
|
|
508
|
+
self.writeback = tk.BooleanVar(value=True)
|
|
509
|
+
ttk.Checkbutton(body, text="Update skill.md on disk with these values",
|
|
510
|
+
variable=self.writeback).pack(anchor="w")
|
|
511
|
+
|
|
512
|
+
self.status = ttk.Label(pad, text="", style="PanelDim.TLabel")
|
|
513
|
+
self.status.pack(anchor="w", padx=24, pady=(10, 0))
|
|
514
|
+
|
|
515
|
+
row = tk.Frame(pad, bg=BG_PANEL)
|
|
516
|
+
row.pack(fill="x", padx=24, pady=(16, 20))
|
|
517
|
+
ttk.Button(row, text="Cancel", command=self.destroy).pack(side="right")
|
|
518
|
+
self.pub_btn = ttk.Button(row, text="Publish", style="Accent.TButton",
|
|
519
|
+
command=self._publish)
|
|
520
|
+
self.pub_btn.pack(side="right", padx=(0, 8))
|
|
521
|
+
|
|
522
|
+
def _browse(self):
|
|
523
|
+
folder = filedialog.askdirectory(
|
|
524
|
+
title="Select skill folder (must contain skill.py)", parent=self)
|
|
525
|
+
if not folder:
|
|
526
|
+
return
|
|
527
|
+
self.path_entry.delete(0, "end")
|
|
528
|
+
self.path_entry.insert(0, folder)
|
|
529
|
+
self._loaded_folder = Path(folder)
|
|
530
|
+
self._load_skill_md()
|
|
531
|
+
|
|
532
|
+
def _load_skill_md(self):
|
|
533
|
+
folder = self._loaded_folder
|
|
534
|
+
if not folder or not folder.is_dir():
|
|
535
|
+
return
|
|
536
|
+
|
|
537
|
+
has_py = (folder / "skill.py").exists()
|
|
538
|
+
has_md = (folder / "skill.md").exists()
|
|
539
|
+
|
|
540
|
+
if not has_py:
|
|
541
|
+
self.banner.configure(
|
|
542
|
+
text="⚠ folder has no skill.py — publishing will fail",
|
|
543
|
+
foreground=RED)
|
|
544
|
+
return
|
|
545
|
+
|
|
546
|
+
if not has_md:
|
|
547
|
+
self.banner.configure(
|
|
548
|
+
text="⚠ folder has no skill.md — will be created on publish",
|
|
549
|
+
foreground=YELLOW)
|
|
550
|
+
if not self.name_entry.get():
|
|
551
|
+
self.name_entry.insert(0, folder.name)
|
|
552
|
+
return
|
|
553
|
+
|
|
554
|
+
meta = read_skill_md(folder)
|
|
555
|
+
if not meta:
|
|
556
|
+
self.banner.configure(
|
|
557
|
+
text="⚠ skill.md has no frontmatter — will be regenerated",
|
|
558
|
+
foreground=YELLOW)
|
|
559
|
+
else:
|
|
560
|
+
self.banner.configure(
|
|
561
|
+
text=f"✓ loaded from skill.md — author: "
|
|
562
|
+
f"{meta.get('author', 'unknown')}",
|
|
563
|
+
foreground=GREEN)
|
|
564
|
+
|
|
565
|
+
if meta.get("name"):
|
|
566
|
+
self.name_entry.delete(0, "end")
|
|
567
|
+
self.name_entry.insert(0, meta["name"])
|
|
568
|
+
elif not self.name_entry.get():
|
|
569
|
+
self.name_entry.insert(0, folder.name)
|
|
570
|
+
|
|
571
|
+
if meta.get("description") and not self.desc_entry.get():
|
|
572
|
+
self.desc_entry.delete(0, "end")
|
|
573
|
+
self.desc_entry.insert(0, meta["description"])
|
|
574
|
+
|
|
575
|
+
if meta.get("version"):
|
|
576
|
+
self.ver_entry.delete(0, "end")
|
|
577
|
+
self.ver_entry.insert(0, meta["version"])
|
|
578
|
+
|
|
579
|
+
def _publish(self):
|
|
580
|
+
path_str = self.path_entry.get().strip()
|
|
581
|
+
name = self.name_entry.get().strip()
|
|
582
|
+
desc = self.desc_entry.get().strip()
|
|
583
|
+
ver = self.ver_entry.get().strip() or "1.0.0"
|
|
584
|
+
|
|
585
|
+
if not path_str or not Path(path_str).is_dir():
|
|
586
|
+
messagebox.showerror("Publish", "Folder does not exist", parent=self)
|
|
587
|
+
return
|
|
588
|
+
path = Path(path_str)
|
|
589
|
+
|
|
590
|
+
if not (path / "skill.py").exists():
|
|
591
|
+
messagebox.showerror(
|
|
592
|
+
"Publish",
|
|
593
|
+
"Folder must contain skill.py.",
|
|
594
|
+
parent=self)
|
|
595
|
+
return
|
|
596
|
+
if not name or not desc:
|
|
597
|
+
messagebox.showerror("Publish", "Name and description required",
|
|
598
|
+
parent=self)
|
|
599
|
+
return
|
|
600
|
+
|
|
601
|
+
if self.writeback.get():
|
|
602
|
+
try:
|
|
603
|
+
existing = read_skill_md(path)
|
|
604
|
+
body = ""
|
|
605
|
+
md_path = path / "skill.md"
|
|
606
|
+
if md_path.exists() and existing:
|
|
607
|
+
text = md_path.read_text(encoding="utf-8")
|
|
608
|
+
parts = text.split("---", 2)
|
|
609
|
+
if len(parts) >= 3:
|
|
610
|
+
body = parts[2].strip()
|
|
611
|
+
write_skill_md(path, name, desc, ver,
|
|
612
|
+
author=self.cfg.get("nickname", ""),
|
|
613
|
+
body=body)
|
|
614
|
+
except Exception as ex:
|
|
615
|
+
if not messagebox.askyesno(
|
|
616
|
+
"Publish",
|
|
617
|
+
f"Could not update skill.md: {ex}\n\nContinue anyway?",
|
|
618
|
+
parent=self):
|
|
619
|
+
return
|
|
620
|
+
|
|
621
|
+
self.pub_btn.configure(state="disabled")
|
|
622
|
+
self.status.configure(text="packaging…")
|
|
623
|
+
|
|
624
|
+
def work():
|
|
625
|
+
try:
|
|
626
|
+
import io
|
|
627
|
+
buf = io.BytesIO()
|
|
628
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
629
|
+
for f in path.rglob("*"):
|
|
630
|
+
if f.is_file():
|
|
631
|
+
zf.write(f, str(f.relative_to(path)))
|
|
632
|
+
blob = buf.getvalue()
|
|
633
|
+
if len(blob) > 5 * 1024 * 1024:
|
|
634
|
+
raise ApiError(f"skill too large ({len(blob)} bytes, max 5 MB)")
|
|
635
|
+
|
|
636
|
+
self.after(0, lambda: self.status.configure(
|
|
637
|
+
text=f"uploading {len(blob):,} bytes…"))
|
|
638
|
+
|
|
639
|
+
r = api("POST", "/skills", self.cfg, auth=True, body={
|
|
640
|
+
"name": name,
|
|
641
|
+
"description": desc,
|
|
642
|
+
"version": ver,
|
|
643
|
+
"zip_b64": base64.b64encode(blob).decode("ascii"),
|
|
644
|
+
})
|
|
645
|
+
self.result = r
|
|
646
|
+
self.after(0, self.destroy)
|
|
647
|
+
except ApiError as ex:
|
|
648
|
+
err_msg = str(ex)
|
|
649
|
+
self.after(0, lambda m=err_msg: messagebox.showerror(
|
|
650
|
+
"Publish", m, parent=self))
|
|
651
|
+
self.after(0, lambda: self.pub_btn.configure(state="normal"))
|
|
652
|
+
self.after(0, lambda: self.status.configure(text=""))
|
|
653
|
+
except Exception as ex:
|
|
654
|
+
err_msg = f"{type(ex).__name__}: {ex}"
|
|
655
|
+
self.after(0, lambda m=err_msg: messagebox.showerror(
|
|
656
|
+
"Publish", m, parent=self))
|
|
657
|
+
self.after(0, lambda: self.pub_btn.configure(state="normal"))
|
|
658
|
+
|
|
659
|
+
threading.Thread(target=work, daemon=True).start()
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
# ---------------------------------------------------------------------------
|
|
663
|
+
# Main window
|
|
664
|
+
# ---------------------------------------------------------------------------
|
|
665
|
+
|
|
666
|
+
class SkillStore(tk.Tk):
|
|
667
|
+
def __init__(self):
|
|
668
|
+
super().__init__()
|
|
669
|
+
self.cfg = load_config()
|
|
670
|
+
|
|
671
|
+
self.title("Skill Store")
|
|
672
|
+
self.geometry("1120x720")
|
|
673
|
+
self.minsize(960, 580)
|
|
674
|
+
apply_theme(self)
|
|
675
|
+
|
|
676
|
+
self.installed_only = False
|
|
677
|
+
self.mine_only = False
|
|
678
|
+
self.selected_skill = None
|
|
679
|
+
self._my_skill_names = {s.get("name") for s in self.cfg.get("my_skills", [])}
|
|
680
|
+
|
|
681
|
+
self._build_ui()
|
|
682
|
+
self._restore_session()
|
|
683
|
+
self.after(200, self.refresh_skills)
|
|
684
|
+
|
|
685
|
+
def _build_ui(self):
|
|
686
|
+
header = tk.Frame(self, bg=BG_PANEL)
|
|
687
|
+
header.pack(fill="x", side="top")
|
|
688
|
+
|
|
689
|
+
left = tk.Frame(header, bg=BG_PANEL)
|
|
690
|
+
left.pack(side="left", padx=16, pady=10)
|
|
691
|
+
ttk.Label(left, text="SKILL STORE", style="Header.TLabel").pack(anchor="w")
|
|
692
|
+
ttk.Label(left, text=DEFAULT_SERVER, style="Sub.TLabel").pack(anchor="w")
|
|
693
|
+
ttk.Label(left, text=f"fallback: {SKILL_SERVER_FALLBACK}",
|
|
694
|
+
style="Sub.TLabel").pack(anchor="w")
|
|
695
|
+
|
|
696
|
+
right = tk.Frame(header, bg=BG_PANEL)
|
|
697
|
+
right.pack(side="right", padx=16, pady=10)
|
|
698
|
+
self.user_lbl = ttk.Label(right, text="", style="User.TLabel")
|
|
699
|
+
self.user_lbl.pack(side="right", padx=(0, 12))
|
|
700
|
+
self.login_btn = ttk.Button(right, text="Sign in", style="Accent.TButton",
|
|
701
|
+
command=self._on_login_click)
|
|
702
|
+
self.login_btn.pack(side="right")
|
|
703
|
+
|
|
704
|
+
toolbar = tk.Frame(self, bg=BG)
|
|
705
|
+
toolbar.pack(fill="x", padx=16, pady=(12, 6))
|
|
706
|
+
|
|
707
|
+
ttk.Label(toolbar, text="Search:", style="Dim.TLabel").pack(side="left")
|
|
708
|
+
self.search_var = tk.StringVar()
|
|
709
|
+
self.search_entry = ttk.Entry(toolbar, textvariable=self.search_var,
|
|
710
|
+
width=36, font=("Segoe UI", 10))
|
|
711
|
+
self.search_entry.pack(side="left", padx=(8, 8), ipady=2)
|
|
712
|
+
self.search_entry.bind("<Return>", lambda ev: self.refresh_skills())
|
|
713
|
+
|
|
714
|
+
ttk.Button(toolbar, text="Search", command=self.refresh_skills).pack(side="left")
|
|
715
|
+
ttk.Button(toolbar, text="Refresh", command=self.refresh_skills).pack(side="left", padx=(6, 0))
|
|
716
|
+
self.publish_btn = ttk.Button(toolbar, text="Publish…",
|
|
717
|
+
command=self._on_publish_click)
|
|
718
|
+
self.publish_btn.pack(side="left", padx=(6, 0))
|
|
719
|
+
|
|
720
|
+
self.mine_btn = ttk.Button(toolbar, text="Mine only", style="Toggle.TButton",
|
|
721
|
+
command=self._toggle_mine)
|
|
722
|
+
self.mine_btn.pack(side="right", padx=(6, 0))
|
|
723
|
+
self.installed_btn = ttk.Button(toolbar, text="Installed only",
|
|
724
|
+
style="Toggle.TButton",
|
|
725
|
+
command=self._toggle_installed)
|
|
726
|
+
self.installed_btn.pack(side="right")
|
|
727
|
+
|
|
728
|
+
split = tk.Frame(self, bg=BG)
|
|
729
|
+
split.pack(fill="both", expand=True, padx=16, pady=(0, 8))
|
|
730
|
+
|
|
731
|
+
left_frame = tk.Frame(split, bg=BG_PANEL,
|
|
732
|
+
highlightbackground=BORDER, highlightthickness=1)
|
|
733
|
+
left_frame.pack(side="left", fill="both", expand=True)
|
|
734
|
+
|
|
735
|
+
cols = ("name", "version", "author", "downloads", "mine", "installed")
|
|
736
|
+
self.tree = ttk.Treeview(left_frame, columns=cols, show="headings",
|
|
737
|
+
selectmode="browse")
|
|
738
|
+
for c, t, w, a in [
|
|
739
|
+
("name", "Name", 200, "w"),
|
|
740
|
+
("version", "Version", 80, "center"),
|
|
741
|
+
("author", "Author", 120, "w"),
|
|
742
|
+
("downloads", "DLs", 60, "e"),
|
|
743
|
+
("mine", "Yours", 60, "center"),
|
|
744
|
+
("installed", "Installed", 80, "center"),
|
|
745
|
+
]:
|
|
746
|
+
self.tree.heading(c, text=t)
|
|
747
|
+
self.tree.column(c, width=w, anchor=a)
|
|
748
|
+
|
|
749
|
+
style = ttk.Style(self)
|
|
750
|
+
style.configure("Treeview",
|
|
751
|
+
background=BG_PANEL, fieldbackground=BG_PANEL,
|
|
752
|
+
foreground=FG, bordercolor=BORDER,
|
|
753
|
+
rowheight=26, font=("Segoe UI", 10))
|
|
754
|
+
style.configure("Treeview.Heading",
|
|
755
|
+
background=BG_CARD, foreground=FG_DIM,
|
|
756
|
+
relief="flat", font=("Segoe UI", 9, "bold"))
|
|
757
|
+
style.map("Treeview",
|
|
758
|
+
background=[("selected", ACCENT)],
|
|
759
|
+
foreground=[("selected", "#ffffff")])
|
|
760
|
+
|
|
761
|
+
vsb = ttk.Scrollbar(left_frame, orient="vertical", command=self.tree.yview)
|
|
762
|
+
self.tree.configure(yscrollcommand=vsb.set)
|
|
763
|
+
self.tree.pack(side="left", fill="both", expand=True)
|
|
764
|
+
vsb.pack(side="right", fill="y")
|
|
765
|
+
self.tree.bind("<<TreeviewSelect>>", self._on_select)
|
|
766
|
+
|
|
767
|
+
right_frame = tk.Frame(split, bg=BG_CARD, width=360,
|
|
768
|
+
highlightbackground=BORDER, highlightthickness=1)
|
|
769
|
+
right_frame.pack(side="right", fill="y", padx=(12, 0))
|
|
770
|
+
right_frame.pack_propagate(False)
|
|
771
|
+
|
|
772
|
+
detail = tk.Frame(right_frame, bg=BG_CARD)
|
|
773
|
+
detail.pack(fill="both", expand=True, padx=18, pady=18)
|
|
774
|
+
|
|
775
|
+
self.d_name = tk.Label(detail, text="Select a skill",
|
|
776
|
+
bg=BG_CARD, fg=FG,
|
|
777
|
+
font=("Segoe UI", 14, "bold"),
|
|
778
|
+
anchor="w", justify="left", wraplength=320)
|
|
779
|
+
self.d_name.pack(fill="x")
|
|
780
|
+
|
|
781
|
+
self.d_meta = tk.Label(detail, text="", bg=BG_CARD, fg=FG_DIM,
|
|
782
|
+
font=("Segoe UI", 9), anchor="w", justify="left")
|
|
783
|
+
self.d_meta.pack(fill="x", pady=(4, 12))
|
|
784
|
+
|
|
785
|
+
self.d_desc = tk.Label(detail, text="", bg=BG_CARD, fg=FG,
|
|
786
|
+
font=("Segoe UI", 10), anchor="nw",
|
|
787
|
+
justify="left", wraplength=320)
|
|
788
|
+
self.d_desc.pack(fill="x", pady=(0, 12))
|
|
789
|
+
|
|
790
|
+
self.d_status = tk.Label(detail, text="", bg=BG_CARD, fg=GREEN,
|
|
791
|
+
font=("Segoe UI", 9, "bold"), anchor="w")
|
|
792
|
+
self.d_status.pack(fill="x", pady=(0, 12))
|
|
793
|
+
|
|
794
|
+
self.d_install_btn = ttk.Button(detail, text="Install",
|
|
795
|
+
style="Green.TButton",
|
|
796
|
+
command=self._on_install_click)
|
|
797
|
+
self.d_install_btn.pack(fill="x", pady=(0, 6))
|
|
798
|
+
|
|
799
|
+
self.d_uninstall_btn = ttk.Button(detail, text="Uninstall",
|
|
800
|
+
style="Danger.TButton",
|
|
801
|
+
command=self._on_uninstall_click)
|
|
802
|
+
self.d_uninstall_btn.pack(fill="x", pady=(0, 6))
|
|
803
|
+
|
|
804
|
+
self.d_delete_btn = ttk.Button(detail, text="Delete from server",
|
|
805
|
+
style="Danger.TButton",
|
|
806
|
+
command=self._on_delete_click)
|
|
807
|
+
|
|
808
|
+
status_bar = tk.Frame(self, bg=BG_PANEL)
|
|
809
|
+
status_bar.pack(fill="x", side="bottom")
|
|
810
|
+
self.status_lbl = ttk.Label(status_bar, text="Ready.",
|
|
811
|
+
style="PanelDim.TLabel")
|
|
812
|
+
self.status_lbl.pack(side="left", padx=16, pady=6)
|
|
813
|
+
self.cache_lbl = ttk.Label(status_bar, text="", style="PanelDim.TLabel")
|
|
814
|
+
self.cache_lbl.pack(side="left", padx=16, pady=6)
|
|
815
|
+
self.count_lbl = ttk.Label(status_bar, text="", style="PanelDim.TLabel")
|
|
816
|
+
self.count_lbl.pack(side="right", padx=16, pady=6)
|
|
817
|
+
|
|
818
|
+
# ---- session ----
|
|
819
|
+
|
|
820
|
+
def _restore_session(self):
|
|
821
|
+
if self.cfg.get("token") and self.cfg.get("nickname"):
|
|
822
|
+
self.user_lbl.configure(
|
|
823
|
+
text=f"● {self.cfg['nickname']} (id {self.cfg.get('user_id','?')})")
|
|
824
|
+
self.login_btn.configure(text="Sign out",
|
|
825
|
+
command=self._on_logout_click)
|
|
826
|
+
self.publish_btn.configure(state="normal")
|
|
827
|
+
|
|
828
|
+
cached = self.cfg.get("my_skills", [])
|
|
829
|
+
if cached:
|
|
830
|
+
self.cache_lbl.configure(
|
|
831
|
+
text=f"cached: {len(cached)} published · "
|
|
832
|
+
f"last sync {fmt_time(self.cfg.get('cached_at'))}")
|
|
833
|
+
self._refresh_me(silent=True)
|
|
834
|
+
else:
|
|
835
|
+
self.user_lbl.configure(text="not signed in")
|
|
836
|
+
self.publish_btn.configure(state="disabled")
|
|
837
|
+
|
|
838
|
+
def _refresh_me(self, silent=False):
|
|
839
|
+
if not self.cfg.get("token"):
|
|
840
|
+
return
|
|
841
|
+
|
|
842
|
+
def work():
|
|
843
|
+
try:
|
|
844
|
+
r = api("GET", "/me", self.cfg, auth=True)
|
|
845
|
+
user = r["user"]
|
|
846
|
+
skills = r.get("skills", [])
|
|
847
|
+
self.cfg["user_id"] = user["id"]
|
|
848
|
+
self.cfg["nickname"] = user["nickname"]
|
|
849
|
+
self.cfg["my_skills"] = skills
|
|
850
|
+
self.cfg["cached_at"] = time.time()
|
|
851
|
+
save_config(self.cfg)
|
|
852
|
+
self._my_skill_names = {s["name"] for s in skills}
|
|
853
|
+
|
|
854
|
+
def ui_update():
|
|
855
|
+
self.user_lbl.configure(
|
|
856
|
+
text=f"● {user['nickname']} (id {user['id']})")
|
|
857
|
+
self.cache_lbl.configure(
|
|
858
|
+
text=f"cached: {len(skills)} published · "
|
|
859
|
+
f"last sync {fmt_time(self.cfg['cached_at'])}")
|
|
860
|
+
if not silent:
|
|
861
|
+
self.set_status(f"synced: {len(skills)} published")
|
|
862
|
+
self.refresh_skills()
|
|
863
|
+
self.after(0, ui_update)
|
|
864
|
+
except ApiError as ex:
|
|
865
|
+
msg = str(ex)
|
|
866
|
+
if "AUTH_EXPIRED" in msg:
|
|
867
|
+
self.after(0, self._handle_expired)
|
|
868
|
+
elif not silent:
|
|
869
|
+
self.after(0, lambda m=msg: self.set_status(f"error: {m}"))
|
|
870
|
+
|
|
871
|
+
threading.Thread(target=work, daemon=True).start()
|
|
872
|
+
|
|
873
|
+
def _handle_expired(self):
|
|
874
|
+
messagebox.showwarning(
|
|
875
|
+
"Session expired",
|
|
876
|
+
"Your saved session has expired. Please sign in again.",
|
|
877
|
+
parent=self)
|
|
878
|
+
clear_auth(self.cfg)
|
|
879
|
+
self._my_skill_names = set()
|
|
880
|
+
self.user_lbl.configure(text="not signed in")
|
|
881
|
+
self.login_btn.configure(text="Sign in", command=self._on_login_click)
|
|
882
|
+
self.publish_btn.configure(state="disabled")
|
|
883
|
+
self.cache_lbl.configure(text="")
|
|
884
|
+
self.refresh_skills()
|
|
885
|
+
|
|
886
|
+
def _on_login_click(self):
|
|
887
|
+
dlg = LoginDialog(self, self.cfg)
|
|
888
|
+
self.wait_window(dlg)
|
|
889
|
+
if dlg.result:
|
|
890
|
+
token, nick, uid, kind = dlg.result
|
|
891
|
+
self.cfg["token"] = token
|
|
892
|
+
self.cfg["nickname"] = nick
|
|
893
|
+
self.cfg["user_id"] = uid
|
|
894
|
+
self.cfg["last_login"] = time.time()
|
|
895
|
+
save_config(self.cfg)
|
|
896
|
+
|
|
897
|
+
self.user_lbl.configure(text=f"● {nick} (id {uid})")
|
|
898
|
+
self.login_btn.configure(text="Sign out",
|
|
899
|
+
command=self._on_logout_click)
|
|
900
|
+
self.publish_btn.configure(state="normal")
|
|
901
|
+
self.set_status(f"{kind} as {nick}")
|
|
902
|
+
self._refresh_me()
|
|
903
|
+
self.refresh_skills()
|
|
904
|
+
|
|
905
|
+
def _on_logout_click(self):
|
|
906
|
+
if not messagebox.askyesno("Sign out",
|
|
907
|
+
"Sign out and forget saved session?",
|
|
908
|
+
parent=self):
|
|
909
|
+
return
|
|
910
|
+
try:
|
|
911
|
+
api("POST", "/logout", self.cfg, {}, auth=True)
|
|
912
|
+
except ApiError:
|
|
913
|
+
pass
|
|
914
|
+
clear_auth(self.cfg)
|
|
915
|
+
self._my_skill_names = set()
|
|
916
|
+
self.user_lbl.configure(text="not signed in")
|
|
917
|
+
self.login_btn.configure(text="Sign in", command=self._on_login_click)
|
|
918
|
+
self.publish_btn.configure(state="disabled")
|
|
919
|
+
self.cache_lbl.configure(text="")
|
|
920
|
+
self.set_status("signed out")
|
|
921
|
+
self.refresh_skills()
|
|
922
|
+
|
|
923
|
+
# ---- list ----
|
|
924
|
+
|
|
925
|
+
def refresh_skills(self):
|
|
926
|
+
self.set_status("loading skills…")
|
|
927
|
+
|
|
928
|
+
def work():
|
|
929
|
+
try:
|
|
930
|
+
path = "/skills"
|
|
931
|
+
q = self.search_var.get().strip()
|
|
932
|
+
if q:
|
|
933
|
+
path += f"?search={urllib.parse.quote(q)}"
|
|
934
|
+
r = api("GET", path, self.cfg)
|
|
935
|
+
skills = r.get("skills", [])
|
|
936
|
+
self.after(0, lambda s=skills: self._populate(s))
|
|
937
|
+
except ApiError as ex:
|
|
938
|
+
msg = str(ex)
|
|
939
|
+
self.after(0, lambda m=msg: self.set_status(f"error: {m}"))
|
|
940
|
+
|
|
941
|
+
threading.Thread(target=work, daemon=True).start()
|
|
942
|
+
|
|
943
|
+
def _populate(self, skills):
|
|
944
|
+
self.tree.delete(*self.tree.get_children())
|
|
945
|
+
shown = 0
|
|
946
|
+
for s in skills:
|
|
947
|
+
installed = is_installed(s["name"])
|
|
948
|
+
is_mine = s["name"] in self._my_skill_names
|
|
949
|
+
if self.installed_only and not installed:
|
|
950
|
+
continue
|
|
951
|
+
if self.mine_only and not is_mine:
|
|
952
|
+
continue
|
|
953
|
+
self.tree.insert("", "end", iid=s["name"], values=(
|
|
954
|
+
s["name"],
|
|
955
|
+
s.get("version", "?"),
|
|
956
|
+
s.get("author", "?"),
|
|
957
|
+
s.get("downloads", 0),
|
|
958
|
+
"★" if is_mine else "",
|
|
959
|
+
"✓" if installed else "",
|
|
960
|
+
))
|
|
961
|
+
shown += 1
|
|
962
|
+
self.count_lbl.configure(text=f"{shown} skill(s)")
|
|
963
|
+
self.set_status("loaded.")
|
|
964
|
+
|
|
965
|
+
def _toggle_installed(self):
|
|
966
|
+
self.installed_only = not self.installed_only
|
|
967
|
+
self.installed_btn.configure(
|
|
968
|
+
style="ToggleOn.TButton" if self.installed_only else "Toggle.TButton")
|
|
969
|
+
self.refresh_skills()
|
|
970
|
+
|
|
971
|
+
def _toggle_mine(self):
|
|
972
|
+
self.mine_only = not self.mine_only
|
|
973
|
+
self.mine_btn.configure(
|
|
974
|
+
style="ToggleOn.TButton" if self.mine_only else "Toggle.TButton")
|
|
975
|
+
self.refresh_skills()
|
|
976
|
+
|
|
977
|
+
def _on_select(self, event):
|
|
978
|
+
sel = self.tree.selection()
|
|
979
|
+
if not sel:
|
|
980
|
+
return
|
|
981
|
+
name = sel[0]
|
|
982
|
+
self.selected_skill = name
|
|
983
|
+
self._load_detail(name)
|
|
984
|
+
|
|
985
|
+
def _load_detail(self, name):
|
|
986
|
+
self.d_name.configure(text=name)
|
|
987
|
+
self.d_meta.configure(text="loading…")
|
|
988
|
+
self.d_desc.configure(text="")
|
|
989
|
+
self.d_status.configure(text="")
|
|
990
|
+
self.d_install_btn.configure(state="disabled")
|
|
991
|
+
self.d_uninstall_btn.configure(state="disabled")
|
|
992
|
+
self.d_delete_btn.pack_forget()
|
|
993
|
+
|
|
994
|
+
def work():
|
|
995
|
+
try:
|
|
996
|
+
r = api("GET", f"/skills/{name}", self.cfg)
|
|
997
|
+
self.after(0, lambda s=r["skill"]: self._show_detail(s))
|
|
998
|
+
except ApiError as ex:
|
|
999
|
+
msg = str(ex)
|
|
1000
|
+
self.after(0, lambda m=msg: self.d_meta.configure(text=f"error: {m}"))
|
|
1001
|
+
|
|
1002
|
+
threading.Thread(target=work, daemon=True).start()
|
|
1003
|
+
|
|
1004
|
+
def _show_detail(self, s):
|
|
1005
|
+
name = s["name"]
|
|
1006
|
+
is_mine = name in self._my_skill_names
|
|
1007
|
+
installed = is_installed(name)
|
|
1008
|
+
|
|
1009
|
+
self.d_name.configure(text=name)
|
|
1010
|
+
parts = [f"v{s['version']}", s['author'], f"{s['downloads']} downloads"]
|
|
1011
|
+
if is_mine:
|
|
1012
|
+
parts.append("★ yours")
|
|
1013
|
+
self.d_meta.configure(text=" · ".join(parts))
|
|
1014
|
+
self.d_desc.configure(text=s["description"])
|
|
1015
|
+
|
|
1016
|
+
if installed:
|
|
1017
|
+
self.d_status.configure(text="✓ installed", fg=GREEN)
|
|
1018
|
+
self.d_install_btn.configure(text="Reinstall", state="normal")
|
|
1019
|
+
self.d_uninstall_btn.configure(state="normal")
|
|
1020
|
+
else:
|
|
1021
|
+
self.d_status.configure(text="not installed", fg=FG_DIM)
|
|
1022
|
+
self.d_install_btn.configure(text="Install", state="normal")
|
|
1023
|
+
self.d_uninstall_btn.configure(state="disabled")
|
|
1024
|
+
|
|
1025
|
+
if is_mine:
|
|
1026
|
+
self.d_delete_btn.pack(fill="x", pady=(6, 0))
|
|
1027
|
+
|
|
1028
|
+
# ---- install / uninstall / delete ----
|
|
1029
|
+
|
|
1030
|
+
def _on_install_click(self):
|
|
1031
|
+
if not self.selected_skill:
|
|
1032
|
+
return
|
|
1033
|
+
if not self.cfg.get("token"):
|
|
1034
|
+
messagebox.showinfo("Install", "Sign in first.", parent=self)
|
|
1035
|
+
return
|
|
1036
|
+
|
|
1037
|
+
name = self.selected_skill
|
|
1038
|
+
self.d_install_btn.configure(state="disabled")
|
|
1039
|
+
self.set_status(f"installing {name}…")
|
|
1040
|
+
|
|
1041
|
+
def work():
|
|
1042
|
+
try:
|
|
1043
|
+
r = api("GET", f"/skills/{name}/download", self.cfg, auth=True)
|
|
1044
|
+
version = r.get("version", "?")
|
|
1045
|
+
blob = base64.b64decode(r["zip_b64"])
|
|
1046
|
+
target = SKILLS_DIR / name
|
|
1047
|
+
if target.exists():
|
|
1048
|
+
shutil.rmtree(target)
|
|
1049
|
+
extract_zip_safe(blob, target)
|
|
1050
|
+
|
|
1051
|
+
def ok():
|
|
1052
|
+
self.set_status(f"✓ installed {name} v{version}")
|
|
1053
|
+
self.refresh_skills()
|
|
1054
|
+
self._load_detail(name)
|
|
1055
|
+
self.after(0, ok)
|
|
1056
|
+
except ApiError as ex:
|
|
1057
|
+
msg = str(ex)
|
|
1058
|
+
self.after(0, lambda m=msg: messagebox.showerror(
|
|
1059
|
+
"Install", m, parent=self))
|
|
1060
|
+
self.after(0, lambda m=msg: self.set_status(f"error: {m}"))
|
|
1061
|
+
self.after(0, lambda: self.d_install_btn.configure(state="normal"))
|
|
1062
|
+
except Exception as ex:
|
|
1063
|
+
msg = f"{type(ex).__name__}: {ex}"
|
|
1064
|
+
self.after(0, lambda m=msg: messagebox.showerror(
|
|
1065
|
+
"Install", m, parent=self))
|
|
1066
|
+
self.after(0, lambda: self.d_install_btn.configure(state="normal"))
|
|
1067
|
+
|
|
1068
|
+
threading.Thread(target=work, daemon=True).start()
|
|
1069
|
+
|
|
1070
|
+
def _on_uninstall_click(self):
|
|
1071
|
+
if not self.selected_skill:
|
|
1072
|
+
return
|
|
1073
|
+
name = self.selected_skill
|
|
1074
|
+
if not messagebox.askyesno("Uninstall",
|
|
1075
|
+
f"Remove '{name}' from ./skills/ ?",
|
|
1076
|
+
parent=self):
|
|
1077
|
+
return
|
|
1078
|
+
target = SKILLS_DIR / name
|
|
1079
|
+
try:
|
|
1080
|
+
if target.exists():
|
|
1081
|
+
shutil.rmtree(target)
|
|
1082
|
+
self.set_status(f"removed {name}")
|
|
1083
|
+
self.refresh_skills()
|
|
1084
|
+
self._load_detail(name)
|
|
1085
|
+
except Exception as ex:
|
|
1086
|
+
msg = f"{type(ex).__name__}: {ex}"
|
|
1087
|
+
messagebox.showerror("Uninstall", msg, parent=self)
|
|
1088
|
+
|
|
1089
|
+
def _on_delete_click(self):
|
|
1090
|
+
if not self.selected_skill:
|
|
1091
|
+
return
|
|
1092
|
+
name = self.selected_skill
|
|
1093
|
+
if name not in self._my_skill_names:
|
|
1094
|
+
return
|
|
1095
|
+
|
|
1096
|
+
if not messagebox.askyesno(
|
|
1097
|
+
"Delete from server",
|
|
1098
|
+
f"Permanently delete '{name}' from the server?\n\n"
|
|
1099
|
+
f"This cannot be undone. Other users will no longer be able "
|
|
1100
|
+
f"to install it.",
|
|
1101
|
+
parent=self):
|
|
1102
|
+
return
|
|
1103
|
+
confirm = messagebox.askstring(
|
|
1104
|
+
"Confirm delete",
|
|
1105
|
+
f"Type the skill name to confirm:\n\n {name}",
|
|
1106
|
+
parent=self)
|
|
1107
|
+
if confirm != name:
|
|
1108
|
+
self.set_status("delete cancelled")
|
|
1109
|
+
return
|
|
1110
|
+
|
|
1111
|
+
self.set_status(f"deleting {name}…")
|
|
1112
|
+
|
|
1113
|
+
def work():
|
|
1114
|
+
try:
|
|
1115
|
+
api("DELETE", f"/skills/{name}", self.cfg, auth=True)
|
|
1116
|
+
self.cfg["my_skills"] = [
|
|
1117
|
+
s for s in self.cfg.get("my_skills", [])
|
|
1118
|
+
if s.get("name") != name
|
|
1119
|
+
]
|
|
1120
|
+
save_config(self.cfg)
|
|
1121
|
+
self._my_skill_names.discard(name)
|
|
1122
|
+
|
|
1123
|
+
def ok():
|
|
1124
|
+
self.set_status(f"✓ deleted {name}")
|
|
1125
|
+
self.refresh_skills()
|
|
1126
|
+
self.cache_lbl.configure(
|
|
1127
|
+
text=f"cached: {len(self.cfg['my_skills'])} published · "
|
|
1128
|
+
f"last sync {fmt_time(self.cfg.get('cached_at'))}")
|
|
1129
|
+
self._load_detail(name)
|
|
1130
|
+
self.after(0, ok)
|
|
1131
|
+
except ApiError as ex:
|
|
1132
|
+
msg = str(ex)
|
|
1133
|
+
self.after(0, lambda m=msg: messagebox.showerror(
|
|
1134
|
+
"Delete", m, parent=self))
|
|
1135
|
+
self.after(0, lambda m=msg: self.set_status(f"error: {m}"))
|
|
1136
|
+
except Exception as ex:
|
|
1137
|
+
msg = f"{type(ex).__name__}: {ex}"
|
|
1138
|
+
self.after(0, lambda m=msg: messagebox.showerror(
|
|
1139
|
+
"Delete", m, parent=self))
|
|
1140
|
+
|
|
1141
|
+
threading.Thread(target=work, daemon=True).start()
|
|
1142
|
+
|
|
1143
|
+
# ---- publish ----
|
|
1144
|
+
|
|
1145
|
+
def _on_publish_click(self):
|
|
1146
|
+
if not self.cfg.get("token"):
|
|
1147
|
+
messagebox.showinfo("Publish", "Sign in first.", parent=self)
|
|
1148
|
+
return
|
|
1149
|
+
dlg = PublishDialog(self, self.cfg)
|
|
1150
|
+
self.wait_window(dlg)
|
|
1151
|
+
if dlg.result:
|
|
1152
|
+
self.set_status(
|
|
1153
|
+
f"✓ published {dlg.result['name']} v{dlg.result['version']}")
|
|
1154
|
+
self._refresh_me(silent=True)
|
|
1155
|
+
self.refresh_skills()
|
|
1156
|
+
|
|
1157
|
+
def set_status(self, text):
|
|
1158
|
+
self.status_lbl.configure(text=text)
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
# ---------------------------------------------------------------------------
|
|
1162
|
+
# Entry point
|
|
1163
|
+
# ---------------------------------------------------------------------------
|
|
1164
|
+
|
|
1165
|
+
def main():
|
|
1166
|
+
app = SkillStore()
|
|
1167
|
+
app.mainloop()
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
if __name__ == "__main__":
|
|
1171
|
+
main()
|