lovstudio-skill-helper 0.1.0__tar.gz

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,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .DS_Store
8
+ .env
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lovstudio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: lovstudio-skill-helper
3
+ Version: 0.1.0
4
+ Summary: Lovstudio skill helper — activate and run paid Lovstudio skills locally. Decryption keys never touch disk.
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.9
7
+ Requires-Dist: cryptography>=42
8
+ Requires-Dist: pyyaml>=6
@@ -0,0 +1,46 @@
1
+ # lovstudio-skill-helper
2
+
3
+ CLI helper for Lovstudio paid skills — activate your license and transparently decrypt/run protected skills locally. Decryption keys are fetched per-invocation from the license server and live only in process memory; they never touch disk.
4
+
5
+ ## Install
6
+
7
+ The canonical way is via [`uv`](https://docs.astral.sh/uv/) — no install step needed, runs on first use:
8
+
9
+ ```bash
10
+ uvx lovstudio-skill-helper activate <license-key>
11
+ ```
12
+
13
+ Or install it persistently:
14
+
15
+ ```bash
16
+ pipx install lovstudio-skill-helper
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```bash
22
+ # one-time per device
23
+ lovstudio-skill-helper activate <license-key>
24
+
25
+ # then any paid skill placeholder SKILL.md will call:
26
+ lovstudio-skill-helper decrypt <skill-name> # print plaintext SKILL.md to stdout
27
+ lovstudio-skill-helper exec <skill-name> <script> # run an encrypted script once
28
+
29
+ lovstudio-skill-helper status # show current activation
30
+ lovstudio-skill-helper heartbeat # refresh last-seen
31
+ lovstudio-skill-helper deactivate # wipe local license
32
+ ```
33
+
34
+ ## How it works
35
+
36
+ Paid skills ship as AES-256-GCM ciphertext under `~/.claude/skills/<name>/` (or `~/.claude/skills/lovstudio-<name>/`), placed there by `npx skills add ...`. Each call to `decrypt` / `exec`:
37
+
38
+ 1. Signs an HMAC proof with your license key (key itself never leaves the device).
39
+ 2. Hits the Lovstudio license server, which verifies the proof, checks entitlement, and returns a per-skill-version AES key.
40
+ 3. Decrypts in memory, streams to stdout or a `tempfile.TemporaryDirectory` that is wiped on exit.
41
+
42
+ License keys are sold via the 手工川 (ShougongChuan) WeChat official account.
43
+
44
+ ## License
45
+
46
+ MIT.
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "lovstudio-skill-helper"
7
+ version = "0.1.0"
8
+ description = "Lovstudio skill helper — activate and run paid Lovstudio skills locally. Decryption keys never touch disk."
9
+ requires-python = ">=3.9"
10
+ dependencies = [
11
+ "cryptography>=42",
12
+ "pyyaml>=6",
13
+ ]
14
+
15
+ [project.scripts]
16
+ lovstudio-skill-helper = "lovstudio_skill_helper.cli:main"
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/lovstudio_skill_helper"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,123 @@
1
+ """Signed requests to the Lovstudio licensing Edge Functions.
2
+
3
+ Protocol mirrors OpenClacky:
4
+ proof = HMAC_SHA256(license_key, f"{action}:{key_hash}:{user_id}:{device_id}:{timestamp}:{nonce}{extra}")
5
+
6
+ The license_key itself is NEVER sent over the wire — only key_hash + proof.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import hmac
12
+ import json
13
+ import secrets
14
+ import time
15
+ import urllib.error
16
+ import urllib.request
17
+
18
+ from . import config
19
+
20
+
21
+ def hmac_hex(key_hex: str, message: str) -> str:
22
+ return hmac.new(bytes.fromhex(key_hex), message.encode(), hashlib.sha256).hexdigest()
23
+
24
+
25
+ def key_hash(license_key_hex: str) -> str:
26
+ return hashlib.sha256(bytes.fromhex(license_key_hex)).hexdigest()
27
+
28
+
29
+ def parse_user_id_from_key(license_key_hex: str) -> int:
30
+ """First 8 hex chars of the license key = user_id. Server validates independently."""
31
+ return int(license_key_hex[:8], 16)
32
+
33
+
34
+ def signed_payload(
35
+ license_key: str,
36
+ action: str,
37
+ device_id: str,
38
+ extra_suffix: str = "",
39
+ extra_fields: dict | None = None,
40
+ ) -> dict:
41
+ kh = key_hash(license_key)
42
+ uid = str(parse_user_id_from_key(license_key))
43
+ ts = str(int(time.time()))
44
+ nonce = secrets.token_hex(16)
45
+ msg = f"{action}:{kh}:{uid}:{device_id}:{ts}:{nonce}{extra_suffix}"
46
+ proof = hmac_hex(license_key, msg)
47
+ payload = {
48
+ "key_hash": kh,
49
+ "user_id": uid,
50
+ "device_id": device_id,
51
+ "timestamp": ts,
52
+ "nonce": nonce,
53
+ "proof": proof,
54
+ }
55
+ if extra_fields:
56
+ payload.update(extra_fields)
57
+ return payload
58
+
59
+
60
+ class ApiError(RuntimeError):
61
+ def __init__(self, status: int, message: str):
62
+ super().__init__(f"HTTP {status}: {message}")
63
+ self.status = status
64
+ self.message = message
65
+
66
+
67
+ def call(path: str, body: dict, timeout: int = 15) -> dict:
68
+ req = urllib.request.Request(
69
+ f"{config.api_base()}/{path}",
70
+ data=json.dumps(body).encode(),
71
+ headers={
72
+ "content-type": "application/json",
73
+ "authorization": f"Bearer {config.anon_key()}",
74
+ },
75
+ )
76
+ try:
77
+ with urllib.request.urlopen(req, timeout=timeout) as r:
78
+ return json.loads(r.read())
79
+ except urllib.error.HTTPError as e:
80
+ try:
81
+ err_body = json.loads(e.read()).get("error", "unknown error")
82
+ except Exception:
83
+ err_body = "unknown error"
84
+ raise ApiError(e.code, err_body) from None
85
+
86
+
87
+ def activate(license_key: str, device_id: str) -> dict:
88
+ payload = signed_payload(
89
+ license_key, "activate", device_id,
90
+ extra_fields={"device_info": config.device_info()},
91
+ )
92
+ return call("activate", payload)
93
+
94
+
95
+ def heartbeat(license_key: str, device_id: str) -> dict:
96
+ return call("heartbeat", signed_payload(license_key, "heartbeat", device_id))
97
+
98
+
99
+ def skill_keys(license_key: str, device_id: str, skill_name: str, skill_version: str) -> dict:
100
+ suffix = f":{skill_name}:{skill_version}"
101
+ payload = signed_payload(
102
+ license_key, "skill_keys", device_id,
103
+ extra_suffix=suffix,
104
+ extra_fields={"skill_name": skill_name, "skill_version": skill_version},
105
+ )
106
+ return call("skill_keys", payload)
107
+
108
+
109
+ def list_catalog(timeout: int = 15) -> list[dict]:
110
+ """Public catalog of all skills (no auth). Returns [{name, category, paid}, ...]."""
111
+ url = f"{config.rest_base()}/skills?select=name,category,paid"
112
+ req = urllib.request.Request(
113
+ url,
114
+ headers={
115
+ "apikey": config.anon_key(),
116
+ "authorization": f"Bearer {config.anon_key()}",
117
+ },
118
+ )
119
+ try:
120
+ with urllib.request.urlopen(req, timeout=timeout) as r:
121
+ return json.loads(r.read())
122
+ except urllib.error.HTTPError as e:
123
+ raise ApiError(e.code, "catalog fetch failed") from None
@@ -0,0 +1,264 @@
1
+ """lovstudio-skill-helper CLI — activate, heartbeat, decrypt, exec.
2
+
3
+ Trust model:
4
+ - ~/.lovstudio/license.yml holds license_key (chmod 600). Anyone with this
5
+ file can impersonate the user. Don't share.
6
+ - Decryption keys are fetched from the server per invocation, used in
7
+ memory, then die with the process. They are NEVER written to disk.
8
+ - `exec` decrypts a script to a tmpdir, runs it, then deletes the tmpdir.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ from pathlib import Path
19
+
20
+ from . import api, config
21
+ from .crypto import SkillManifest, decrypt_file
22
+
23
+
24
+ _BUY_HINT = " Buy a license key at https://lovstudio.ai (or follow the 手工川 / ShougongChuan WeChat OA)."
25
+
26
+
27
+ def _require_license() -> dict:
28
+ lic = config.load_license()
29
+ if not lic:
30
+ print("error: not activated. run `lovstudio-skill-helper activate <key>` first.", file=sys.stderr)
31
+ print(_BUY_HINT, file=sys.stderr)
32
+ sys.exit(2)
33
+ return lic
34
+
35
+
36
+ def cmd_activate(args) -> int:
37
+ raw = args.key.strip().lower()
38
+ # Accept the human-friendly "lk-" prefix; the wire protocol uses raw hex.
39
+ license_key = raw[3:] if raw.startswith("lk-") else raw
40
+ if len(license_key) != 64 or not all(c in "0123456789abcdef" for c in license_key):
41
+ print("error: license key must be 64 hex chars (with optional 'lk-' prefix).", file=sys.stderr)
42
+ return 2
43
+
44
+ existing = config.load_license() or {}
45
+ device_id = existing.get("device_id") or config.generate_device_id()
46
+
47
+ try:
48
+ resp = api.activate(license_key, device_id)
49
+ except api.ApiError as e:
50
+ print(f"error: activation failed — {e.message}", file=sys.stderr)
51
+ print(_BUY_HINT, file=sys.stderr)
52
+ return 1
53
+
54
+ data = {
55
+ "license_key": license_key,
56
+ "device_id": device_id,
57
+ "user_id": resp.get("user_id"),
58
+ "expires_at": resp.get("expires_at"),
59
+ "entitled_skills": resp.get("entitled_skills", []),
60
+ "last_heartbeat_at": None,
61
+ }
62
+ config.save_license(data)
63
+ skills = ", ".join(data["entitled_skills"]) or "(none)"
64
+ print(f"✓ activated. user_id={data['user_id']} entitled={skills}")
65
+ return 0
66
+
67
+
68
+ def cmd_heartbeat(args) -> int:
69
+ lic = _require_license()
70
+ try:
71
+ resp = api.heartbeat(lic["license_key"], lic["device_id"])
72
+ except api.ApiError as e:
73
+ print(f"error: heartbeat failed — {e.message}", file=sys.stderr)
74
+ return 1
75
+ lic["expires_at"] = resp.get("expires_at")
76
+ config.save_license(lic)
77
+ print(f"✓ heartbeat ok. expires_at={resp.get('expires_at')}")
78
+ return 0
79
+
80
+
81
+ def cmd_status(args) -> int:
82
+ lic = config.load_license()
83
+ if not lic:
84
+ print("not activated.")
85
+ return 0
86
+
87
+ if args.json:
88
+ redacted = {**lic, "license_key": lic["license_key"][:8] + "…"}
89
+ print(json.dumps(redacted, indent=2, ensure_ascii=False))
90
+ return 0
91
+
92
+ # License header.
93
+ key_short = lic["license_key"][:8] + "…"
94
+ entitled: set[str] = set(lic.get("entitled_skills") or [])
95
+ print(f"license_key {key_short}")
96
+ print(f"device_id {lic.get('device_id', '—')}")
97
+ print(f"user_id {lic.get('user_id', '—')}")
98
+ print(f"expires_at {lic.get('expires_at') or '— (no expiry)'}")
99
+ print(f"last_heartbeat_at {lic.get('last_heartbeat_at') or '—'}")
100
+ print(f"entitled {len(entitled)} skill(s)")
101
+ print()
102
+
103
+ # Fetch catalog; fall back to flat list if offline.
104
+ try:
105
+ catalog = api.list_catalog()
106
+ except api.ApiError as e:
107
+ print(f"(catalog fetch failed — {e.message}; showing flat entitled list)", file=sys.stderr)
108
+ for name in sorted(entitled):
109
+ print(f" [x] {name}")
110
+ return 0
111
+
112
+ # Group by category. `paid` flag tells user whether a skill requires a license at all.
113
+ by_cat: dict[str, list[dict]] = {}
114
+ for row in catalog:
115
+ cat = row.get("category") or "(uncategorized)"
116
+ by_cat.setdefault(cat, []).append(row)
117
+
118
+ known_names = {row["name"] for row in catalog}
119
+ # Entitled-but-not-in-catalog: show under a synthetic bucket so they aren't lost.
120
+ orphans = sorted(entitled - known_names)
121
+ if orphans:
122
+ by_cat.setdefault("(other)", []).extend({"name": n, "paid": True} for n in orphans)
123
+
124
+ for cat in sorted(by_cat):
125
+ rows = sorted(by_cat[cat], key=lambda r: r["name"])
126
+ granted = sum(1 for r in rows if r["name"] in entitled)
127
+ paid_count = sum(1 for r in rows if r.get("paid"))
128
+ print(f"{cat} ({granted}/{paid_count} paid entitled, {len(rows)} total)")
129
+ for r in rows:
130
+ name = r["name"]
131
+ has = name in entitled
132
+ paid = r.get("paid", False)
133
+ if has:
134
+ mark = "[x]"
135
+ elif paid:
136
+ mark = "[ ]"
137
+ else:
138
+ mark = " ·" # free skill, no entitlement needed
139
+ suffix = "" if paid else " (free)"
140
+ print(f" {mark} {name}{suffix}")
141
+ print()
142
+
143
+ return 0
144
+
145
+
146
+ def cmd_deactivate(args) -> int:
147
+ config.wipe_license()
148
+ print("✓ license wiped from local disk.")
149
+ return 0
150
+
151
+
152
+ def _manifest_for(skill_name: str) -> SkillManifest:
153
+ d = config.skill_dir(skill_name)
154
+ if not (d / "MANIFEST.enc.json").exists():
155
+ candidates = config.skill_dir_candidates(skill_name)
156
+ print(f"error: skill '{skill_name}' not installed (no MANIFEST.enc.json found).", file=sys.stderr)
157
+ print(f" searched, in order:", file=sys.stderr)
158
+ for c in candidates:
159
+ mark = "✓" if (c / "MANIFEST.enc.json").exists() else "✗"
160
+ print(f" {mark} {c}", file=sys.stderr)
161
+ print(f" install via either:", file=sys.stderr)
162
+ print(f" npx skills add lovstudio/skills # full marketplace", file=sys.stderr)
163
+ print(f" npx skills add lovstudio/{skill_name}-skill # just this one", file=sys.stderr)
164
+ sys.exit(2)
165
+ return SkillManifest(d)
166
+
167
+
168
+ def _read_skill_version(manifest: SkillManifest) -> str:
169
+ """Version is baked into MANIFEST.enc.json (format v2+)."""
170
+ if manifest.skill_version:
171
+ return manifest.skill_version
172
+ raise RuntimeError(
173
+ f"manifest at {manifest.skill_dir} has no skill_version field. "
174
+ "Re-pack with pack-skill.py --skill-version <semver>."
175
+ )
176
+
177
+
178
+ def _fetch_key(lic: dict, skill_name: str, version: str) -> bytes:
179
+ try:
180
+ resp = api.skill_keys(lic["license_key"], lic["device_id"], skill_name, version)
181
+ except api.ApiError as e:
182
+ print(f"error: skill_keys failed — {e.message}", file=sys.stderr)
183
+ # 403 = entitlement missing for this skill — point at the storefront.
184
+ if e.status in (401, 403):
185
+ print(_BUY_HINT, file=sys.stderr)
186
+ sys.exit(1)
187
+ return bytes.fromhex(resp["decryption_key"])
188
+
189
+
190
+ def cmd_decrypt(args) -> int:
191
+ """Print the decrypted SKILL.md to stdout. This is what Claude reads."""
192
+ lic = _require_license()
193
+ manifest = _manifest_for(args.skill_name)
194
+ version = _read_skill_version(manifest)
195
+ key = _fetch_key(lic, args.skill_name, version)
196
+ plaintext = decrypt_file(manifest, "SKILL.md", key)
197
+ sys.stdout.buffer.write(plaintext)
198
+ return 0
199
+
200
+
201
+ def cmd_exec(args) -> int:
202
+ """Decrypt a script file to a tmpdir, execute it, then clean up."""
203
+ lic = _require_license()
204
+ manifest = _manifest_for(args.skill_name)
205
+ version = _read_skill_version(manifest)
206
+ key = _fetch_key(lic, args.skill_name, version)
207
+
208
+ if args.script_path not in manifest.files:
209
+ print(f"error: '{args.script_path}' not in manifest.", file=sys.stderr)
210
+ return 2
211
+ plaintext = decrypt_file(manifest, args.script_path, key)
212
+
213
+ with tempfile.TemporaryDirectory(prefix="lovstudio-") as tmp:
214
+ tmp_path = Path(tmp) / Path(args.script_path).name
215
+ tmp_path.write_bytes(plaintext)
216
+ tmp_path.chmod(0o700)
217
+
218
+ # Pick interpreter from extension. KISS — extend when needed.
219
+ suffix = tmp_path.suffix
220
+ if suffix == ".py":
221
+ cmd = [sys.executable, str(tmp_path), *args.script_args]
222
+ elif suffix == ".sh":
223
+ cmd = ["bash", str(tmp_path), *args.script_args]
224
+ else:
225
+ cmd = [str(tmp_path), *args.script_args]
226
+
227
+ result = subprocess.run(cmd)
228
+ return result.returncode
229
+
230
+
231
+ def main(argv: list[str] | None = None) -> int:
232
+ p = argparse.ArgumentParser(prog="lovstudio-skill-helper")
233
+ sub = p.add_subparsers(dest="cmd", required=True)
234
+
235
+ p_activate = sub.add_parser("activate", help="activate a license key")
236
+ p_activate.add_argument("key", help="license key (e.g. lk-<64 hex chars>)")
237
+ p_activate.set_defaults(func=cmd_activate)
238
+
239
+ p_hb = sub.add_parser("heartbeat", help="send heartbeat to refresh license")
240
+ p_hb.set_defaults(func=cmd_heartbeat)
241
+
242
+ p_status = sub.add_parser("status", help="show local license state (by category, with entitlement marks)")
243
+ p_status.add_argument("--json", action="store_true", help="raw JSON output (old behavior)")
244
+ p_status.set_defaults(func=cmd_status)
245
+
246
+ p_deact = sub.add_parser("deactivate", help="wipe local license file")
247
+ p_deact.set_defaults(func=cmd_deactivate)
248
+
249
+ p_dec = sub.add_parser("decrypt", help="print decrypted SKILL.md to stdout")
250
+ p_dec.add_argument("skill_name")
251
+ p_dec.set_defaults(func=cmd_decrypt)
252
+
253
+ p_exec = sub.add_parser("exec", help="run a decrypted script from a skill")
254
+ p_exec.add_argument("skill_name")
255
+ p_exec.add_argument("script_path", help="relative path inside the skill, e.g. scripts/foo.py")
256
+ p_exec.add_argument("script_args", nargs=argparse.REMAINDER)
257
+ p_exec.set_defaults(func=cmd_exec)
258
+
259
+ args = p.parse_args(argv)
260
+ return args.func(args)
261
+
262
+
263
+ if __name__ == "__main__":
264
+ sys.exit(main())
@@ -0,0 +1,112 @@
1
+ """On-disk layout for activated state.
2
+
3
+ ~/.lovstudio/
4
+ └── license.yml # license_key, device_id, activated_at, expires_at,
5
+ # last_heartbeat_at, entitled_skills
6
+
7
+ Encrypted skill bundles live under ~/.claude/skills/<name>/ (or the
8
+ `lovstudio-<name>/` variant), placed there by `npx skills add ...`.
9
+
10
+ Decryption keys are NEVER persisted here. They live in the running CLI's
11
+ memory for the duration of one `decrypt` or `exec` invocation, then die.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import platform
17
+ import uuid
18
+ from pathlib import Path
19
+
20
+ import yaml
21
+
22
+ CONFIG_DIR = Path(os.environ.get("LOVSTUDIO_HOME", Path.home() / ".lovstudio"))
23
+ LICENSE_FILE = CONFIG_DIR / "license.yml"
24
+
25
+ # Default Edge Function endpoint. Overridable via env for dev/test.
26
+ # Points at the lovstudio.ai web project (merged license system).
27
+ DEFAULT_API_BASE = "https://nouchjcfeoobplxkwasg.supabase.co/functions/v1"
28
+ # Default anon key — Edge Functions require it for JWT gate, even though
29
+ # we enforce real auth via HMAC inside the function body.
30
+ DEFAULT_ANON_KEY = (
31
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
32
+ "eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im5vdWNoamNmZW9vYnBseGt3YXNnIiwicm9sZSI6"
33
+ "ImFub24iLCJpYXQiOjE3NjYxNjI1OTMsImV4cCI6MjA4MTczODU5M30."
34
+ "P3A_AoAjp0EXIafeBBeqp972h_lO7oXjbKgu0OdMsjA"
35
+ )
36
+
37
+
38
+ def api_base() -> str:
39
+ return os.environ.get("LOVSTUDIO_API_BASE", DEFAULT_API_BASE)
40
+
41
+
42
+ def rest_base() -> str:
43
+ """PostgREST base URL — derived from api_base() by stripping the Edge
44
+ Functions suffix. Overridable via env for dev/test.
45
+ """
46
+ override = os.environ.get("LOVSTUDIO_REST_BASE")
47
+ if override:
48
+ return override
49
+ base = api_base()
50
+ suffix = "/functions/v1"
51
+ root = base[: -len(suffix)] if base.endswith(suffix) else base
52
+ return f"{root}/rest/v1"
53
+
54
+
55
+ def anon_key() -> str:
56
+ return os.environ.get("LOVSTUDIO_ANON_KEY", DEFAULT_ANON_KEY)
57
+
58
+
59
+ def load_license() -> dict | None:
60
+ if not LICENSE_FILE.exists():
61
+ return None
62
+ return yaml.safe_load(LICENSE_FILE.read_text()) or {}
63
+
64
+
65
+ def save_license(data: dict) -> None:
66
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
67
+ LICENSE_FILE.write_text(yaml.safe_dump(data, sort_keys=False))
68
+ # Restrict to owner-read/write — the key_secret equivalent is stored here.
69
+ LICENSE_FILE.chmod(0o600)
70
+
71
+
72
+ def wipe_license() -> None:
73
+ if LICENSE_FILE.exists():
74
+ LICENSE_FILE.unlink()
75
+
76
+
77
+ def generate_device_id() -> str:
78
+ """Stable-ish device id. Not privacy-sensitive; mirrors OpenClacky approach."""
79
+ return uuid.uuid4().hex
80
+
81
+
82
+ def device_info() -> dict:
83
+ return {
84
+ "os": platform.system().lower(),
85
+ "os_version": platform.release(),
86
+ "hostname": platform.node(),
87
+ "python": platform.python_version(),
88
+ }
89
+
90
+
91
+ def skill_dir_candidates(skill_name: str) -> list[Path]:
92
+ """Search candidates for an encrypted skill bundle, in priority order.
93
+
94
+ 1. ~/.claude/skills/<name>/ ← `npx skills add` with bare name
95
+ 2. ~/.claude/skills/lovstudio-<name>/ ← `npx skills add` with namespaced name
96
+ (free skills + paid skills both land here)
97
+ """
98
+ return [
99
+ Path.home() / ".claude" / "skills" / skill_name,
100
+ Path.home() / ".claude" / "skills" / f"lovstudio-{skill_name}",
101
+ ]
102
+
103
+
104
+ def skill_dir(skill_name: str) -> Path:
105
+ """Locate an encrypted skill bundle, returning the first candidate that
106
+ contains a MANIFEST.enc.json. Falls back to the primary path so callers
107
+ can render a sane error message.
108
+ """
109
+ for c in skill_dir_candidates(skill_name):
110
+ if (c / "MANIFEST.enc.json").exists():
111
+ return c
112
+ return skill_dir_candidates(skill_name)[0]
@@ -0,0 +1,52 @@
1
+ """AES-256-GCM decryption for brand skills.
2
+
3
+ Reads MANIFEST.enc.json + per-file .enc blobs, returns plaintext.
4
+ The decryption key is passed in by the caller (already fetched from the server);
5
+ this module never persists it and never reads it from disk.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import hashlib
11
+ import json
12
+ from pathlib import Path
13
+
14
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
15
+
16
+
17
+ class SkillManifest:
18
+ def __init__(self, skill_dir: Path):
19
+ manifest_path = skill_dir / "MANIFEST.enc.json"
20
+ if not manifest_path.exists():
21
+ raise FileNotFoundError(f"MANIFEST.enc.json not found in {skill_dir}")
22
+ data = json.loads(manifest_path.read_text())
23
+ self.skill_dir = skill_dir
24
+ self.skill_id: int = data["skill_id"]
25
+ self.skill_version_id: int = data["skill_version_id"]
26
+ self.skill_name: str | None = data.get("skill_name")
27
+ self.skill_version: str | None = data.get("skill_version")
28
+ self.cipher: str = data.get("cipher", "aes-256-gcm")
29
+ self.files: dict = data["files"]
30
+
31
+ def file_meta(self, rel_path: str) -> dict:
32
+ meta = self.files.get(rel_path)
33
+ if not meta:
34
+ raise KeyError(f"'{rel_path}' not in manifest")
35
+ return meta
36
+
37
+
38
+ def decrypt_file(manifest: SkillManifest, rel_path: str, key: bytes) -> bytes:
39
+ """Decrypt one file to plaintext bytes. Verifies SHA256 checksum."""
40
+ meta = manifest.file_meta(rel_path)
41
+ enc_path = manifest.skill_dir / (rel_path + ".enc")
42
+ ciphertext = enc_path.read_bytes()
43
+ iv = base64.b64decode(meta["iv"])
44
+ tag = base64.b64decode(meta["tag"])
45
+ plaintext = AESGCM(key).decrypt(iv, ciphertext + tag, associated_data=None)
46
+
47
+ expected = meta.get("original_checksum")
48
+ if expected:
49
+ actual = hashlib.sha256(plaintext).hexdigest()
50
+ if actual != expected:
51
+ raise ValueError(f"checksum mismatch for {rel_path}")
52
+ return plaintext