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.
- lovstudio_skill_helper-0.1.0/.gitignore +8 -0
- lovstudio_skill_helper-0.1.0/LICENSE +21 -0
- lovstudio_skill_helper-0.1.0/PKG-INFO +8 -0
- lovstudio_skill_helper-0.1.0/README.md +46 -0
- lovstudio_skill_helper-0.1.0/pyproject.toml +19 -0
- lovstudio_skill_helper-0.1.0/src/lovstudio_skill_helper/__init__.py +1 -0
- lovstudio_skill_helper-0.1.0/src/lovstudio_skill_helper/api.py +123 -0
- lovstudio_skill_helper-0.1.0/src/lovstudio_skill_helper/cli.py +264 -0
- lovstudio_skill_helper-0.1.0/src/lovstudio_skill_helper/config.py +112 -0
- lovstudio_skill_helper-0.1.0/src/lovstudio_skill_helper/crypto.py +52 -0
|
@@ -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
|