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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ai = ai:main
@@ -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