unmuzzle 0.1.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.
unmuzzle/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Unmuzzle: censorship-resistant distribution for open-weight models."""
2
+
3
+ __version__ = "0.1.0"
unmuzzle/api.py ADDED
@@ -0,0 +1,206 @@
1
+ """Programmatic API for agents.
2
+
3
+ Agents (Claude Code, Codex, Kimi Code, MCP clients) should call these
4
+ functions, or the MCP server in unmuzzle.mcp_server, instead of scraping
5
+ CLI output. Every function returns plain JSON-serializable dicts and raises
6
+ UnmuzzleError with a machine-actionable message.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ import subprocess
12
+ from pathlib import Path
13
+ from typing import Callable, List, Optional
14
+
15
+ from . import hfcache, sign as signmod
16
+ from .download import DownloadError, download_file, download_torrent, sha256_file
17
+ from .index import ModelEntry, find_model, load_index
18
+ from .publish import build_entry, write_entry
19
+
20
+
21
+ class UnmuzzleError(Exception):
22
+ pass
23
+
24
+
25
+ def _entry_dict(e: ModelEntry) -> dict:
26
+ d = dict(e.raw) if e.raw else {}
27
+ d.update({
28
+ "name": e.name,
29
+ "description": e.description,
30
+ "base_model": e.base_model,
31
+ "license": e.license,
32
+ "tags": e.tags,
33
+ "files": e.files,
34
+ "total_size": e.total_size,
35
+ "mirrors": {"http": e.http, "magnet": e.magnet, "torrent": e.torrent},
36
+ "signed": bool(e.signature),
37
+ "revision": e.revision(),
38
+ })
39
+ return d
40
+
41
+
42
+ def _find(name: str, index: Optional[str]) -> ModelEntry:
43
+ entry = find_model(load_index(index), name)
44
+ if not entry:
45
+ raise UnmuzzleError(f"not found: {name}")
46
+ return entry
47
+
48
+
49
+ def list_models(tag: Optional[str] = None, index: Optional[str] = None) -> List[dict]:
50
+ entries = load_index(index)
51
+ if tag:
52
+ entries = [e for e in entries if tag in e.tags]
53
+ return [_entry_dict(e) for e in entries]
54
+
55
+
56
+ def model_info(name: str, index: Optional[str] = None) -> dict:
57
+ return _entry_dict(_find(name, index))
58
+
59
+
60
+ def check_signature(entry: ModelEntry, require: bool = False) -> dict:
61
+ """Verify the entry's minisign signature. Returns a status dict; raises if invalid."""
62
+ if not entry.signature:
63
+ if require:
64
+ raise UnmuzzleError(f"{entry.name} is unsigned and signature was required")
65
+ return {"signed": False, "verified": False}
66
+ if not entry.publisher_pubkey:
67
+ raise UnmuzzleError("entry has a signature but no publisher_pubkey")
68
+ if not signmod.have_minisign():
69
+ if require:
70
+ raise UnmuzzleError("minisign not installed, cannot verify (brew install minisign)")
71
+ return {"signed": True, "verified": False, "warning": "minisign not installed, skipped"}
72
+ if not signmod.verify(entry.canonical_manifest(), entry.signature, entry.publisher_pubkey):
73
+ raise UnmuzzleError(f"SIGNATURE INVALID for {entry.name}")
74
+ return {"signed": True, "verified": True}
75
+
76
+
77
+ def get(
78
+ name: str,
79
+ dest: Optional[str] = None,
80
+ method: str = "auto",
81
+ jobs: int = 8,
82
+ require_signature: bool = False,
83
+ index: Optional[str] = None,
84
+ progress: Optional[Callable[[str], None]] = None,
85
+ ) -> dict:
86
+ """Download a model. Returns {revision, method, path, hf_cache, files, bytes}."""
87
+ entry = _find(name, index)
88
+ sig = check_signature(entry, require_signature)
89
+
90
+ if method == "auto":
91
+ if (entry.torrent or entry.magnet) and shutil.which("aria2c"):
92
+ method = "torrent"
93
+ elif entry.http:
94
+ method = "http"
95
+ elif entry.torrent or entry.magnet:
96
+ method = "torrent"
97
+ else:
98
+ raise UnmuzzleError("entry has no usable mirrors")
99
+ if method == "torrent" and not (entry.torrent or entry.magnet):
100
+ raise UnmuzzleError("entry has no torrent or magnet link")
101
+ if method == "http" and not entry.http:
102
+ raise UnmuzzleError("entry has no http mirrors")
103
+
104
+ revision = entry.revision()
105
+ if dest:
106
+ root = Path(dest)
107
+ else:
108
+ root = hfcache.model_cache_dir(entry.name) / ".staging" / revision
109
+ root.mkdir(parents=True, exist_ok=True)
110
+
111
+ try:
112
+ if method == "torrent":
113
+ download_torrent(entry.torrent or entry.magnet, root)
114
+ for f in entry.files:
115
+ p = root / f["path"]
116
+ if not p.exists() or sha256_file(p) != f["sha256"]:
117
+ raise UnmuzzleError(f"{f['path']}: sha256 mismatch after torrent download")
118
+ else:
119
+ for f in entry.files:
120
+ if progress:
121
+ progress(f"downloading {f['path']}")
122
+ download_file(entry.http, f["path"], root / f["path"], f["size"], f["sha256"],
123
+ jobs=jobs, progress=progress)
124
+ except DownloadError as e:
125
+ raise UnmuzzleError(str(e))
126
+
127
+ result = {
128
+ "name": entry.name,
129
+ "revision": revision,
130
+ "method": method,
131
+ "files": len(entry.files),
132
+ "bytes": entry.total_size,
133
+ "signature": sig,
134
+ }
135
+ if dest:
136
+ result.update(path=str(root), hf_cache=False)
137
+ else:
138
+ snapshot = hfcache.install(entry.name, revision, root, entry.files)
139
+ shutil.rmtree(root, ignore_errors=True)
140
+ result.update(path=str(snapshot), hf_cache=True)
141
+ result["load_with"] = (
142
+ f'from transformers import AutoModelForCausalLM; '
143
+ f'model = AutoModelForCausalLM.from_pretrained("{entry.name}", local_files_only=True)'
144
+ )
145
+ return result
146
+
147
+
148
+ def publish(
149
+ directory: str,
150
+ name: str,
151
+ http_bases: Optional[List[str]] = None,
152
+ magnet: Optional[str] = None,
153
+ torrent_url: Optional[str] = None,
154
+ description: str = "",
155
+ base_model: str = "",
156
+ license: str = "",
157
+ tags: Optional[List[str]] = None,
158
+ sign_key: Optional[str] = None,
159
+ pubkey: Optional[str] = None,
160
+ index_dir: str = "index",
161
+ ) -> dict:
162
+ """Hash a local model dir, optionally sign, and add it to a local index."""
163
+ entry = build_entry(
164
+ directory=Path(directory), name=name, http_bases=list(http_bases or []),
165
+ magnet=magnet, torrent_url=torrent_url, description=description,
166
+ base_model=base_model,
167
+ license_=license, tags=list(tags or []),
168
+ secret_key=Path(sign_key) if sign_key else None, pubkey=pubkey,
169
+ )
170
+ path = write_entry(Path(index_dir), entry)
171
+ return {
172
+ "entry": entry,
173
+ "entry_path": str(path),
174
+ "index": str(Path(index_dir) / "index.json"),
175
+ "signed": bool(entry.get("signature")),
176
+ }
177
+
178
+
179
+ def verify(name: str, index: Optional[str] = None) -> dict:
180
+ """Re-hash an installed model against the index."""
181
+ entry = _find(name, index)
182
+ snapshot = hfcache.model_cache_dir(entry.name) / "snapshots" / entry.revision()
183
+ if not snapshot.exists():
184
+ raise UnmuzzleError(f"not installed: {entry.name}")
185
+ bad = []
186
+ for f in entry.files:
187
+ p = snapshot / f["path"]
188
+ if not (p.exists() and sha256_file(p) == f["sha256"]):
189
+ bad.append(f["path"])
190
+ return {"name": entry.name, "ok": not bad, "bad_files": bad,
191
+ "checked": len(entry.files), "path": str(snapshot)}
192
+
193
+
194
+ def keygen(key_path: Optional[str] = None) -> dict:
195
+ """Generate a minisign keypair for signing releases. Non-interactive."""
196
+ if not signmod.have_minisign():
197
+ raise UnmuzzleError("minisign not installed (brew install minisign / apt install minisign)")
198
+ secret = Path(key_path or Path.home() / ".minisign" / "unmuzzle.key")
199
+ pub = secret.with_suffix(".pub")
200
+ if secret.exists():
201
+ raise UnmuzzleError(f"key already exists: {secret}")
202
+ secret.parent.mkdir(parents=True, exist_ok=True)
203
+ subprocess.run(["minisign", "-G", "-W", "-s", str(secret), "-p", str(pub)],
204
+ check=True, capture_output=True)
205
+ return {"secret_key": str(secret), "public_key": str(pub),
206
+ "pubkey": signmod.pubkey_from_secret(secret)}
unmuzzle/cli.py ADDED
@@ -0,0 +1,190 @@
1
+ """unmuzzle command line interface.
2
+
3
+ Thin wrapper over unmuzzle.api. All output is human-readable by default and
4
+ JSON with --json, so agents can drive everything non-interactively.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import sys
11
+
12
+ from . import __version__, api
13
+
14
+
15
+ def _fmt_size(n) -> str:
16
+ n = float(n)
17
+ for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
18
+ if n < 1024 or unit == "TiB":
19
+ return f"{n:.1f} {unit}" if unit != "B" else f"{int(n)} B"
20
+ n /= 1024
21
+ return f"{n} B"
22
+
23
+
24
+ def _emit(obj, as_json: bool) -> None:
25
+ if as_json:
26
+ print(json.dumps(obj, indent=2))
27
+
28
+
29
+ def cmd_list(args) -> int:
30
+ models = api.list_models(tag=args.tag, index=args.index)
31
+ _emit(models, args.json)
32
+ if not args.json:
33
+ if not models:
34
+ print("index is empty. Publish a model with `unmuzzle publish`.")
35
+ for m in models:
36
+ sig = " [signed]" if m["signed"] else ""
37
+ tags = f" ({', '.join(m['tags'])})" if m["tags"] else ""
38
+ print(f"{m['name']:<44} {_fmt_size(m['total_size']):>10}{sig}{tags}")
39
+ return 0
40
+
41
+
42
+ def cmd_info(args) -> int:
43
+ m = api.model_info(args.model, index=args.index)
44
+ _emit(m, args.json)
45
+ if not args.json:
46
+ print(f"name: {m['name']}")
47
+ for label, key in (("description", "description"), ("base model", "base_model"),
48
+ ("license", "license")):
49
+ if m.get(key):
50
+ print(f"{label + ':':<13}{m[key]}")
51
+ print(f"revision: {m['revision']}")
52
+ print(f"total size: {_fmt_size(m['total_size'])}")
53
+ print(f"mirrors: {len(m['mirrors']['http'])} http"
54
+ + (", 1 torrent" if m["mirrors"]["magnet"] else ""))
55
+ print(f"signature: {'yes' if m['signed'] else 'no'}")
56
+ print("files:")
57
+ for f in m["files"]:
58
+ print(f" {_fmt_size(f['size']):>10} {f['path']}")
59
+ return 0
60
+
61
+
62
+ def cmd_get(args) -> int:
63
+ progress = (lambda s: print(s, file=sys.stderr)) if args.json else print
64
+ result = api.get(
65
+ args.model, dest=args.dest, method=args.method, jobs=args.jobs,
66
+ require_signature=args.require_signature, index=args.index,
67
+ progress=progress,
68
+ )
69
+ _emit(result, args.json)
70
+ if not args.json:
71
+ if result["signature"].get("verified"):
72
+ print("signature: OK")
73
+ if result["hf_cache"]:
74
+ print(f"\ninstalled into HF cache: {result['path']}")
75
+ print(f"\nload it with:\n {result['load_with']}")
76
+ else:
77
+ print(f"\ndone. Files in {result['path']}")
78
+ return 0
79
+
80
+
81
+ def cmd_publish(args) -> int:
82
+ result = api.publish(
83
+ directory=args.dir, name=args.name, http_bases=args.http_base,
84
+ magnet=args.magnet, torrent_url=args.torrent_url,
85
+ description=args.description or "",
86
+ base_model=args.base_model or "", license=args.license or "",
87
+ tags=args.tag, sign_key=args.sign_key, pubkey=args.pubkey,
88
+ index_dir=args.index_dir,
89
+ )
90
+ _emit(result, args.json)
91
+ if not args.json:
92
+ print(f"wrote {result['entry_path']}")
93
+ print(f"regenerated {result['index']}")
94
+ if not result["signed"]:
95
+ print("note: entry is unsigned. Use --sign-key to sign releases.")
96
+ print("\nnext: upload the model files to your mirror(s), then commit and push the index.")
97
+ return 0
98
+
99
+
100
+ def cmd_verify(args) -> int:
101
+ result = api.verify(args.model, index=args.index)
102
+ _emit(result, args.json)
103
+ if not args.json:
104
+ print(f"{'OK' if result['ok'] else 'BAD'}: {result['checked']} files checked at {result['path']}")
105
+ return 0 if result["ok"] else 1
106
+
107
+
108
+ def cmd_keygen(args) -> int:
109
+ result = api.keygen(args.key)
110
+ _emit(result, args.json)
111
+ if not args.json:
112
+ print(f"secret key: {result['secret_key']} (keep it secret, back it up)")
113
+ print(f"public key: {result['public_key']}")
114
+ print(f"pubkey: {result['pubkey']}")
115
+ return 0
116
+
117
+
118
+ def main(argv=None) -> int:
119
+ p = argparse.ArgumentParser(
120
+ prog="unmuzzle",
121
+ description="Agent-native, censorship-resistant distribution for open-weight models.",
122
+ )
123
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
124
+ sub = p.add_subparsers(dest="cmd", required=True)
125
+
126
+ def common(sp, index=False):
127
+ sp.add_argument("--json", action="store_true", help="machine-readable output")
128
+ if index:
129
+ sp.add_argument("--index", default=None,
130
+ help="index URL or local path (default: $UNMUZZLE_INDEX or the unmuzzle index)")
131
+
132
+ sp = sub.add_parser("list", help="list models in the index")
133
+ sp.add_argument("--tag", help="filter by tag")
134
+ common(sp, index=True)
135
+ sp.set_defaults(fn=cmd_list)
136
+
137
+ sp = sub.add_parser("info", help="show files, mirrors, and signature for a model")
138
+ sp.add_argument("model")
139
+ common(sp, index=True)
140
+ sp.set_defaults(fn=cmd_info)
141
+
142
+ sp = sub.add_parser("get", help="download a model and install it into the HF cache")
143
+ sp.add_argument("model")
144
+ sp.add_argument("--dest", help="download plain files to this directory instead of the HF cache")
145
+ sp.add_argument("--method", choices=["auto", "http", "torrent"], default="auto")
146
+ sp.add_argument("--jobs", type=int, default=8, help="parallel chunks per file (http)")
147
+ sp.add_argument("--require-signature", action="store_true",
148
+ help="abort unless the entry has a valid minisign signature")
149
+ common(sp, index=True)
150
+ sp.set_defaults(fn=cmd_get)
151
+
152
+ sp = sub.add_parser("publish", help="hash a local model dir and add it to a local index")
153
+ sp.add_argument("dir", help="directory containing the model files")
154
+ sp.add_argument("--name", required=True, help="org/name")
155
+ sp.add_argument("--http-base", action="append", help="base URL where files will be served (repeatable)")
156
+ sp.add_argument("--magnet", help="magnet link for the torrent of this directory")
157
+ sp.add_argument("--torrent-url", help="URL of the .torrent file (preferred over magnet: works with zero peers via web seeds)")
158
+ sp.add_argument("--description")
159
+ sp.add_argument("--base-model")
160
+ sp.add_argument("--license")
161
+ sp.add_argument("--tag", action="append", help="tag (repeatable)")
162
+ sp.add_argument("--sign-key", help="minisign secret key to sign the manifest")
163
+ sp.add_argument("--pubkey", help="publisher pubkey (base64); derived from --sign-key if omitted")
164
+ sp.add_argument("--index-dir", default="index", help="local index directory to update")
165
+ common(sp)
166
+ sp.set_defaults(fn=cmd_publish)
167
+
168
+ sp = sub.add_parser("verify", help="re-hash an installed model against the index")
169
+ sp.add_argument("model")
170
+ common(sp, index=True)
171
+ sp.set_defaults(fn=cmd_verify)
172
+
173
+ sp = sub.add_parser("keygen", help="generate a minisign keypair for signing releases")
174
+ sp.add_argument("--key", help="secret key path (default: ~/.minisign/unmuzzle.key)")
175
+ common(sp)
176
+ sp.set_defaults(fn=cmd_keygen)
177
+
178
+ args = p.parse_args(argv)
179
+ try:
180
+ return args.fn(args)
181
+ except api.UnmuzzleError as e:
182
+ if getattr(args, "json", False):
183
+ print(json.dumps({"error": str(e)}), file=sys.stderr)
184
+ else:
185
+ print(f"error: {e}", file=sys.stderr)
186
+ return 1
187
+
188
+
189
+ if __name__ == "__main__":
190
+ sys.exit(main())
unmuzzle/download.py ADDED
@@ -0,0 +1,138 @@
1
+ """Parallel ranged HTTP downloads with resume, plus magnet links via aria2c.
2
+
3
+ HTTP downloads split each file into fixed-size chunks fetched with Range
4
+ requests from any of the entry's mirrors. Chunks land in a .parts directory
5
+ next to the target, so an interrupted download resumes where it stopped.
6
+ Every completed file is verified against the sha256 from the index before
7
+ it is moved into place.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import concurrent.futures as cf
12
+ import hashlib
13
+ import shutil
14
+ import subprocess
15
+ import urllib.request
16
+ from pathlib import Path
17
+ from typing import Callable, List, Optional
18
+
19
+ DEFAULT_CHUNK = 32 * 1024 * 1024 # 32 MiB
20
+
21
+
22
+ class DownloadError(Exception):
23
+ pass
24
+
25
+
26
+ def _fetch_range(url: str, start: int, end: int, dest: Path, retries: int = 4) -> None:
27
+ want = end - start + 1
28
+ for attempt in range(retries):
29
+ try:
30
+ req = urllib.request.Request(url, headers={"Range": f"bytes={start}-{end}"})
31
+ with urllib.request.urlopen(req, timeout=300) as r, open(dest, "wb") as f:
32
+ shutil.copyfileobj(r, f, 1024 * 1024)
33
+ if dest.stat().st_size == want:
34
+ return
35
+ except Exception:
36
+ if attempt == retries - 1:
37
+ raise
38
+ raise DownloadError(f"range {start}-{end} from {url}: incomplete after {retries} tries")
39
+
40
+
41
+ def sha256_file(path: Path, buf: int = 8 * 1024 * 1024) -> str:
42
+ h = hashlib.sha256()
43
+ with open(path, "rb") as f:
44
+ for block in iter(lambda: f.read(buf), b""):
45
+ h.update(block)
46
+ return h.hexdigest()
47
+
48
+
49
+ def download_file(
50
+ base_urls: List[str],
51
+ relpath: str,
52
+ dest: Path,
53
+ size: int,
54
+ sha256: str,
55
+ jobs: int = 8,
56
+ chunk_size: int = DEFAULT_CHUNK,
57
+ progress: Optional[Callable[[str], None]] = None,
58
+ ) -> Path:
59
+ """Fetch one file from any of base_urls (base + '/' + relpath), verify sha256."""
60
+ dest = Path(dest)
61
+ dest.parent.mkdir(parents=True, exist_ok=True)
62
+ if dest.exists() and dest.stat().st_size == size and sha256_file(dest) == sha256:
63
+ return dest # already have it, verified
64
+
65
+ urls = [f"{base.rstrip('/')}/{relpath}" for base in base_urls]
66
+ parts_dir = dest.parent / (dest.name + ".parts")
67
+ parts_dir.mkdir(parents=True, exist_ok=True)
68
+
69
+ n_chunks = max(1, (size + chunk_size - 1) // chunk_size)
70
+ todo = []
71
+ for i in range(n_chunks):
72
+ start = i * chunk_size
73
+ end = min(size - 1, start + chunk_size - 1)
74
+ part = parts_dir / f"{i:06d}"
75
+ if part.exists() and part.stat().st_size == end - start + 1:
76
+ continue # resume: chunk already done
77
+ todo.append((i, start, end, part))
78
+
79
+ def work(t):
80
+ i, start, end, part = t
81
+ last_err: Optional[Exception] = None
82
+ for url in urls:
83
+ try:
84
+ _fetch_range(url, start, end, part)
85
+ return i
86
+ except Exception as e: # try next mirror
87
+ last_err = e
88
+ raise DownloadError(f"{relpath} chunk {i}: all mirrors failed ({last_err})")
89
+
90
+ done = n_chunks - len(todo)
91
+ if todo:
92
+ with cf.ThreadPoolExecutor(max_workers=jobs) as ex:
93
+ for _ in ex.map(work, todo):
94
+ done += 1
95
+ if progress:
96
+ progress(f" {relpath}: {done}/{n_chunks} chunks")
97
+
98
+ tmp = dest.with_name(dest.name + ".tmp")
99
+ h = hashlib.sha256()
100
+ with open(tmp, "wb") as out:
101
+ for i in range(n_chunks):
102
+ with open(parts_dir / f"{i:06d}", "rb") as p:
103
+ while True:
104
+ block = p.read(8 * 1024 * 1024)
105
+ if not block:
106
+ break
107
+ h.update(block)
108
+ out.write(block)
109
+ if h.hexdigest() != sha256:
110
+ tmp.unlink(missing_ok=True)
111
+ shutil.rmtree(parts_dir, ignore_errors=True)
112
+ raise DownloadError(f"{relpath}: sha256 mismatch (expected {sha256})")
113
+ if tmp.stat().st_size != size:
114
+ tmp.unlink(missing_ok=True)
115
+ raise DownloadError(f"{relpath}: size mismatch (expected {size})")
116
+ tmp.replace(dest)
117
+ shutil.rmtree(parts_dir, ignore_errors=True)
118
+ return dest
119
+
120
+
121
+ def download_torrent(uri_or_file: str, dest_dir: Path) -> None:
122
+ """Fetch a torrent (magnet URI, .torrent URL, or local .torrent path) into
123
+ dest_dir using aria2c. Seeding is left to the user.
124
+
125
+ Prefer .torrent files over bare magnets when web seeds are the only
126
+ guaranteed source: a magnet still needs a reachable peer to fetch the
127
+ metadata, a .torrent plus a web seed works with zero peers.
128
+ """
129
+ if not shutil.which("aria2c"):
130
+ raise DownloadError(
131
+ "torrent downloads need aria2c (brew install aria2 / apt install aria2), "
132
+ "or retry with --method http"
133
+ )
134
+ dest_dir.mkdir(parents=True, exist_ok=True)
135
+ subprocess.run(
136
+ ["aria2c", "--seed-time=0", "--summary-interval=30", "-d", str(dest_dir), uri_or_file],
137
+ check=True,
138
+ )
unmuzzle/hfcache.py ADDED
@@ -0,0 +1,54 @@
1
+ """Install downloaded files into the Hugging Face hub cache layout.
2
+
3
+ Once installed, `from_pretrained("org/name", local_files_only=True)` loads
4
+ the model with zero changes to user code. Blobs are content-addressed by
5
+ sha256, snapshots symlink (or copy) into blobs, refs/main points at the
6
+ revision.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shutil
12
+ from pathlib import Path
13
+ from typing import List
14
+
15
+
16
+ def hf_cache_root() -> Path:
17
+ return Path(os.environ.get("HF_HOME", Path.home() / ".cache" / "huggingface")) / "hub"
18
+
19
+
20
+ def model_cache_dir(model_id: str) -> Path:
21
+ return hf_cache_root() / f"models--{model_id.replace('/', '--')}"
22
+
23
+
24
+ def install(model_id: str, revision: str, staging: Path, files: List[dict]) -> Path:
25
+ """Link files from staging into the HF cache. Returns the snapshot path."""
26
+ staging = Path(staging)
27
+ cache = model_cache_dir(model_id)
28
+ blobs = cache / "blobs"
29
+ snapshot = cache / "snapshots" / revision
30
+ refs = cache / "refs"
31
+ for d in (blobs, snapshot, refs):
32
+ d.mkdir(parents=True, exist_ok=True)
33
+
34
+ for f in files:
35
+ src = staging / f["path"]
36
+ if not src.exists():
37
+ raise FileNotFoundError(f"missing staged file {src}")
38
+ blob = blobs / f["sha256"]
39
+ if not blob.exists():
40
+ try:
41
+ os.link(src, blob)
42
+ except OSError:
43
+ shutil.copy2(src, blob)
44
+ link = snapshot / f["path"]
45
+ link.parent.mkdir(parents=True, exist_ok=True)
46
+ if link.is_symlink() or link.exists():
47
+ link.unlink()
48
+ try:
49
+ os.symlink(os.path.relpath(blob, link.parent), link)
50
+ except OSError: # filesystem without symlinks
51
+ shutil.copy2(blob, link)
52
+
53
+ (refs / "main").write_text(revision)
54
+ return snapshot
unmuzzle/index.py ADDED
@@ -0,0 +1,116 @@
1
+ """Index loading, validation, and manifest canonicalization.
2
+
3
+ The index is a single JSON document (see SPEC.md). It can live anywhere:
4
+ a git repo, static hosting, IPFS. Clients fetch it over HTTPS or read it
5
+ from a local path.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import urllib.request
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import List, Optional
16
+
17
+ DEFAULT_INDEX = (
18
+ "https://raw.githubusercontent.com/zengjiajun0623/unmuzzle-hub/main/index/index.json"
19
+ )
20
+
21
+
22
+ class IndexError(Exception):
23
+ pass
24
+
25
+
26
+ @dataclass
27
+ class ModelEntry:
28
+ name: str # "org/name"
29
+ description: str = ""
30
+ base_model: str = ""
31
+ license: str = ""
32
+ tags: List[str] = field(default_factory=list)
33
+ files: List[dict] = field(default_factory=list) # {path, size, sha256}
34
+ http: List[str] = field(default_factory=list) # base URLs, file fetched from base + "/" + path
35
+ magnet: Optional[str] = None
36
+ torrent: Optional[str] = None # URL of the .torrent file (works with zero peers via web seeds)
37
+ publisher_pubkey: Optional[str] = None
38
+ signature: Optional[str] = None # minisign signature over canonical_manifest()
39
+ added: str = ""
40
+ raw: dict = field(default_factory=dict)
41
+
42
+ @property
43
+ def total_size(self) -> int:
44
+ return sum(f["size"] for f in self.files)
45
+
46
+ def canonical_manifest(self) -> str:
47
+ """Deterministic string that gets signed. One 'sha256 path' line per file, sorted."""
48
+ lines = sorted(f'{f["sha256"]} {f["path"]}' for f in self.files)
49
+ return "\n".join(lines) + "\n"
50
+
51
+ def revision(self) -> str:
52
+ """Content-addressed stand-in for an HF commit sha."""
53
+ return hashlib.sha256(self.canonical_manifest().encode()).hexdigest()[:16]
54
+
55
+
56
+ def validate_entry(d: dict) -> ModelEntry:
57
+ if not isinstance(d, dict):
58
+ raise IndexError("entry is not an object")
59
+ name = d.get("name")
60
+ if not name or "/" not in name:
61
+ raise IndexError(f"bad model name: {name!r} (expected 'org/name')")
62
+ files = d.get("files")
63
+ if not files:
64
+ raise IndexError(f"{name}: no files")
65
+ for f in files:
66
+ for key in ("path", "size", "sha256"):
67
+ if key not in f:
68
+ raise IndexError(f"{name}: file entry missing {key!r}: {f!r}")
69
+ parts = Path(f["path"]).parts
70
+ if f["path"].startswith("/") or ".." in parts:
71
+ raise IndexError(f"{name}: unsafe path {f['path']!r}")
72
+ if not isinstance(f["size"], int) or f["size"] < 0:
73
+ raise IndexError(f"{name}: bad size for {f['path']!r}")
74
+ if len(f["sha256"]) != 64:
75
+ raise IndexError(f"{name}: bad sha256 for {f['path']!r}")
76
+ mirrors = d.get("mirrors") or {}
77
+ entry = ModelEntry(
78
+ name=name,
79
+ description=d.get("description", ""),
80
+ base_model=d.get("base_model", ""),
81
+ license=d.get("license", ""),
82
+ tags=list(d.get("tags", [])),
83
+ files=list(files),
84
+ http=list(mirrors.get("http", [])),
85
+ magnet=mirrors.get("magnet"),
86
+ torrent=mirrors.get("torrent"),
87
+ publisher_pubkey=d.get("publisher_pubkey"),
88
+ signature=d.get("signature"),
89
+ added=d.get("added", ""),
90
+ raw=d,
91
+ )
92
+ if not entry.http and not entry.magnet and not entry.torrent:
93
+ raise IndexError(f"{name}: entry has no mirrors")
94
+ return entry
95
+
96
+
97
+ def load_index(source: Optional[str] = None) -> List[ModelEntry]:
98
+ source = source or os.environ.get("UNMUZZLE_INDEX") or DEFAULT_INDEX
99
+ if source.startswith(("http://", "https://")):
100
+ with urllib.request.urlopen(source, timeout=30) as r:
101
+ data = json.loads(r.read().decode())
102
+ else:
103
+ data = json.loads(Path(source).read_text())
104
+ entries = data.get("models") if isinstance(data, dict) else data
105
+ return [validate_entry(e) for e in entries]
106
+
107
+
108
+ def find_model(entries: List[ModelEntry], name: str) -> Optional[ModelEntry]:
109
+ for e in entries:
110
+ if e.name == name:
111
+ return e
112
+ # allow shorthand without org if unambiguous
113
+ matches = [e for e in entries if e.name.split("/", 1)[1] == name]
114
+ if len(matches) == 1:
115
+ return matches[0]
116
+ return None
unmuzzle/mcp_server.py ADDED
@@ -0,0 +1,80 @@
1
+ """MCP server: expose unmuzzle to any MCP-capable agent.
2
+
3
+ Install: pip install 'unmuzzle[mcp]'
4
+ Run: unmuzzle-mcp (stdio transport)
5
+
6
+ Claude Code: claude mcp add unmuzzle -- unmuzzle-mcp
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import List, Optional
12
+
13
+ from . import api
14
+
15
+
16
+ def main() -> None:
17
+ try:
18
+ from mcp.server.fastmcp import FastMCP
19
+ except ImportError:
20
+ raise SystemExit("MCP support needs: pip install 'unmuzzle[mcp]'")
21
+
22
+ mcp = FastMCP("unmuzzle")
23
+
24
+ @mcp.tool()
25
+ def list_models(tag: Optional[str] = None, index: Optional[str] = None) -> str:
26
+ """List open-weight models in the unmuzzle index. Optionally filter by tag."""
27
+ return json.dumps(api.list_models(tag=tag, index=index))
28
+
29
+ @mcp.tool()
30
+ def model_info(name: str, index: Optional[str] = None) -> str:
31
+ """Show files, sizes, sha256, mirrors, and signature status for a model (org/name)."""
32
+ return json.dumps(api.model_info(name, index=index))
33
+
34
+ @mcp.tool()
35
+ def get_model(
36
+ name: str,
37
+ dest: Optional[str] = None,
38
+ method: str = "auto",
39
+ require_signature: bool = True,
40
+ index: Optional[str] = None,
41
+ ) -> str:
42
+ """Download a model, verify sha256 + signature, and install it into the
43
+ Hugging Face cache (default) or a plain directory (dest). method is
44
+ auto, http, or torrent."""
45
+ return json.dumps(api.get(name, dest=dest, method=method,
46
+ require_signature=require_signature, index=index))
47
+
48
+ @mcp.tool()
49
+ def publish_model(
50
+ directory: str,
51
+ name: str,
52
+ http_bases: Optional[List[str]] = None,
53
+ magnet: Optional[str] = None,
54
+ torrent_url: Optional[str] = None,
55
+ description: str = "",
56
+ base_model: str = "",
57
+ license: str = "",
58
+ tags: Optional[List[str]] = None,
59
+ sign_key: Optional[str] = None,
60
+ index_dir: str = "index",
61
+ ) -> str:
62
+ """Hash a local model directory, sign the manifest, and add it to a
63
+ local index. Upload the files to the mirror(s) yourself first or right
64
+ after; the entry records where they live."""
65
+ return json.dumps(api.publish(directory, name, http_bases=http_bases,
66
+ magnet=magnet, torrent_url=torrent_url,
67
+ description=description,
68
+ base_model=base_model, license=license,
69
+ tags=tags, sign_key=sign_key, index_dir=index_dir))
70
+
71
+ @mcp.tool()
72
+ def verify_model(name: str, index: Optional[str] = None) -> str:
73
+ """Re-hash an installed model against the signed index manifest."""
74
+ return json.dumps(api.verify(name, index=index))
75
+
76
+ mcp.run()
77
+
78
+
79
+ if __name__ == "__main__":
80
+ main()
unmuzzle/publish.py ADDED
@@ -0,0 +1,88 @@
1
+ """Build index entries from a local model directory (`unmuzzle publish`)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from datetime import date
6
+ from pathlib import Path
7
+ from typing import List, Optional
8
+
9
+ from .download import sha256_file
10
+ from .index import validate_entry
11
+ from . import sign as signmod
12
+
13
+ SKIP_NAMES = {".DS_Store"}
14
+ SKIP_SUFFIXES = (".parts", ".tmp")
15
+
16
+
17
+ def _iter_model_files(root: Path):
18
+ for p in sorted(root.rglob("*")):
19
+ if not p.is_file():
20
+ continue
21
+ rel = p.relative_to(root).as_posix()
22
+ if p.name in SKIP_NAMES or any(rel.endswith(s) for s in SKIP_SUFFIXES):
23
+ continue
24
+ if any(part.startswith(".") for part in p.relative_to(root).parts):
25
+ continue
26
+ yield rel, p
27
+
28
+
29
+ def build_entry(
30
+ directory: Path,
31
+ name: str,
32
+ http_bases: List[str],
33
+ magnet: Optional[str] = None,
34
+ torrent_url: Optional[str] = None,
35
+ description: str = "",
36
+ base_model: str = "",
37
+ license_: str = "",
38
+ tags: Optional[List[str]] = None,
39
+ secret_key: Optional[Path] = None,
40
+ pubkey: Optional[str] = None,
41
+ ) -> dict:
42
+ directory = Path(directory)
43
+ files = []
44
+ for rel, p in _iter_model_files(directory):
45
+ files.append({"path": rel, "size": p.stat().st_size, "sha256": sha256_file(p)})
46
+ if not files:
47
+ raise ValueError(f"no model files found in {directory}")
48
+
49
+ entry = {
50
+ "name": name,
51
+ "description": description,
52
+ "base_model": base_model,
53
+ "license": license_,
54
+ "tags": tags or [],
55
+ "added": date.today().isoformat(),
56
+ "files": files,
57
+ "mirrors": {"http": http_bases, "magnet": magnet, "torrent": torrent_url},
58
+ }
59
+
60
+ if secret_key:
61
+ manifest = validate_entry(entry).canonical_manifest()
62
+ entry["signature"] = signmod.sign(manifest, Path(secret_key))
63
+ entry["publisher_pubkey"] = pubkey or signmod.pubkey_from_secret(Path(secret_key))
64
+
65
+ validate_entry(entry) # final sanity check
66
+ return entry
67
+
68
+
69
+ def write_entry(index_dir: Path, entry: dict) -> Path:
70
+ """Write one entry file and regenerate the combined index.json."""
71
+ index_dir = Path(index_dir)
72
+ models_dir = index_dir / "models"
73
+ models_dir.mkdir(parents=True, exist_ok=True)
74
+ slug = entry["name"].replace("/", "__")
75
+ (models_dir / f"{slug}.json").write_text(json.dumps(entry, indent=2) + "\n")
76
+ regenerate(index_dir)
77
+ return models_dir / f"{slug}.json"
78
+
79
+
80
+ def regenerate(index_dir: Path) -> Path:
81
+ models_dir = Path(index_dir) / "models"
82
+ entries = []
83
+ if models_dir.exists():
84
+ for f in sorted(models_dir.glob("*.json")):
85
+ entries.append(json.loads(f.read_text()))
86
+ out = Path(index_dir) / "index.json"
87
+ out.write_text(json.dumps({"version": 1, "models": entries}, indent=2) + "\n")
88
+ return out
unmuzzle/sign.py ADDED
@@ -0,0 +1,64 @@
1
+ """minisign-based signing of manifests.
2
+
3
+ The publisher signs the canonical manifest (sha256 + path per file) with
4
+ minisign (OpenBSD signify compatible). Clients verify before installing.
5
+ minisign is a single static binary: brew install minisign.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ import subprocess
11
+ import tempfile
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+
16
+ class SignatureError(Exception):
17
+ pass
18
+
19
+
20
+ def have_minisign() -> bool:
21
+ return shutil.which("minisign") is not None
22
+
23
+
24
+ def pubkey_from_secret(secret_key: Path) -> str:
25
+ """Derive the base64 public key from a secret key via `minisign -R`."""
26
+ with tempfile.TemporaryDirectory() as td:
27
+ pub = Path(td) / "key.pub"
28
+ subprocess.run(
29
+ ["minisign", "-R", "-s", str(secret_key), "-p", str(pub)],
30
+ check=True, capture_output=True,
31
+ )
32
+ lines = pub.read_text().splitlines()
33
+ for line in lines:
34
+ line = line.strip()
35
+ if line and not line.startswith("untrusted comment"):
36
+ return line
37
+ raise SignatureError(f"could not parse pubkey from {pub!r}")
38
+
39
+
40
+ def sign(manifest: str, secret_key: Path) -> str:
41
+ with tempfile.TemporaryDirectory() as td:
42
+ msg = Path(td) / "manifest.txt"
43
+ sig = Path(td) / "manifest.txt.minisig"
44
+ msg.write_text(manifest)
45
+ subprocess.run(
46
+ ["minisign", "-S", "-s", str(secret_key), "-x", str(sig), "-m", str(msg)],
47
+ check=True, capture_output=True,
48
+ )
49
+ return sig.read_text()
50
+
51
+
52
+ def verify(manifest: str, signature: str, pubkey_b64: str) -> bool:
53
+ if not have_minisign():
54
+ raise SignatureError("minisign not installed, cannot verify signature")
55
+ with tempfile.TemporaryDirectory() as td:
56
+ msg = Path(td) / "manifest.txt"
57
+ sig = Path(td) / "manifest.txt.minisig"
58
+ msg.write_text(manifest)
59
+ sig.write_text(signature)
60
+ r = subprocess.run(
61
+ ["minisign", "-V", "-P", pubkey_b64, "-x", str(sig), "-m", str(msg)],
62
+ capture_output=True,
63
+ )
64
+ return r.returncode == 0
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: unmuzzle
3
+ Version: 0.1.0
4
+ Summary: Censorship-resistant distribution for open-weight models. BitTorrent + HTTP mirrors, signed checksums, Hugging Face cache compatible.
5
+ Author: Jiajun Zeng
6
+ License: MIT
7
+ Keywords: llm,open-weights,bittorrent,distribution,censorship-resistant
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=7; extra == "dev"
13
+ Provides-Extra: mcp
14
+ Requires-Dist: mcp>=1.2; extra == "mcp"
15
+ Dynamic: license-file
16
+
17
+ # unmuzzle
18
+
19
+ [![e2e-verify](https://github.com/zengjiajun0623/unmuzzle-hub/actions/workflows/e2e-verify.yml/badge.svg)](https://github.com/zengjiajun0623/unmuzzle-hub/actions/workflows/e2e-verify.yml)
20
+
21
+ Agent-native distribution for open-weight models.
22
+
23
+ AI agents are the users here. `unmuzzle` is built so an agent (Claude Code,
24
+ Codex, Kimi Code, any MCP client) can publish and download models end to end
25
+ with no human in the loop: plain-JSON index, `--json` on every command, an MCP
26
+ server, and no gates, auth flows, or license click-throughs that only a human
27
+ in a browser can complete.
28
+
29
+ It is also censorship-resistant by construction:
30
+
31
+ - **The index is plain JSON in a git repo.** Anyone can mirror or fork it.
32
+ - **Weights move over BitTorrent (web-seeded) and HTTP mirrors.** Web seeds
33
+ keep every torrent alive at zero peers; no central server to block or bill.
34
+ - **Every file is sha256-pinned and the manifest is minisign-signed.** The
35
+ signature, not the host, is the trust root. Any static host can be a mirror.
36
+ - **Installs land in the Hugging Face cache layout**, so `transformers` loads
37
+ them with zero code changes.
38
+
39
+ Zero dependencies. Python 3.9+.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install git+https://github.com/zengjiajun0623/unmuzzle-hub.git
45
+ # optional: aria2 for torrents, minisign for signature verification
46
+ ```
47
+
48
+ ## For agents
49
+
50
+ Read [AGENTS.md](AGENTS.md). It is the complete publish/download protocol.
51
+ Or add the MCP server:
52
+
53
+ ```bash
54
+ pip install 'unmuzzle[mcp]'
55
+ claude mcp add unmuzzle -- unmuzzle-mcp
56
+ ```
57
+
58
+ ## For humans
59
+
60
+ ```bash
61
+ unmuzzle list
62
+ unmuzzle info unmuzzle/qwen2.5-14b-abliterated
63
+ unmuzzle get unmuzzle/qwen2.5-14b-abliterated --require-signature --dest ./models
64
+ ```
65
+
66
+ Every command takes `--json`.
67
+
68
+ ## First release: unmuzzle/qwen2.5-14b-abliterated
69
+
70
+ Qwen2.5-14B-Instruct with the CCP-censorship refusal direction weight-baked
71
+ out (GGUF Q4_K_M, ~9 GB, Apache-2.0). Answers factually on Tiananmen, Xinjiang,
72
+ Taiwan, and similar topics. Runs in Ollama/llama.cpp on a 16 GB machine:
73
+
74
+ ```bash
75
+ unmuzzle get unmuzzle/qwen2.5-14b-abliterated --require-signature --dest ./models
76
+ ollama create unmuzzle-qwen14b -f Modelfile # Modelfile in the HF mirror repo
77
+ ollama run unmuzzle-qwen14b
78
+ ```
79
+
80
+ Mirrors: Hugging Face HTTP + web-seeded torrent. Signed with the unmuzzle
81
+ minisign key.
82
+
83
+ ## Publish a model
84
+
85
+ ```bash
86
+ unmuzzle keygen # one-time signing identity
87
+ unmuzzle publish ./mymodel \
88
+ --name org/mymodel \
89
+ --http-base https://your-mirror/mymodel \
90
+ --torrent-url https://your-mirror/mymodel.torrent \
91
+ --magnet "magnet:?xt=..." \
92
+ --sign-key ~/.minisign/unmuzzle.key
93
+ ```
94
+
95
+ Then PR your `index/models/*.json` into this repo, or host your own index and
96
+ point clients at it with `--index` / `$UNMUZZLE_INDEX`. The full recipe,
97
+ including torrent creation with web seeds, is in [AGENTS.md](AGENTS.md).
98
+ Format and threat model: [SPEC.md](SPEC.md).
99
+
100
+ ## Why not just Hugging Face / a mirror / a torrent site?
101
+
102
+ - **HF** gates models behind human browser flows and can take them down
103
+ (ERNIE ViLG, GEITje). In mainland China the site itself is blocked, which is
104
+ why hf-mirror.com exists. Right default for ordinary models, wrong single
105
+ point of failure for models someone wants suppressed, and unusable for
106
+ agents hitting gated repos.
107
+ - **Mirrors** (hf-mirror, ModelScope) reintroduce a single operator who can
108
+ censor, and you cannot verify what they serve back to you.
109
+ - **A bare torrent** has no discovery, no publisher identity, and dies at zero
110
+ seeds. unmuzzle adds the index, per-file sha256, publisher signatures, and
111
+ web seeds on top.
112
+
113
+ ## Roadmap
114
+
115
+ - [x] first real release (unmuzzle/qwen2.5-14b-abliterated)
116
+ - [x] MCP server for agent-native publish/download
117
+ - [ ] standalone registry site generated from the index
118
+ - [ ] DHT-native peer discovery, built-in torrent client (drop aria2c)
119
+ - [ ] index federation: subscribe to multiple publisher indexes
120
+
121
+ ## License
122
+
123
+ MIT
@@ -0,0 +1,15 @@
1
+ unmuzzle/__init__.py,sha256=n2fVjIxguuZSNrcgYo6TTBxZXUDFp8ZJ1OuxK3cCtTQ,97
2
+ unmuzzle/api.py,sha256=shXaHcc8HQQuwkQiWknFPWMPFdF_eD2im86h5rAbw8A,7627
3
+ unmuzzle/cli.py,sha256=ONEDexvbHBworjd7fCZAvB-s18ClyGgE2rspLzrDwrA,7382
4
+ unmuzzle/download.py,sha256=0BVYDLdUcPlCLzJs7BjPyQ9XoLIx-3bzGVr3_KHK_SM,4968
5
+ unmuzzle/hfcache.py,sha256=Z9BLt5l6oFmyeK-hHn-S7B6vewzHhQFJf0XtPSVFztc,1797
6
+ unmuzzle/index.py,sha256=02rsW61lB0MMxjP1A5Aeu8_13eFJIL_Pt4WM6Bmnuhw,4209
7
+ unmuzzle/mcp_server.py,sha256=MGnd1ys5EcDhjxkQB59UR--Hm-CNnUNVAbNKuTE0UqU,2862
8
+ unmuzzle/publish.py,sha256=Iwll56PdIu7DPFHXzI5-8w5udnALeFQF99Muv-kDSVA,2824
9
+ unmuzzle/sign.py,sha256=TpzU0U7jzrMrN_5GGPDDpH6GJUVYJ_PeyTOxYCNAqJ4,2096
10
+ unmuzzle-0.1.0.dist-info/licenses/LICENSE,sha256=CIzTP7_6jQBHtFLMP1Nl_Huw43O2g56fO4Hx6UwxDEc,1068
11
+ unmuzzle-0.1.0.dist-info/METADATA,sha256=urB7w6lLxnNJlDgRILLGwbl25s1-6cWgFcusq2gD1pA,4448
12
+ unmuzzle-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
13
+ unmuzzle-0.1.0.dist-info/entry_points.txt,sha256=JFhw31dwwapKpkXHpkboGKw_eUlngUgTnnhsjrH9Cv4,87
14
+ unmuzzle-0.1.0.dist-info/top_level.txt,sha256=3AK_aVinNJgrjkyrXhQHyoXYC_Kcn92RJNbpFLh6aWQ,9
15
+ unmuzzle-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ unmuzzle = unmuzzle.cli:main
3
+ unmuzzle-mcp = unmuzzle.mcp_server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jiajun Zeng
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 @@
1
+ unmuzzle