airegistry-cli 2.3.1__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.
- ai.py +511 -0
- airegistry_cli-2.3.1.dist-info/METADATA +173 -0
- airegistry_cli-2.3.1.dist-info/RECORD +7 -0
- airegistry_cli-2.3.1.dist-info/WHEEL +5 -0
- airegistry_cli-2.3.1.dist-info/entry_points.txt +2 -0
- airegistry_cli-2.3.1.dist-info/licenses/LICENSE +9 -0
- airegistry_cli-2.3.1.dist-info/top_level.txt +1 -0
ai.py
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""AIRegistry CLI: search, inspect and install versioned AI components.
|
|
3
|
+
|
|
4
|
+
Standard library only. The CLI copies files and never executes them.
|
|
5
|
+
"""
|
|
6
|
+
import argparse
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
|
|
15
|
+
__version__ = "2.3.1"
|
|
16
|
+
|
|
17
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
18
|
+
# Used when the CLI is installed with pip (no registry.json next to ai.py). Component files are
|
|
19
|
+
# resolved relative to this URL, so it must point at a tree that contains registry/<name>/versions/.
|
|
20
|
+
PUBLIC_REGISTRY_URL = "https://raw.githubusercontent.com/MUdevelops/AIRegistry/main/registry.json"
|
|
21
|
+
|
|
22
|
+
NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}$")
|
|
23
|
+
VER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?$")
|
|
24
|
+
REF_RE = re.compile(r"^([a-z0-9][a-z0-9-]{0,39})(?:@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?))?$")
|
|
25
|
+
DEP_RE = re.compile(r"^([a-z0-9][a-z0-9-]{0,39})@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)( \(optional\))?$")
|
|
26
|
+
REQUIRED = {"name": str, "version": str, "description": str, "category": str,
|
|
27
|
+
"runtime": str, "license": str, "tags": list, "files": list, "source": str}
|
|
28
|
+
BAD_EXT = {".exe", ".dll", ".so", ".dylib", ".bat", ".cmd", ".ps1", ".msi", ".com", ".scr", ".jar"}
|
|
29
|
+
MAX_BYTES = 2_000_000
|
|
30
|
+
LOCK = os.path.join(".airegistry", "lock.json")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Err(Exception):
|
|
34
|
+
"""A user-facing error: printed without a traceback."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def warn(message):
|
|
38
|
+
print("warning: " + message, file=sys.stderr)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---- validation -----------------------------------------------------------
|
|
42
|
+
def safe_rel(p):
|
|
43
|
+
"""Return the parts of a safe relative POSIX path, else raise."""
|
|
44
|
+
if (not isinstance(p, str) or not p or "\0" in p or "\\" in p or ":" in p
|
|
45
|
+
or p.startswith("/")):
|
|
46
|
+
raise Err(f"unsafe path: {p!r}")
|
|
47
|
+
parts = p.split("/")
|
|
48
|
+
if any(x in ("", ".", "..") for x in parts):
|
|
49
|
+
raise Err(f"unsafe path: {p!r}")
|
|
50
|
+
return parts
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def validate(m):
|
|
54
|
+
"""Validate one release manifest and return it."""
|
|
55
|
+
if not isinstance(m, dict):
|
|
56
|
+
raise Err("manifest must be a JSON object")
|
|
57
|
+
for k, t in REQUIRED.items():
|
|
58
|
+
if k not in m:
|
|
59
|
+
raise Err(f"missing field '{k}'")
|
|
60
|
+
if not isinstance(m[k], t):
|
|
61
|
+
raise Err(f"field '{k}' must be {t.__name__}")
|
|
62
|
+
if t is str and not m[k].strip():
|
|
63
|
+
raise Err(f"field '{k}' is empty")
|
|
64
|
+
if not NAME_RE.match(m["name"]):
|
|
65
|
+
raise Err(f"invalid name {m['name']!r} (use a-z 0-9 -)")
|
|
66
|
+
if not VER_RE.match(m["version"]):
|
|
67
|
+
raise Err(f"invalid version {m['version']!r} (use MAJOR.MINOR.PATCH)")
|
|
68
|
+
if "release_notes" in m and not isinstance(m["release_notes"], str):
|
|
69
|
+
raise Err("field 'release_notes' must be str")
|
|
70
|
+
deps = m.get("dependencies", [])
|
|
71
|
+
for k, v in (("tags", m["tags"]), ("files", m["files"]), ("dependencies", deps)):
|
|
72
|
+
if not isinstance(v, list) or not all(isinstance(x, str) for x in v):
|
|
73
|
+
raise Err(f"'{k}' must be a list of strings")
|
|
74
|
+
if not m["files"] or len(set(m["files"])) != len(m["files"]):
|
|
75
|
+
raise Err("'files' must be non-empty and unique")
|
|
76
|
+
for f in m["files"]:
|
|
77
|
+
safe_rel(f)
|
|
78
|
+
if os.path.splitext(f)[1].lower() in BAD_EXT:
|
|
79
|
+
raise Err(f"executable file type not allowed: {f}")
|
|
80
|
+
safe_rel(m["source"])
|
|
81
|
+
return m
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def vkey(v):
|
|
85
|
+
"""Sort key for semantic versions; a release without a pre-release tag sorts above one with it."""
|
|
86
|
+
m = VER_RE.match(v) if isinstance(v, str) else None
|
|
87
|
+
return (int(m[1]), int(m[2]), int(m[3]), 0 if m[4] else 1) if m else (0, 0, 0, 0)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_ref(ref):
|
|
91
|
+
"""Split 'name' or 'name@version' into (name, version-or-None)."""
|
|
92
|
+
m = REF_RE.match(ref or "")
|
|
93
|
+
if not m:
|
|
94
|
+
raise Err(f"invalid component reference {ref!r} (use name or name@MAJOR.MINOR.PATCH)")
|
|
95
|
+
return m[1], m[2]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_registry(data):
|
|
99
|
+
"""Parse registry JSON into {name: {'name', 'latest', 'releases': {version: manifest}}}.
|
|
100
|
+
|
|
101
|
+
Accepts the versioned format ({'versions': [...], 'latest_version': ...}) and, for
|
|
102
|
+
compatibility, the older flat format (one manifest per component).
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
reg = json.loads(data)
|
|
106
|
+
except ValueError as e:
|
|
107
|
+
raise Err(f"registry is not valid JSON: {e}")
|
|
108
|
+
if not isinstance(reg, dict) or not isinstance(reg.get("components"), list):
|
|
109
|
+
raise Err("registry needs a 'components' list")
|
|
110
|
+
out = {}
|
|
111
|
+
for entry in reg["components"]:
|
|
112
|
+
if not isinstance(entry, dict):
|
|
113
|
+
raise Err("each registry component must be a JSON object")
|
|
114
|
+
releases = {}
|
|
115
|
+
if "versions" in entry:
|
|
116
|
+
versions = entry["versions"]
|
|
117
|
+
if not isinstance(versions, list) or not versions:
|
|
118
|
+
raise Err(f"{entry.get('name')}: 'versions' must be a non-empty list")
|
|
119
|
+
manifests = versions
|
|
120
|
+
else:
|
|
121
|
+
manifests = [entry]
|
|
122
|
+
for m in manifests:
|
|
123
|
+
validate(m)
|
|
124
|
+
_check_checksums(m)
|
|
125
|
+
if m["version"] in releases:
|
|
126
|
+
raise Err(f"duplicate release {m['name']}@{m['version']}")
|
|
127
|
+
releases[m["version"]] = m
|
|
128
|
+
name = next(iter(releases.values()))["name"]
|
|
129
|
+
if any(m["name"] != name for m in releases.values()) or entry.get("name", name) != name:
|
|
130
|
+
raise Err(f"release names do not match component {name!r}")
|
|
131
|
+
latest = entry.get("latest_version", max(releases, key=vkey))
|
|
132
|
+
if latest not in releases:
|
|
133
|
+
raise Err(f"{name}: latest_version {latest!r} is not a published release")
|
|
134
|
+
if name in out:
|
|
135
|
+
raise Err(f"duplicate component {name}")
|
|
136
|
+
out[name] = {"name": name, "latest": latest, "releases": releases}
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _check_checksums(m):
|
|
141
|
+
sh = m.get("sha256", {})
|
|
142
|
+
if not isinstance(sh, dict) or set(sh) - set(m["files"]):
|
|
143
|
+
raise Err(f"bad sha256 map for {m['name']}@{m['version']}")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def resolve(comps, ref):
|
|
147
|
+
"""Return the release manifest for 'name' (latest) or 'name@version'."""
|
|
148
|
+
name, version = parse_ref(ref)
|
|
149
|
+
comp = comps.get(name)
|
|
150
|
+
if comp is None:
|
|
151
|
+
raise Err(f"unknown component '{name}' (try: ai search {name})")
|
|
152
|
+
version = version or comp["latest"]
|
|
153
|
+
release = comp["releases"].get(version)
|
|
154
|
+
if release is None:
|
|
155
|
+
have = ", ".join(sorted(comp["releases"], key=vkey, reverse=True))
|
|
156
|
+
raise Err(f"{name} has no release {version} (available: {have})")
|
|
157
|
+
return release
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def latest(comp):
|
|
161
|
+
return comp["releases"][comp["latest"]]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---- io / config ----------------------------------------------------------
|
|
165
|
+
def fetch(loc):
|
|
166
|
+
try:
|
|
167
|
+
if re.match(r"^https?://", loc):
|
|
168
|
+
req = urllib.request.Request(loc, headers={"User-Agent": "airegistry/" + __version__})
|
|
169
|
+
with urllib.request.urlopen(req, timeout=15) as r:
|
|
170
|
+
data = r.read(MAX_BYTES + 1)
|
|
171
|
+
else:
|
|
172
|
+
with open(loc, "rb") as f:
|
|
173
|
+
data = f.read(MAX_BYTES + 1)
|
|
174
|
+
except Exception as e:
|
|
175
|
+
raise Err(f"cannot fetch {loc}: {e}")
|
|
176
|
+
if len(data) > MAX_BYTES:
|
|
177
|
+
raise Err(f"{loc} is too large")
|
|
178
|
+
return data
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def join(base, rel):
|
|
182
|
+
if re.match(r"^https?://", base):
|
|
183
|
+
return urllib.parse.urljoin(base, rel)
|
|
184
|
+
return os.path.join(os.path.dirname(base), *rel.split("/"))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def home():
|
|
188
|
+
return os.environ.get("AIREGISTRY_HOME") or os.path.join(os.path.expanduser("~"), ".airegistry")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def read_json(path):
|
|
192
|
+
try:
|
|
193
|
+
with open(path, encoding="utf-8") as f:
|
|
194
|
+
v = json.load(f)
|
|
195
|
+
return v if isinstance(v, dict) else {}
|
|
196
|
+
except (OSError, ValueError):
|
|
197
|
+
return {}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def default_registry():
|
|
201
|
+
"""A registry.json next to ai.py (repository checkout), otherwise the public registry."""
|
|
202
|
+
local = os.path.join(HERE, "registry.json")
|
|
203
|
+
return local if os.path.isfile(local) else PUBLIC_REGISTRY_URL
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def registry_url():
|
|
207
|
+
return (os.environ.get("REGISTRY_URL")
|
|
208
|
+
or read_json(os.path.join(home(), "config.json")).get("registry_url") or default_registry())
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def cache_path(url):
|
|
212
|
+
"""One cache file per registry URL, so switching registries never serves another one's data."""
|
|
213
|
+
digest = hashlib.sha256(url.encode("utf-8")).hexdigest()[:16]
|
|
214
|
+
return os.path.join(home(), "cache", digest + ".json")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def load_registry(offline=False):
|
|
218
|
+
url, cache = registry_url(), cache_path(registry_url())
|
|
219
|
+
data, fresh = None, False
|
|
220
|
+
if not offline:
|
|
221
|
+
try:
|
|
222
|
+
data, fresh = fetch(url), True
|
|
223
|
+
except Err as e:
|
|
224
|
+
if not os.path.exists(cache):
|
|
225
|
+
raise
|
|
226
|
+
warn(f"{e}; using cached registry")
|
|
227
|
+
if data is None:
|
|
228
|
+
try:
|
|
229
|
+
with open(cache, "rb") as f:
|
|
230
|
+
data = f.read()
|
|
231
|
+
except OSError:
|
|
232
|
+
raise Err("no cached registry yet; run once without --offline")
|
|
233
|
+
try:
|
|
234
|
+
comps = parse_registry(data)
|
|
235
|
+
except Err as e:
|
|
236
|
+
if fresh:
|
|
237
|
+
raise Err(f"registry at {url} is invalid: {e}")
|
|
238
|
+
raise Err(f"cached registry is invalid ({e}); run again without --offline")
|
|
239
|
+
if fresh:
|
|
240
|
+
os.makedirs(os.path.dirname(cache), exist_ok=True)
|
|
241
|
+
with open(cache, "wb") as f:
|
|
242
|
+
f.write(data)
|
|
243
|
+
return url, comps
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def read_lock(root):
|
|
247
|
+
p = os.path.join(root, LOCK)
|
|
248
|
+
if not os.path.exists(p):
|
|
249
|
+
return {"components": {}}
|
|
250
|
+
try:
|
|
251
|
+
with open(p, encoding="utf-8") as f:
|
|
252
|
+
lock = json.load(f)
|
|
253
|
+
ok = isinstance(lock.get("components"), dict)
|
|
254
|
+
except (OSError, ValueError, AttributeError):
|
|
255
|
+
ok = False
|
|
256
|
+
if not ok:
|
|
257
|
+
raise Err(f"corrupt lock file {LOCK}")
|
|
258
|
+
return lock
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def write_lock(root, lock):
|
|
262
|
+
p = os.path.join(root, LOCK)
|
|
263
|
+
os.makedirs(os.path.dirname(p), exist_ok=True)
|
|
264
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
265
|
+
json.dump(lock, f, indent=1, sort_keys=True)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def safe_dest(root, rel_dir, f):
|
|
269
|
+
"""Resolve rel_dir/f under root; reject traversal and symlink escapes."""
|
|
270
|
+
parts = safe_rel(rel_dir) + safe_rel(f)
|
|
271
|
+
real = os.path.realpath(root)
|
|
272
|
+
p = os.path.realpath(os.path.join(real, *parts))
|
|
273
|
+
if os.path.commonpath([real, p]) != real:
|
|
274
|
+
raise Err(f"path escapes project: {'/'.join(parts)}")
|
|
275
|
+
return os.path.join(root, *parts)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ---- core ops -------------------------------------------------------------
|
|
279
|
+
def search(comps, q):
|
|
280
|
+
"""Rank components by how well name, tags, category and description match every query term."""
|
|
281
|
+
terms, res = q.lower().split(), []
|
|
282
|
+
for comp in comps.values():
|
|
283
|
+
m = latest(comp)
|
|
284
|
+
hay = " ".join([m["name"], m["description"], m["category"], *m["tags"]]).lower()
|
|
285
|
+
if all(t in hay for t in terms):
|
|
286
|
+
score = sum(3 if t in m["name"] else 2 if t in m["tags"] else 1 for t in terms)
|
|
287
|
+
res.append((-score, m["name"], m))
|
|
288
|
+
return [r[2] for r in sorted(res, key=lambda r: r[:2])]
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def get_file(url, m, f):
|
|
292
|
+
data = fetch(join(url, m["source"] + "/" + f))
|
|
293
|
+
h = m.get("sha256", {}).get(f)
|
|
294
|
+
if h and hashlib.sha256(data).hexdigest() != h:
|
|
295
|
+
raise Err(f"checksum mismatch for {f}")
|
|
296
|
+
return data
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def prune(top):
|
|
300
|
+
for d, _, _ in os.walk(top, topdown=False):
|
|
301
|
+
try:
|
|
302
|
+
os.rmdir(d)
|
|
303
|
+
except OSError:
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def install(m, url, root, rel_dir, lock, dry, force):
|
|
308
|
+
d = f"{rel_dir}/{m['name']}"
|
|
309
|
+
targets = [(f, safe_dest(root, d, f)) for f in m["files"]]
|
|
310
|
+
if not force:
|
|
311
|
+
for _, p in targets:
|
|
312
|
+
if os.path.exists(p):
|
|
313
|
+
raise Err(f"{os.path.relpath(p, root)} exists; use --force to overwrite")
|
|
314
|
+
print(f"{'would add' if dry else 'add'} {m['name']}@{m['version']} -> {d}/")
|
|
315
|
+
for f, _ in targets:
|
|
316
|
+
print(" + " + f)
|
|
317
|
+
required = [x for x in m.get("dependencies", []) if not x.endswith("(optional)")]
|
|
318
|
+
optional = [x[: -len(" (optional)")] for x in m.get("dependencies", []) if x.endswith("(optional)")]
|
|
319
|
+
if required:
|
|
320
|
+
print(" requires (not installed automatically): " + ", ".join(required))
|
|
321
|
+
if optional:
|
|
322
|
+
print(" works with (optional): " + ", ".join(optional))
|
|
323
|
+
if not m.get("sha256"):
|
|
324
|
+
warn("registry provides no checksums; files are unverified")
|
|
325
|
+
blobs = [get_file(url, m, f) for f, _ in targets]
|
|
326
|
+
if dry:
|
|
327
|
+
return
|
|
328
|
+
for (_, p), b in zip(targets, blobs):
|
|
329
|
+
os.makedirs(os.path.dirname(p), exist_ok=True)
|
|
330
|
+
with open(p, "wb") as f:
|
|
331
|
+
f.write(b)
|
|
332
|
+
old = lock["components"].get(m["name"])
|
|
333
|
+
if isinstance(old, dict) and isinstance(old.get("files"), list):
|
|
334
|
+
for f in old["files"]:
|
|
335
|
+
if f not in m["files"]:
|
|
336
|
+
p = safe_dest(root, d, f)
|
|
337
|
+
if os.path.isfile(p):
|
|
338
|
+
os.remove(p)
|
|
339
|
+
print(" - " + f + " (no longer in component)")
|
|
340
|
+
prune(os.path.join(root, *safe_rel(d)))
|
|
341
|
+
lock["components"][m["name"]] = {"version": m["version"], "dir": d, "files": list(m["files"])}
|
|
342
|
+
write_lock(root, lock)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def table(rows):
|
|
346
|
+
"""Print left-aligned columns sized to their content."""
|
|
347
|
+
if not rows:
|
|
348
|
+
return
|
|
349
|
+
widths = [max(len(str(r[i])) for r in rows) for i in range(len(rows[0]) - 1)]
|
|
350
|
+
for r in rows:
|
|
351
|
+
print(" ".join(str(c).ljust(w) for c, w in zip(r, widths)) + " " + str(r[-1]))
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# ---- commands -------------------------------------------------------------
|
|
355
|
+
def cmd_search(a):
|
|
356
|
+
_, comps = load_registry(a.offline)
|
|
357
|
+
res = search(comps, " ".join(a.query))
|
|
358
|
+
table([(m["name"], m["version"], m["category"], m["description"]) for m in res])
|
|
359
|
+
if not res:
|
|
360
|
+
print("no matches")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def cmd_info(a):
|
|
364
|
+
_, comps = load_registry(a.offline)
|
|
365
|
+
m = resolve(comps, a.component)
|
|
366
|
+
comp = comps[m["name"]]
|
|
367
|
+
for k in ("name", "version", "description", "category", "runtime", "license", "source"):
|
|
368
|
+
print(f"{k:<13}{m[k]}")
|
|
369
|
+
print(f"{'tags':<13}{', '.join(m['tags'])}")
|
|
370
|
+
print(f"{'dependencies':<13}{', '.join(m.get('dependencies', [])) or 'none'}")
|
|
371
|
+
print(f"{'files':<13}{', '.join(m['files'])}")
|
|
372
|
+
print(f"{'releases':<13}{', '.join(sorted(comp['releases'], key=vkey, reverse=True))}")
|
|
373
|
+
if m.get("release_notes"):
|
|
374
|
+
print(f"{'notes':<13}{m['release_notes'].splitlines()[0]}")
|
|
375
|
+
print(f"\ninstall: ai add {m['name']}@{m['version']}")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def cmd_add(a):
|
|
379
|
+
url, comps = load_registry(a.offline)
|
|
380
|
+
m = resolve(comps, a.component)
|
|
381
|
+
safe_rel(a.dir)
|
|
382
|
+
root = os.getcwd()
|
|
383
|
+
lock = read_lock(root)
|
|
384
|
+
current = lock["components"].get(m["name"])
|
|
385
|
+
if current is not None and not a.force:
|
|
386
|
+
have = current.get("version", "?") if isinstance(current, dict) else "?"
|
|
387
|
+
if have == m["version"]:
|
|
388
|
+
raise Err(f"{m['name']}@{have} is already installed; use --force to reinstall")
|
|
389
|
+
raise Err(f"{m['name']}@{have} is already installed; use 'ai update' to upgrade or --force to replace it")
|
|
390
|
+
install(m, url, root, a.dir, lock, a.dry_run, a.force)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def cmd_remove(a):
|
|
394
|
+
root = os.getcwd()
|
|
395
|
+
lock = read_lock(root)
|
|
396
|
+
name = parse_ref(a.component)[0]
|
|
397
|
+
e = lock["components"].get(name)
|
|
398
|
+
if e is None:
|
|
399
|
+
raise Err(f"'{name}' is not installed")
|
|
400
|
+
if not (isinstance(e, dict) and isinstance(e.get("dir"), str) and isinstance(e.get("files"), list)):
|
|
401
|
+
raise Err("corrupt lock entry")
|
|
402
|
+
paths = [safe_dest(root, e["dir"], f) for f in e["files"]]
|
|
403
|
+
print(f"{'would remove' if a.dry_run else 'remove'} {name}:")
|
|
404
|
+
for p in paths:
|
|
405
|
+
print(" - " + os.path.relpath(p, root))
|
|
406
|
+
if a.dry_run:
|
|
407
|
+
return
|
|
408
|
+
for p in paths:
|
|
409
|
+
if os.path.isfile(p):
|
|
410
|
+
os.remove(p)
|
|
411
|
+
prune(os.path.join(root, *safe_rel(e["dir"])))
|
|
412
|
+
del lock["components"][name]
|
|
413
|
+
write_lock(root, lock)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def cmd_list(a):
|
|
417
|
+
if a.all:
|
|
418
|
+
_, comps = load_registry(a.offline)
|
|
419
|
+
table([(m["name"], m["version"], m["description"]) for m in (latest(c) for c in comps.values())])
|
|
420
|
+
return
|
|
421
|
+
comps = read_lock(os.getcwd())["components"]
|
|
422
|
+
table([(n, e.get("version", "?"), e.get("dir", "?")) for n, e in sorted(comps.items())])
|
|
423
|
+
if not comps:
|
|
424
|
+
print("nothing installed (try: ai search <query>)")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def cmd_update(a):
|
|
428
|
+
url, comps = load_registry(a.offline)
|
|
429
|
+
root = os.getcwd()
|
|
430
|
+
lock = read_lock(root)
|
|
431
|
+
names = sorted(parse_ref(c)[0] for c in a.components) if a.components else sorted(lock["components"])
|
|
432
|
+
for name in names:
|
|
433
|
+
if name not in lock["components"]:
|
|
434
|
+
raise Err(f"'{name}' is not installed")
|
|
435
|
+
n = 0
|
|
436
|
+
for name in names:
|
|
437
|
+
e = lock["components"][name]
|
|
438
|
+
comp = comps.get(name)
|
|
439
|
+
if not comp:
|
|
440
|
+
warn(f"{name} is no longer in the registry")
|
|
441
|
+
continue
|
|
442
|
+
if not (isinstance(e, dict) and isinstance(e.get("dir"), str) and "/" in e["dir"]):
|
|
443
|
+
raise Err("corrupt lock entry")
|
|
444
|
+
m = latest(comp)
|
|
445
|
+
if vkey(m["version"]) > vkey(e.get("version")):
|
|
446
|
+
print(f"{name}: {e.get('version')} -> {m['version']} (overwrites local edits)")
|
|
447
|
+
install(m, url, root, e["dir"].rsplit("/", 1)[0], lock, a.dry_run, True)
|
|
448
|
+
n += 1
|
|
449
|
+
if not n:
|
|
450
|
+
print("everything is up to date")
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def cmd_config(a):
|
|
454
|
+
p = os.path.join(home(), "config.json")
|
|
455
|
+
if a.key:
|
|
456
|
+
if a.key != "registry_url" or not a.value:
|
|
457
|
+
raise Err("usage: ai config registry_url <path-or-https-url>")
|
|
458
|
+
cfg = read_json(p)
|
|
459
|
+
cfg["registry_url"] = a.value
|
|
460
|
+
os.makedirs(home(), exist_ok=True)
|
|
461
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
462
|
+
json.dump(cfg, f, indent=1)
|
|
463
|
+
print(f"wrote {p}")
|
|
464
|
+
print(f"registry_url {registry_url()}\nhome {home()}\nconfig file {p}")
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def build_parser():
|
|
468
|
+
p = argparse.ArgumentParser("ai", description="AIRegistry: versioned, reusable AI components.")
|
|
469
|
+
p.add_argument("--version", action="version", version="ai " + __version__)
|
|
470
|
+
s = p.add_subparsers(dest="cmd", required=True)
|
|
471
|
+
|
|
472
|
+
def sub(name, fn, help, offline=True, dry=False):
|
|
473
|
+
sp = s.add_parser(name, help=help)
|
|
474
|
+
sp.set_defaults(fn=fn, dry_run=False, offline=False)
|
|
475
|
+
if offline:
|
|
476
|
+
sp.add_argument("--offline", action="store_true", help="use the cached registry only")
|
|
477
|
+
if dry:
|
|
478
|
+
sp.add_argument("--dry-run", action="store_true", help="show changes, write nothing")
|
|
479
|
+
return sp
|
|
480
|
+
|
|
481
|
+
sub("search", cmd_search, "search components").add_argument("query", nargs="*")
|
|
482
|
+
sub("info", cmd_info, "show component details").add_argument("component", help="name or name@version")
|
|
483
|
+
sp = sub("add", cmd_add, "add a component to this project", dry=True)
|
|
484
|
+
sp.add_argument("component", help="name or name@version")
|
|
485
|
+
sp.add_argument("--dir", default="ai_components", help="relative target directory")
|
|
486
|
+
sp.add_argument("--force", action="store_true", help="overwrite existing files")
|
|
487
|
+
sub("remove", cmd_remove, "remove an installed component", offline=False, dry=True).add_argument("component")
|
|
488
|
+
sub("list", cmd_list, "list installed components").add_argument("--all", action="store_true", help="list the registry instead")
|
|
489
|
+
sub("update", cmd_update, "update installed components to the latest release", dry=True).add_argument(
|
|
490
|
+
"components", nargs="*", help="limit to these components")
|
|
491
|
+
sp = sub("config", cmd_config, "show or set config", offline=False)
|
|
492
|
+
sp.add_argument("key", nargs="?")
|
|
493
|
+
sp.add_argument("value", nargs="?")
|
|
494
|
+
return p
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def main(argv=None):
|
|
498
|
+
a = build_parser().parse_args(argv)
|
|
499
|
+
try:
|
|
500
|
+
a.fn(a)
|
|
501
|
+
return 0
|
|
502
|
+
except Err as e:
|
|
503
|
+
print(f"error: {e}", file=sys.stderr)
|
|
504
|
+
return 1
|
|
505
|
+
except KeyboardInterrupt:
|
|
506
|
+
print("interrupted", file=sys.stderr)
|
|
507
|
+
return 130
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
if __name__ == "__main__":
|
|
511
|
+
sys.exit(main())
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: airegistry-cli
|
|
3
|
+
Version: 2.3.1
|
|
4
|
+
Summary: Dependency-free CLI for discovering and installing versioned AI components from AIRegistry
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://airegistry.instantregret96.workers.dev
|
|
7
|
+
Project-URL: Repository, https://github.com/MUdevelops/AIRegistry
|
|
8
|
+
Project-URL: Issues, https://github.com/MUdevelops/AIRegistry/issues
|
|
9
|
+
Keywords: ai,registry,rag,agents,components,cli
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
<div align="center">
|
|
26
|
+
|
|
27
|
+
<img src="https://raw.githubusercontent.com/MUdevelops/AIRegistry/main/readme_assets/Animated-Header.gif" alt="AIRegistry animated header" width="100%"/>
|
|
28
|
+
|
|
29
|
+
<br/>
|
|
30
|
+
|
|
31
|
+
<p>
|
|
32
|
+
<a href="https://github.com/MUdevelops/AIRegistry/actions/workflows/ci.yml"><img src="https://github.com/MUdevelops/AIRegistry/actions/workflows/ci.yml/badge.svg" alt="CI"/></a>
|
|
33
|
+
<a href="https://github.com/MUdevelops/AIRegistry/releases"><img src="https://img.shields.io/badge/Release-v2.3.1-00d9ff?style=flat-square" alt="v2.3.0"/></a>
|
|
34
|
+
<a href="https://github.com/MUdevelops/AIRegistry/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-8b5cf6?style=flat-square" alt="MIT License"/></a>
|
|
35
|
+
<img src="https://img.shields.io/badge/Python-%3E%3D3.9-22c55e?style=flat-square&logo=python&logoColor=white" alt="Python 3.9+"/>
|
|
36
|
+
<img src="https://img.shields.io/badge/components-11-111827?style=flat-square" alt="11 components"/>
|
|
37
|
+
</p>
|
|
38
|
+
|
|
39
|
+
### **Install AI capabilities, not libraries.**
|
|
40
|
+
|
|
41
|
+
<p>
|
|
42
|
+
AIRegistry is an open-source registry and dependency-free CLI for reusable, versioned
|
|
43
|
+
AI building blocks: retrieval, memory, agents, prompts, tools, citations and validation.
|
|
44
|
+
</p>
|
|
45
|
+
|
|
46
|
+
<p>
|
|
47
|
+
<a href="https://airegistry.instantregret96.workers.dev">Live registry</a> ·
|
|
48
|
+
<a href="https://github.com/MUdevelops/AIRegistry/issues">Issues</a> ·
|
|
49
|
+
<a href="https://github.com/MUdevelops/AIRegistry/blob/main/CONTRIBUTING.md">Contributing</a> ·
|
|
50
|
+
<a href="https://github.com/MUdevelops/AIRegistry/blob/main/CHANGELOG.md">Changelog</a>
|
|
51
|
+
</p>
|
|
52
|
+
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
## What is AIRegistry?
|
|
56
|
+
|
|
57
|
+
AI projects keep rebuilding the same small pieces: prompt builders, chunkers, retrievers, memory, validators, tool routers. AIRegistry packages them as **small, readable, versioned source components** that you copy into your own project and own.
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
search -> inspect -> add -> build
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
> **Reuse the capability. Own the source. Pin the version.**
|
|
64
|
+
|
|
65
|
+
| | |
|
|
66
|
+
|---|---|
|
|
67
|
+
| **Versioned, immutable releases** | Every release lives at `registry/<name>/versions/<version>/` and is never edited afterwards. |
|
|
68
|
+
| **Exact references** | `ai add keyword-rag@1.1.0` installs precisely that release. |
|
|
69
|
+
| **Verified installs** | Every file has a SHA-256 checksum that the CLI checks before writing anything. |
|
|
70
|
+
| **Standard library only** | The CLI and every component run on plain Python 3.9+. |
|
|
71
|
+
| **Review-first publishing** | Community submissions become GitHub pull requests, never direct commits. |
|
|
72
|
+
|
|
73
|
+
## Quick start
|
|
74
|
+
|
|
75
|
+
Install the CLI from a checkout:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
git clone https://github.com/MUdevelops/AIRegistry.git
|
|
79
|
+
cd AIRegistry
|
|
80
|
+
python -m pip install .
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Then, in your own project:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
ai search rag # find components
|
|
87
|
+
ai info keyword-rag # inspect: releases, files, dependencies
|
|
88
|
+
ai add keyword-rag --dry-run # preview, writes nothing
|
|
89
|
+
ai add keyword-rag # install the latest release
|
|
90
|
+
ai add context-builder@1.1.0 # or pin an exact release
|
|
91
|
+
ai list # what is installed
|
|
92
|
+
ai update # move installed components to their latest release
|
|
93
|
+
ai remove keyword-rag
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Components are copied to `ai_components/<name>/` (change it with `--dir`) and recorded in `.airegistry/lock.json`. Use one in your code:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
import sys
|
|
100
|
+
sys.path.insert(0, "ai_components/keyword-rag/src")
|
|
101
|
+
|
|
102
|
+
from keyword_rag import retrieve
|
|
103
|
+
|
|
104
|
+
print(retrieve(["Cats purr.", "Python is a language."], "python", top_k=1))
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Without `pip`, run the CLI directly: `python ai.py search rag`. In a checkout, the CLI reads the local `registry.json`; otherwise it uses the public registry. Point it elsewhere with `ai config registry_url <path-or-https-url>`.
|
|
108
|
+
|
|
109
|
+
## Component catalog
|
|
110
|
+
|
|
111
|
+
| Component | Latest | Category | What it does |
|
|
112
|
+
|---|---:|---|---|
|
|
113
|
+
| [`agent`](https://github.com/MUdevelops/AIRegistry/blob/main/registry/agent/versions/1.0.0/README.md) | `1.0.0` | agent | Deterministic tool-calling agent loop with a pluggable planner, step records and bounded execution. |
|
|
114
|
+
| [`citation-builder`](https://github.com/MUdevelops/AIRegistry/blob/main/registry/citation-builder/versions/1.1.0/README.md) | `1.1.0` | tool | Format numbered source citations and bibliographies with duplicate merging and stable numbering. |
|
|
115
|
+
| [`context-builder`](https://github.com/MUdevelops/AIRegistry/blob/main/registry/context-builder/versions/1.1.0/README.md) | `1.1.0` | retrieval | Combine retrieved passages into a numbered, size-limited context block for prompts. |
|
|
116
|
+
| [`conversation-memory`](https://github.com/MUdevelops/AIRegistry/blob/main/registry/conversation-memory/versions/1.1.0/README.md) | `1.1.0` | memory | In-process conversation store with size limit, keyword search, character-budget windows and snapshots. |
|
|
117
|
+
| [`json-validator`](https://github.com/MUdevelops/AIRegistry/blob/main/registry/json-validator/versions/1.1.0/README.md) | `1.1.0` | validation | Validate JSON text and return error position, type, keys and readable schema violations. |
|
|
118
|
+
| [`keyword-rag`](https://github.com/MUdevelops/AIRegistry/blob/main/registry/keyword-rag/versions/1.1.0/README.md) | `1.1.0` | retrieval | Dependency-free keyword retrieval that ranks documents and returns relevant passages. |
|
|
119
|
+
| [`prompt-builder`](https://github.com/MUdevelops/AIRegistry/blob/main/registry/prompt-builder/versions/1.1.0/README.md) | `1.1.0` | prompt | Build reusable prompts from system, instruction, context and input sections with safe placeholder filling. |
|
|
120
|
+
| [`query-rewriter`](https://github.com/MUdevelops/AIRegistry/blob/main/registry/query-rewriter/versions/1.1.0/README.md) | `1.1.0` | retrieval | Deterministic query normalization, stopword removal and keyword variants for retrieval workflows. |
|
|
121
|
+
| [`retrieval-pipeline`](https://github.com/MUdevelops/AIRegistry/blob/main/registry/retrieval-pipeline/versions/1.1.0/README.md) | `1.1.0` | workflow | Compose a retriever and a context builder into one validated workflow that returns bounded AI-ready context. |
|
|
122
|
+
| [`text-chunker`](https://github.com/MUdevelops/AIRegistry/blob/main/registry/text-chunker/versions/1.1.0/README.md) | `1.1.0` | text | Split text into overlapping chunks by characters or words, with optional source offsets. |
|
|
123
|
+
| [`tool-router`](https://github.com/MUdevelops/AIRegistry/blob/main/registry/tool-router/versions/1.1.0/README.md) | `1.1.0` | tool | Register Python functions as named tools and execute calls safely, returning errors as data. |
|
|
124
|
+
|
|
125
|
+
Every component is stdlib-only, typed, documented and covered by tests. Dependencies between components are optional and exact; `ai add` shows them but never installs them automatically. Typical composition:
|
|
126
|
+
|
|
127
|
+
```text
|
|
128
|
+
query-rewriter --> keyword-rag --> context-builder --> prompt-builder
|
|
129
|
+
\______ retrieval-pipeline ______/
|
|
130
|
+
conversation-memory tool-router --> agent citation-builder
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Browse the full catalog at the [live registry](https://airegistry.instantregret96.workers.dev).
|
|
134
|
+
|
|
135
|
+
## Registry model
|
|
136
|
+
|
|
137
|
+
```text
|
|
138
|
+
registry/
|
|
139
|
+
keyword-rag/
|
|
140
|
+
versions/
|
|
141
|
+
1.0.0/ component.json README.md src/keyword_rag.py
|
|
142
|
+
1.1.0/ component.json README.md src/keyword_rag.py
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`python scripts/build_registry.py` validates every release and generates `registry.json` and `website/registry.json`; `--check` fails when they are stale. A release is never changed after it is published: fixes ship as a new version. See [ARCHITECTURE.md](ARCHITECTURE.md).
|
|
146
|
+
|
|
147
|
+
## Community publishing
|
|
148
|
+
|
|
149
|
+
```text
|
|
150
|
+
GitHub sign-in -> Submit Component -> Worker validation -> GitHub App -> branch + commit -> Pull Request -> CI + review -> merge
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The publisher accepts new components and new releases of existing ones. Nothing is written to `main` directly. Read [CONTRIBUTING.md](CONTRIBUTING.md) for the format and rules.
|
|
154
|
+
|
|
155
|
+
## Security
|
|
156
|
+
|
|
157
|
+
Registry content and submissions are untrusted input. The CLI validates paths and checksums, rejects executable file types and never runs what it installs. See [SECURITY.md](SECURITY.md) for details and how to report a vulnerability.
|
|
158
|
+
|
|
159
|
+
## Development
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
python scripts/build_registry.py --check
|
|
163
|
+
python -W error -m unittest discover -s tests -v
|
|
164
|
+
python -m py_compile ai.py scripts/build_registry.py
|
|
165
|
+
node --check website/app.js
|
|
166
|
+
node --check cloudflare/worker.js
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Deployment and operations: [docs/CLOUD_AUTH.md](docs/CLOUD_AUTH.md), [docs/MONITORING.md](docs/MONITORING.md).
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
ai.py,sha256=LogOL5gTMeHhl71RueWIdgRMi0eUVLWeXSpSGkW5RmU,19567
|
|
2
|
+
airegistry_cli-2.3.1.dist-info/licenses/LICENSE,sha256=M4rYDBI42UB3m6yyclVzR9xZeQaH1NfKRg-YftJM400,1080
|
|
3
|
+
airegistry_cli-2.3.1.dist-info/METADATA,sha256=8vfwruIa8IaUzZj-Mcarx9Gbv5Rtv-wfbH7qrV8Cn-E,9426
|
|
4
|
+
airegistry_cli-2.3.1.dist-info/WHEEL,sha256=a-udL2m4V_6Yn8b-uKzWb12RjRxwwMo6ohYFM24tpaY,91
|
|
5
|
+
airegistry_cli-2.3.1.dist-info/entry_points.txt,sha256=g4YiNOKuYBw7vDc-STJ06AriGPZpSowHztDQ0mbMgYQ,31
|
|
6
|
+
airegistry_cli-2.3.1.dist-info/top_level.txt,sha256=TJAp5TUfTUztZSUatbygths7CWRrFfnOMCtZ-DIcw6c,3
|
|
7
|
+
airegistry_cli-2.3.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AIRegistry contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ai
|