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.
@@ -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()