eclipse-bazaar 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.
@@ -0,0 +1,15 @@
1
+ # Eclipse Bazaar · © 2026 Cyan Eclipse Technologies LLC · cetech.ai
2
+ """eclipse-bazaar — one distribution, three surfaces (gate A5):
3
+
4
+ * the Eclipse Bazaar MCP server (console script: eclipse-bazaar)
5
+ * the `bazaar` CLI (search / product / categories / check)
6
+ * the Python SDK (from eclipse_bazaar import Bazaar)
7
+
8
+ Catalog data ships as a stamped bundled snapshot; verdict summaries
9
+ ship as a hash-pinned verdict snapshot (gate A1). Powered by Cyan
10
+ Eclipse Technologies (cetech.ai).
11
+ """
12
+ from ._version import __version__
13
+ from .client import Bazaar
14
+
15
+ __all__ = ["__version__", "Bazaar"]
@@ -0,0 +1,2 @@
1
+ # GENERATED by tools/sync_version.py from server.json — do not edit.
2
+ __version__ = "0.1.0"
@@ -0,0 +1,166 @@
1
+ # Eclipse Bazaar · © 2026 Cyan Eclipse Technologies LLC · cetech.ai
2
+ """Data access for the eclipse-bazaar distribution.
3
+
4
+ Resolution order (first hit wins):
5
+ catalog $BAZAAR_CATALOG -> repo-root catalog.json (repo mode only,
6
+ kept canonical by sync_verdicts.py) -> bundled
7
+ data/catalog.json
8
+ corpus see eclipse_bazaar.check (bulk file, sha256-verified)
9
+ scorecard bundled verdict snapshot first (stamped + hash-pinned by
10
+ tools/make_verdict_snapshot.py); repo mode falls back to the
11
+ live Test Bench card when the snapshot lacks the ref.
12
+
13
+ Gate A1: packaged mode never reaches for ../test-bench/results/ — the
14
+ snapshot is the only verdict source an installed wheel needs.
15
+
16
+ Repo mode is a proven state, not a path-existence guess (PY-D1): it
17
+ holds only inside the bazaar-mcp checkout itself (its own markers —
18
+ .git and server.json at the repo root) or when BAZAAR_REPO_MODE=1 is
19
+ set explicitly. An installed wheel is snapshot-only by construction:
20
+ files planted at the computed install-tree levels are never served.
21
+ """
22
+ import json
23
+ import os
24
+ import urllib.request
25
+ from pathlib import Path
26
+
27
+ PKG_DIR = Path(__file__).resolve().parent
28
+ DATA_DIR = PKG_DIR / "data"
29
+ REPO_ROOT = PKG_DIR.parents[1] # dev layout: bazaar-mcp/ (site-packages when installed)
30
+ CETECH_DIR = PKG_DIR.parents[2] # dev layout: cyan-eclipse/ (meaningless when installed)
31
+
32
+ REFRESH_URLS = [
33
+ "https://raw.githubusercontent.com/CYAN-ECLIPSE-TECHNOLOGIES-LLC/bazaar-mcp/main/catalog.json",
34
+ ]
35
+
36
+ _LAST_SOURCE = "bundled snapshot"
37
+ _SNAPSHOT = None
38
+
39
+
40
+ def _repo_mode() -> bool:
41
+ """True only in the bazaar-mcp checkout itself (proven by its own
42
+ markers) or under an explicit BAZAAR_REPO_MODE=1 opt-in. This gates
43
+ both repo-mode fallbacks below; an installed wheel never satisfies
44
+ it by accident of the install layout (PY-D1)."""
45
+ if os.environ.get("BAZAAR_REPO_MODE") == "1":
46
+ return True
47
+ return (REPO_ROOT / ".git").exists() and \
48
+ (REPO_ROOT / "server.json").exists()
49
+
50
+
51
+ def catalog_path() -> Path:
52
+ env = os.environ.get("BAZAAR_CATALOG")
53
+ if env:
54
+ return Path(env)
55
+ repo_cat = REPO_ROOT / "catalog.json"
56
+ if _repo_mode() and repo_cat.exists():
57
+ return repo_cat
58
+ return DATA_DIR / "catalog.json"
59
+
60
+
61
+ def fetch_catalog(url: str, timeout: int = 15) -> dict:
62
+ req = urllib.request.Request(url, headers={"Accept": "application/json"})
63
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
64
+ return json.loads(resp.read().decode("utf-8"))
65
+
66
+
67
+ def catalog_dict(refresh: bool = False) -> dict:
68
+ """The catalog as a dict. refresh=True tries the public HTTPS JSON
69
+ source first (one bulk document — never per-product queries) and
70
+ falls back to the local snapshot on any failure."""
71
+ global _LAST_SOURCE
72
+ if refresh:
73
+ for url in REFRESH_URLS:
74
+ try:
75
+ cat = fetch_catalog(url)
76
+ if isinstance(cat, dict) and "products" in cat:
77
+ _LAST_SOURCE = f"refreshed from {url}"
78
+ return cat
79
+ except Exception:
80
+ continue
81
+ path = catalog_path()
82
+ cat = json.loads(path.read_text())
83
+ _LAST_SOURCE = ("repo catalog.json" if path == REPO_ROOT / "catalog.json"
84
+ else "bundled snapshot")
85
+ return cat
86
+
87
+
88
+ def freshness(cat: dict) -> dict:
89
+ return {"catalog_built": cat.get("built"),
90
+ "sources": cat.get("sources", []),
91
+ "served_from": _LAST_SOURCE,
92
+ "note": "Statuses and verdicts are as of the catalog build date; "
93
+ "scorecard summaries come from the stamped verdict "
94
+ "snapshot bundled with this package (hash-pinned to the "
95
+ "Test Bench cards), or the referenced card in repo mode."}
96
+
97
+
98
+ # ------------------------------------------------------------ scorecards
99
+ def _check_rows(sc: dict):
100
+ """Normalise the three Test Bench card shapes to (name, verdict):
101
+ list-of-dicts (original), dict-of-results (later cards), and
102
+ checks-absent (scoped re-bench cards -> no rows)."""
103
+ checks = sc.get("checks")
104
+ if isinstance(checks, list):
105
+ for ch in checks:
106
+ if isinstance(ch, dict):
107
+ yield ch.get("name"), ch.get("verdict", "other")
108
+ elif isinstance(checks, dict):
109
+ for name, res in checks.items():
110
+ if isinstance(res, dict):
111
+ if "verdict" in res:
112
+ yield name, res["verdict"]
113
+ elif "pass" in res:
114
+ yield name, "pass" if res["pass"] else "fail"
115
+ else:
116
+ yield name, "other"
117
+ else:
118
+ yield name, "other"
119
+
120
+
121
+ def summarize_card(sc: dict) -> dict:
122
+ """Scorecard file -> the summary the server returns. Pure function —
123
+ tools/make_verdict_snapshot.py builds the snapshot with this exact
124
+ code, which is what makes packaged/repo parity testable. (The
125
+ pre-packaging server crashed on dict-shaped and checks-absent cards;
126
+ the summary for list-shaped cards is unchanged.)"""
127
+ counts = {"pass": 0, "fail": 0, "na": 0, "other": 0}
128
+ na_names, fail_names = [], []
129
+ total = 0
130
+ for name, v in _check_rows(sc):
131
+ total += 1
132
+ counts[v if v in counts else "other"] += 1
133
+ if v == "na":
134
+ na_names.append(name)
135
+ if v == "fail":
136
+ fail_names.append(name)
137
+ return {"tool": sc.get("tool") or sc.get("product"),
138
+ "version": sc.get("version"),
139
+ "bench_date": sc.get("bench_date") or sc.get("date"),
140
+ "bench_version": sc.get("bench_version"),
141
+ "environment": sc.get("environment"),
142
+ "verdict": sc.get("verdict"),
143
+ "checks_total": total, "counts": counts,
144
+ "not_tested_checks": na_names,
145
+ "failed_checks": fail_names}
146
+
147
+
148
+ def load_snapshot():
149
+ global _SNAPSHOT
150
+ if _SNAPSHOT is None:
151
+ path = DATA_DIR / "verdict_snapshot.json"
152
+ _SNAPSHOT = json.loads(path.read_text()) if path.exists() else {}
153
+ return _SNAPSHOT
154
+
155
+
156
+ def scorecard_summary(ref: str):
157
+ """Summary for a catalog scorecard_ref, or None if unavailable."""
158
+ snap = load_snapshot()
159
+ rec = (snap.get("cards") or {}).get(ref)
160
+ if rec is not None:
161
+ return rec["summary"]
162
+ if _repo_mode():
163
+ path = CETECH_DIR / ref # the live Test Bench card, checkout only
164
+ if path.exists():
165
+ return summarize_card(json.loads(path.read_text()))
166
+ return None
@@ -0,0 +1,233 @@
1
+ # Eclipse Bazaar · © 2026 Cyan Eclipse Technologies LLC · cetech.ai
2
+ """`bazaar check` — marketplace-standing check for configured MCP servers.
3
+
4
+ Phase 1 (this build). Every configured server is classified into
5
+ exactly one of four classes, from config text + the published Bazaar
6
+ corpus only:
7
+
8
+ CERTIFIED listed, with a Tier-2 Test Bench verdict on record
9
+ LISTED in the catalog, no Tier-2 verdict (listing status shown)
10
+ NOT LISTED no Bazaar record for the resolved identity (a neutral
11
+ fact — not a warning, not a failure)
12
+ UNRESOLVABLE no identity could be extracted from the config entry
13
+
14
+ Later-phase vocabulary for not-yet-public record classes does not exist
15
+ in this binary by construction: score/record fields for unpublished
16
+ data classes are absent — not null, not stubbed, not behind a flag —
17
+ and ship only in a later phase, once the underlying records themselves
18
+ are public (the gate binds the data, not the channel).
19
+
20
+ Fences (breach = gate K1): this module never executes a configured
21
+ server, never reads a tool description, never contacts a configured
22
+ server's endpoint, and never scores or ranks an unlisted server.
23
+ There is deliberately no aggregate score over the report.
24
+
25
+ Architecture (gate S1): the corpus is one bulk file, downloaded whole
26
+ and sha256-verified against its manifest before use, then joined
27
+ locally. Zero per-server queries leave the machine. The bundled corpus
28
+ snapshot is the offline default; a per-name lookup mode does not exist.
29
+ """
30
+ import hashlib
31
+ import json
32
+ import os
33
+ import urllib.request
34
+ from pathlib import Path
35
+
36
+ from .catalog import DATA_DIR
37
+ from .configparse import (default_config_paths, extract_identity,
38
+ load_config_entries, unresolvable_reason)
39
+
40
+ CERTIFIED = "CERTIFIED"
41
+ LISTED = "LISTED"
42
+ NOT_LISTED = "NOT LISTED"
43
+ UNRESOLVABLE = "UNRESOLVABLE"
44
+ CLASSES = (CERTIFIED, LISTED, NOT_LISTED, UNRESOLVABLE)
45
+
46
+ CORPUS_BASE = ("https://raw.githubusercontent.com/"
47
+ "CYAN-ECLIPSE-TECHNOLOGIES-LLC/bazaar-mcp/main/corpus")
48
+ CORPUS_URL = CORPUS_BASE + "/corpus.jsonl"
49
+ MANIFEST_URL = CORPUS_BASE + "/corpus.manifest.json"
50
+
51
+
52
+ class CorpusError(Exception):
53
+ pass
54
+
55
+
56
+ # ---------------------------------------------------------------- corpus
57
+ def _verify(raw: bytes, manifest: dict):
58
+ digest = hashlib.sha256(raw).hexdigest()
59
+ if manifest.get("sha256") != digest:
60
+ raise CorpusError(
61
+ f"corpus sha256 mismatch: manifest {manifest.get('sha256')}, "
62
+ f"downloaded {digest} — refusing unverified corpus")
63
+ return digest
64
+
65
+
66
+ def _parse_corpus(raw: bytes):
67
+ records = []
68
+ for line in raw.decode("utf-8").splitlines():
69
+ line = line.strip()
70
+ if line:
71
+ records.append(json.loads(line))
72
+ return records
73
+
74
+
75
+ def _bundled_corpus():
76
+ raw = (DATA_DIR / "corpus.jsonl").read_bytes()
77
+ manifest = json.loads((DATA_DIR / "corpus.manifest.json").read_text())
78
+ digest = _verify(raw, manifest)
79
+ return _parse_corpus(raw), {"source": "bundled snapshot",
80
+ "sha256": digest, "manifest": manifest}
81
+
82
+
83
+ def _download(url, timeout=20):
84
+ req = urllib.request.Request(url, headers={"Accept": "*/*"})
85
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
86
+ return resp.read()
87
+
88
+
89
+ def _refreshed_corpus():
90
+ manifest = json.loads(_download(MANIFEST_URL).decode("utf-8"))
91
+ raw = _download(CORPUS_URL)
92
+ digest = _verify(raw, manifest)
93
+ cache = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) \
94
+ / "eclipse-bazaar"
95
+ cache.mkdir(parents=True, exist_ok=True)
96
+ (cache / "corpus.jsonl").write_bytes(raw)
97
+ (cache / "corpus.manifest.json").write_text(json.dumps(manifest, indent=1))
98
+ return _parse_corpus(raw), {"source": f"refreshed from {CORPUS_URL}",
99
+ "sha256": digest, "manifest": manifest}
100
+
101
+
102
+ def load_corpus(corpus_path=None, refresh=False):
103
+ """(records, meta). Explicit local file wins; then refresh; the
104
+ bundled snapshot is the always-available fallback."""
105
+ if corpus_path:
106
+ raw = Path(corpus_path).read_bytes()
107
+ manifest_path = Path(str(corpus_path).replace(
108
+ "corpus.jsonl", "corpus.manifest.json"))
109
+ meta = {"source": f"local file {corpus_path}", "sha256": None,
110
+ "manifest": None}
111
+ if manifest_path.exists():
112
+ manifest = json.loads(manifest_path.read_text())
113
+ meta["sha256"] = _verify(raw, manifest)
114
+ meta["manifest"] = manifest
115
+ meta["source"] += " (sha256 verified against sibling manifest)"
116
+ return _parse_corpus(raw), meta
117
+ if refresh:
118
+ try:
119
+ return _refreshed_corpus()
120
+ except Exception as e:
121
+ records, meta = _bundled_corpus()
122
+ meta["refresh_error"] = f"{type(e).__name__}: {e}"
123
+ meta["source"] += " (refresh failed, bundled fallback)"
124
+ return records, meta
125
+ return _bundled_corpus()
126
+
127
+
128
+ def build_index(records):
129
+ index = {}
130
+ for rec in records:
131
+ for ident in rec.get("identities", []):
132
+ index.setdefault(ident.lower(), rec)
133
+ return index
134
+
135
+
136
+ # ------------------------------------------------------------ classify
137
+ def classify(config_name, entry, index):
138
+ identity = extract_identity(entry)
139
+ if identity is None:
140
+ return {"config_name": config_name, "class": UNRESOLVABLE,
141
+ "identity": None,
142
+ "reason": unresolvable_reason(entry),
143
+ "listing": None, "drift": None, "version_note": None}
144
+ rec = index.get(identity["name"])
145
+ if rec is None and "/" in identity["name"]:
146
+ # Qualified reference (registry/owner image path, npm scope,
147
+ # remote authority): standing binds to the FULL qualified
148
+ # identity. A bare-name match under a foreign qualifier is a
149
+ # different source — never an inheritance path (PY-D3). It is
150
+ # reported NOT LISTED with the collision named, so the reader
151
+ # can tell an impersonation shape from an unknown name.
152
+ base = identity["name"].rsplit("/", 1)[-1]
153
+ base_rec = index.get(base)
154
+ if base_rec is not None:
155
+ hint = next((i for i in base_rec.get("identities", [])
156
+ if "/" in i), base_rec["id"])
157
+ return {"config_name": config_name, "class": NOT_LISTED,
158
+ "identity": identity,
159
+ "reason": "no Bazaar listing record for this "
160
+ f"identity; a product named {base!r} is "
161
+ f"listed under a different source ({hint}) "
162
+ "— standing does not transfer across "
163
+ "registries, owners, or scopes",
164
+ "listing": None, "drift": None, "version_note": None}
165
+ if rec is None:
166
+ return {"config_name": config_name, "class": NOT_LISTED,
167
+ "identity": identity,
168
+ "reason": "no Bazaar listing record for this identity",
169
+ "listing": None, "drift": None, "version_note": None}
170
+ cls = CERTIFIED if (rec.get("certified") and rec.get("bench_verdict")) else LISTED
171
+ drift = None
172
+ version_note = None
173
+ pin, listed = identity.get("pin"), rec.get("listing_version")
174
+ if pin and listed and pin != listed:
175
+ drift = {"pinned": pin, "listed": listed,
176
+ "note": f"config pins version {pin}; the listing's current "
177
+ f"version is {listed} — the tested standing below "
178
+ f"belongs to the listed version"}
179
+ elif pin and not listed:
180
+ # The pin cannot be checked: the listing carries no version to
181
+ # check it against. Disclose, never imply verification (PY-D5).
182
+ version_note = ("version not checked: the listing carries no "
183
+ "version, so the pin cannot be checked against it")
184
+ listing = {"id": rec["id"], "name": rec["name"], "status": rec["status"],
185
+ "bench_verdict": rec.get("bench_verdict"),
186
+ "certified_scope": rec.get("certified_scope"),
187
+ "listing_version": listed,
188
+ "scorecard_ref": rec.get("scorecard_ref"),
189
+ "listing_url": rec.get("listing_url")}
190
+ return {"config_name": config_name, "class": cls, "identity": identity,
191
+ "reason": None, "listing": listing, "drift": drift,
192
+ "version_note": version_note}
193
+
194
+
195
+ def run_check(config_paths=None, corpus_path=None, refresh_corpus=False):
196
+ """The full Phase-1 check. Returns the report dict (no score —
197
+ counts per class only)."""
198
+ paths = [Path(p) for p in config_paths] if config_paths \
199
+ else default_config_paths()
200
+ records, corpus_meta = load_corpus(corpus_path, refresh_corpus)
201
+ index = build_index(records)
202
+ entries, file_notes = [], []
203
+ for path in paths:
204
+ loaded, error = load_config_entries(path)
205
+ if error:
206
+ file_notes.append({"config_file": str(path), "error": error})
207
+ continue
208
+ for name, entry in loaded:
209
+ row = classify(name, entry, index)
210
+ row["config_file"] = str(path)
211
+ entries.append(row)
212
+ summary = {c: 0 for c in CLASSES}
213
+ for row in entries:
214
+ summary[row["class"]] += 1
215
+ return {
216
+ "tool": "eclipse-bazaar check (phase 1)",
217
+ "corpus": {"records": len(records),
218
+ "source": corpus_meta["source"],
219
+ "sha256": corpus_meta["sha256"],
220
+ "generated_at": (corpus_meta.get("manifest") or {}).get("generated_at"),
221
+ "refresh_error": corpus_meta.get("refresh_error")},
222
+ "config_files": [str(p) for p in paths],
223
+ "file_notes": file_notes,
224
+ "summary": summary,
225
+ "entries": entries,
226
+ "notes": [
227
+ "Classes are facts about listing/verdict records, not safety "
228
+ "judgements. NOT LISTED is neutral: the Bazaar catalog v0.1 "
229
+ "covers Cyan Eclipse Technologies products.",
230
+ "env values in your configs were never read, copied, or "
231
+ "transmitted; the join ran locally against the bulk corpus.",
232
+ ],
233
+ }
eclipse_bazaar/cli.py ADDED
@@ -0,0 +1,178 @@
1
+ # Eclipse Bazaar · © 2026 Cyan Eclipse Technologies LLC · cetech.ai
2
+ """`bazaar` command line — terminal face of the eclipse-bazaar SDK.
3
+
4
+ bazaar search <query> [--category C] [--refresh] [--json]
5
+ bazaar product <id> [--json]
6
+ bazaar categories [--json]
7
+ bazaar check [--config PATH ...] [--corpus PATH] [--refresh-corpus]
8
+ [--out PATH] [--json]
9
+ bazaar version
10
+
11
+ `check` prints the config files it is about to read *before* reading
12
+ them, and its report carries counts per class — never an aggregate
13
+ score (see eclipse_bazaar.check for the Phase-1 fences).
14
+ """
15
+ import argparse
16
+ import json
17
+ import sys
18
+
19
+ from ._version import __version__
20
+ from .check import run_check
21
+ from .client import Bazaar
22
+ from .configparse import default_config_paths
23
+
24
+
25
+ def _emit(obj, as_json, render):
26
+ if as_json:
27
+ print(json.dumps(obj, indent=1))
28
+ else:
29
+ print(render(obj))
30
+
31
+
32
+ def _render_search(res):
33
+ lines = [f"{res['count']} result(s) for {res['query']!r}:"]
34
+ for h in res["results"]:
35
+ cert = h["certification"]
36
+ badge = cert["bench_verdict"] or "no verdict on record"
37
+ lines.append(f" {h['id']} — {h['name']} [{h['status']}] "
38
+ f"({badge})")
39
+ lines.append(f" {h['tagline']}")
40
+ for r in h["match_reasons"]:
41
+ lines.append(f" · {r}")
42
+ return "\n".join(lines)
43
+
44
+
45
+ def _render_product(res):
46
+ p = res["product"]
47
+ c = p["certification"]
48
+ lines = [f"{p['name']} ({p['id']}) — {p['status']}",
49
+ f" {p['tagline']}",
50
+ f" pricing: {p['pricing_model']}",
51
+ f" categories: {', '.join(p['categories'])}",
52
+ f" certification: {c['bench_verdict'] or 'NOT YET CERTIFIED'}",
53
+ " capabilities:"]
54
+ lines += [f" - {cap}" for cap in p["capabilities"]]
55
+ if c.get("not_tested"):
56
+ lines.append(" not tested:")
57
+ lines += [f" - {n}" for n in c["not_tested"]]
58
+ return "\n".join(lines)
59
+
60
+
61
+ def _render_categories(res):
62
+ lines = [f"{res['product_total']} products in "
63
+ f"{len(res['categories'])} categories:"]
64
+ for c in res["categories"]:
65
+ by = ", ".join(f"{k}={v}" for k, v in sorted(c["by_status"].items()))
66
+ lines.append(f" {c['category']}: {c['total']} ({by})")
67
+ return "\n".join(lines)
68
+
69
+
70
+ def _render_check(rep):
71
+ lines = [f"Corpus: {rep['corpus']['records']} listing records "
72
+ f"({rep['corpus']['source']}, sha256 "
73
+ f"{(rep['corpus']['sha256'] or 'n/a')[:16]}…)"]
74
+ for note in rep["file_notes"]:
75
+ lines.append(f" ! {note['config_file']}: {note['error']}")
76
+ current = None
77
+ for e in rep["entries"]:
78
+ if e["config_file"] != current:
79
+ current = e["config_file"]
80
+ lines.append(f"\n{current}:")
81
+ ident = e["identity"]
82
+ idtxt = "—" if ident is None else \
83
+ f"{ident['kind']}:{ident['name']}" + \
84
+ (f"@{ident['pin']}" if ident.get("pin") else "")
85
+ lines.append(f" {e['config_name']:<28} {e['class']:<13} {idtxt}")
86
+ if e["listing"]:
87
+ li = e["listing"]
88
+ verdict = li["bench_verdict"] or "no Tier-2 verdict"
89
+ lines.append(f" -> {li['name']} [{li['status']}] — {verdict}")
90
+ if e["drift"]:
91
+ lines.append(f" drift: {e['drift']['note']}")
92
+ if e.get("version_note"):
93
+ lines.append(f" {e['version_note']}")
94
+ if e["reason"] and e["class"] in ("UNRESOLVABLE",):
95
+ lines.append(f" reason: {e['reason']}")
96
+ s = rep["summary"]
97
+ lines.append("\nSummary: " + " · ".join(f"{k} {v}" for k, v in s.items()))
98
+ lines += [f"Note: {n}" for n in rep["notes"]]
99
+ return "\n".join(lines)
100
+
101
+
102
+ def main(argv=None) -> int:
103
+ ap = argparse.ArgumentParser(
104
+ prog="bazaar",
105
+ description="Eclipse Bazaar — Cyan Eclipse Technologies' catalog "
106
+ "of tested MCP products: search it, inspect listings, "
107
+ "and check your MCP client configs against it.")
108
+ sub = ap.add_subparsers(dest="cmd", required=True)
109
+
110
+ p = sub.add_parser("search", help="keyword search over the catalog")
111
+ p.add_argument("query")
112
+ p.add_argument("--category", default=None)
113
+ p.add_argument("--refresh", action="store_true",
114
+ help="refresh the catalog (one bulk HTTPS fetch) first")
115
+ p.add_argument("--json", action="store_true")
116
+
117
+ p = sub.add_parser("product", help="full listing for one product id")
118
+ p.add_argument("id")
119
+ p.add_argument("--json", action="store_true")
120
+
121
+ p = sub.add_parser("categories", help="catalog categories + counts")
122
+ p.add_argument("--json", action="store_true")
123
+
124
+ p = sub.add_parser("check", help="classify your configured MCP servers "
125
+ "against the Bazaar corpus (phase 1)")
126
+ p.add_argument("--config", action="append", default=None,
127
+ metavar="PATH", help="config file (repeatable); default: "
128
+ "discover known client locations")
129
+ p.add_argument("--corpus", default=None, metavar="PATH",
130
+ help="local corpus.jsonl (default: bundled snapshot)")
131
+ p.add_argument("--refresh-corpus", action="store_true",
132
+ help="download the current corpus + manifest and verify "
133
+ "its sha256 before use")
134
+ p.add_argument("--out", default=None, metavar="PATH",
135
+ help="also write the JSON report to this path")
136
+ p.add_argument("--json", action="store_true")
137
+
138
+ sub.add_parser("version", help="print the package version")
139
+
140
+ args = ap.parse_args(argv)
141
+
142
+ if args.cmd == "version":
143
+ print(__version__)
144
+ return 0
145
+ if args.cmd == "search":
146
+ bz = Bazaar(refresh=args.refresh)
147
+ _emit(bz.search(args.query, args.category), args.json, _render_search)
148
+ return 0
149
+ if args.cmd == "product":
150
+ _emit(Bazaar().get_listing(args.id), args.json, _render_product)
151
+ return 0
152
+ if args.cmd == "categories":
153
+ _emit(Bazaar().categories(), args.json, _render_categories)
154
+ return 0
155
+ if args.cmd == "check":
156
+ paths = args.config
157
+ if not paths:
158
+ found = default_config_paths()
159
+ print("Config files to read (discovered client locations):",
160
+ file=sys.stderr)
161
+ for fp in found:
162
+ print(f" {fp}", file=sys.stderr)
163
+ if not found:
164
+ print(" (none found — pass --config PATH)", file=sys.stderr)
165
+ return 2
166
+ paths = [str(fp) for fp in found]
167
+ rep = run_check(config_paths=paths, corpus_path=args.corpus,
168
+ refresh_corpus=args.refresh_corpus)
169
+ if args.out:
170
+ from pathlib import Path
171
+ Path(args.out).write_text(json.dumps(rep, indent=1) + "\n")
172
+ _emit(rep, args.json, _render_check)
173
+ return 0
174
+ return 2
175
+
176
+
177
+ if __name__ == "__main__":
178
+ raise SystemExit(main())
@@ -0,0 +1,53 @@
1
+ # Eclipse Bazaar · © 2026 Cyan Eclipse Technologies LLC · cetech.ai
2
+ """Python SDK for Eclipse Bazaar (candidate C1 — plumbing, no novelty
3
+ claim). A small read-only client over the same catalog and corpus the
4
+ MCP server and CLI serve: identical ranking, identical match reasons,
5
+ identical status labels, because it calls the same functions.
6
+
7
+ from eclipse_bazaar import Bazaar
8
+ bz = Bazaar() # bundled snapshot (offline)
9
+ bz = Bazaar(refresh=True) # one bulk HTTPS refresh, fallback local
10
+ bz.search("certificate renewal") # -> dict like the MCP tool's result
11
+ bz.get_listing("certbridge-mcp") # full catalog entry
12
+ bz.check() # Phase-1 standing check of your configs
13
+
14
+ No API key (year-one posture: free reads). No telemetry — the client
15
+ sends nothing anywhere except the optional bulk catalog refresh.
16
+ """
17
+ from . import mcp_server as _srv
18
+ from .catalog import catalog_dict
19
+ from .check import run_check
20
+
21
+
22
+ class Bazaar:
23
+ def __init__(self, refresh: bool = False):
24
+ self._cat = catalog_dict(refresh=refresh)
25
+
26
+ # ------------------------------------------------------ discovery
27
+ def search(self, query: str, category: str = None) -> dict:
28
+ return _srv.t_search_products(
29
+ {"query": query, "category": category or ""}, self._cat)
30
+
31
+ def find_solutions(self, problem: str) -> dict:
32
+ return _srv.t_find_solutions({"problem": problem}, self._cat)
33
+
34
+ def get_listing(self, product_id: str) -> dict:
35
+ return _srv.t_get_product({"id": product_id}, self._cat)
36
+
37
+ get_product = get_listing
38
+
39
+ def compare(self, ids) -> dict:
40
+ return _srv.t_compare_products({"ids": list(ids)}, self._cat)
41
+
42
+ def get_certification(self, product_id: str) -> dict:
43
+ return _srv.t_get_certification({"id": product_id}, self._cat)
44
+
45
+ def categories(self) -> dict:
46
+ return _srv.t_list_categories({}, self._cat)
47
+
48
+ # ------------------------------------------------------ standing check
49
+ def check(self, config_paths=None, corpus_path=None,
50
+ refresh_corpus: bool = False) -> dict:
51
+ return run_check(config_paths=config_paths,
52
+ corpus_path=corpus_path,
53
+ refresh_corpus=refresh_corpus)