ea-plugin-cli 0.20261001.3__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,30 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # Copyright © 2025-2026 Wenze Wei. All Rights Reserved.
4
+ #
5
+ # This file is part of Encre.
6
+ # The Encre project belongs to the Dunimd Team.
7
+ #
8
+ # Licensed under the Apache License, Version 2.0 (the "License");
9
+ # you may not use this file except in compliance with the License.
10
+ # You may obtain a copy of the License at
11
+ #
12
+ # http://www.apache.org/licenses/LICENSE-2.0
13
+ #
14
+ # Unless required by applicable law or agreed to in writing, software
15
+ # distributed under the License is distributed on an "AS IS" BASIS,
16
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17
+ # See the License for the specific language governing permissions and
18
+ # limitations under the License.
19
+ #
20
+ # DISCLAIMER: Users must comply with applicable AI regulations.
21
+ # Non-compliance may result in service termination or legal liability.
22
+
23
+ """ea-plugin-cli — the Encre Agent plugin market unified toolchain.
24
+
25
+ Commands (see ``__main__``): init · build · verify · publish · catalog.
26
+ Distribution-name policy, manifest checks and catalog-entry construction
27
+ live in :mod:`ea_plugin_cli.rules` so the registry CI can import them too.
28
+ """
29
+
30
+ __version__ = "0.1.0"
@@ -0,0 +1,618 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # Copyright © 2025-2026 Wenze Wei. All Rights Reserved.
4
+ #
5
+ # This file is part of Encre.
6
+ # The Encre project belongs to the Dunimd Team.
7
+ #
8
+ # Licensed under the Apache License, Version 2.0 (the "License");
9
+ # you may not use this file except in compliance with the License.
10
+ # You may obtain a copy of the License at
11
+ #
12
+ # http://www.apache.org/licenses/LICENSE-2.0
13
+ #
14
+ # Unless required by applicable law or agreed to in writing, software
15
+ # distributed under the License is distributed on an "AS IS" BASIS,
16
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17
+ # See the License for the specific language governing permissions and
18
+ # limitations under the License.
19
+ #
20
+ # DISCLAIMER: Users must comply with applicable AI regulations.
21
+ # Non-compliance may result in service termination or legal liability.
22
+
23
+ """encre-plugin — the Encre Agent plugin market unified CLI.
24
+
25
+ encre-plugin init <name> scaffold an ea-plugin-* package
26
+ encre-plugin build [pkg_dir] policy-check + wheel/sdist + digests
27
+ encre-plugin verify [pkg_dir] isolated install + entry-point smoke
28
+ encre-plugin publish [pkg_dir] twine upload + registry PR
29
+ encre-plugin catalog regen|check rebuild / validate catalog.json
30
+
31
+ Build info (``dist/ea-build-info.json``) is the handoff between stages:
32
+ it carries the catalog entry with per-artifact sha256 digests so the
33
+ registry CI and the market installer never have to re-hash by hand.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import argparse
39
+ import json
40
+ import os
41
+ import shutil
42
+ import subprocess
43
+ import sys
44
+ import tempfile
45
+ from datetime import UTC
46
+ from pathlib import Path
47
+ from typing import Any
48
+
49
+ from .rules import (
50
+ PACKAGE_PREFIX,
51
+ RESERVED_PREFIXES,
52
+ VENDOR_TIERS,
53
+ build_catalog_entry,
54
+ find_ea_entrypoint,
55
+ merge_catalog,
56
+ normalize_module,
57
+ read_pyproject,
58
+ sha256_file,
59
+ validate_for_publish,
60
+ )
61
+
62
+ BUILD_INFO = "ea-build-info.json"
63
+
64
+ _PROBE = """
65
+ import importlib, json, sys
66
+ module_name, _, factory = sys.argv[1].partition(":")
67
+ plugin = getattr(importlib.import_module(module_name), factory)()
68
+ print(json.dumps(plugin.manifest.to_dict()))
69
+ """
70
+
71
+
72
+ def _fail(msg: str, code: int = 1) -> None:
73
+ print(f"error: {msg}", file=sys.stderr)
74
+ raise SystemExit(code)
75
+
76
+
77
+ def _host_python() -> str:
78
+ """Interpreter the CLI drives `python -m build / venv / twine` with.
79
+
80
+ A PyInstaller-frozen exe is not a Python interpreter, so the compiled
81
+ ``encre-plugin`` resolves a real system Python (>= 3.11) from PATH
82
+ instead. An unfrozen run keeps ``sys.executable``, so dev environments
83
+ (editable installs, project venvs) behave exactly as before.
84
+ """
85
+ if not getattr(sys, "frozen", False):
86
+ return sys.executable
87
+ probe = "import sys; sys.exit(0 if sys.version_info >= (3, 11) else 1)"
88
+ for name in ("python", "python3", "py"):
89
+ cand = shutil.which(name)
90
+ if cand and subprocess.run([cand, "-c", probe], capture_output=True).returncode == 0:
91
+ return cand
92
+ _fail("no Python 3.11+ interpreter found on PATH — the downloaded encre-plugin shells out to a real Python for build/verify/publish")
93
+ return "" # unreachable; _fail raises
94
+
95
+
96
+ def _ensure_pytools(py: str, *mods: str) -> None:
97
+ """Make the host interpreter able to import the PyPA helper modules.
98
+
99
+ The download-and-run promise: nobody pip-installs a toolchain before
100
+ using the exe, so a missing helper (build / twine) is installed into
101
+ the host interpreter on first use — silent when already present.
102
+ """
103
+ missing = [m for m in mods if subprocess.run([py, "-c", f"import {m}"], capture_output=True).returncode != 0]
104
+ if not missing:
105
+ return
106
+ print(f"one-time setup: installing {', '.join(missing)} for the publishing toolchain ...")
107
+ r = _run([py, "-m", "pip", "install", "--quiet", "--user", *missing])
108
+ if r.returncode != 0:
109
+ # --user is refused inside venvs; fall back to a plain install.
110
+ r = _run([py, "-m", "pip", "install", "--quiet", *missing])
111
+ if r.returncode != 0:
112
+ _fail(
113
+ f"could not install {', '.join(missing)} into {py} — install manually:\n {py} -m pip install {' '.join(missing)}\n{r.stderr}"
114
+ )
115
+
116
+
117
+ def _run(cmd: list[str], cwd: Path | None = None, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
118
+ return subprocess.run(cmd, cwd=str(cwd) if cwd else None, text=True, capture_output=True, check=False, env=env)
119
+
120
+
121
+ def _load_manifest(pkg_dir: Path) -> dict[str, Any]:
122
+ """Run the package's factory (declared in ea.plugins) and read its manifest.
123
+
124
+ Executed in a subprocess against the host interpreter: the frozen CLI exe
125
+ has no ``encre`` importable in itself, and the author's consent to run
126
+ their own plugin code is expressed by invoking the CLI. The market never
127
+ imports anything this way — it reads the catalog entry baked at build
128
+ time instead.
129
+ """
130
+ py = read_pyproject(pkg_dir)
131
+ ep = find_ea_entrypoint(py)
132
+ if ep is None:
133
+ _fail('no [project.entry-points."ea.plugins"] entry found')
134
+ env = {**os.environ, "PYTHONPATH": os.pathsep.join(filter(None, [str(pkg_dir), os.environ.get("PYTHONPATH")]))}
135
+ r = subprocess.run([_host_python(), "-c", _PROBE, ep[1]], cwd=str(pkg_dir), env=env, text=True, capture_output=True)
136
+ if r.returncode != 0:
137
+ _fail(f"loading plugin factory {ep[1]!r} failed:\n{r.stderr}")
138
+ try:
139
+ return json.loads(r.stdout)
140
+ except json.JSONDecodeError as exc:
141
+ _fail(f"factory {ep[1]!r} printed non-JSON: {r.stdout[:200]!r} ({exc})")
142
+ return {} # unreachable
143
+
144
+
145
+ # ── init ──────────────────────────────────────────────────────────────
146
+
147
+ _PYPROJECT_TMPL = """\
148
+ [build-system]
149
+ requires = ["setuptools>=68", "wheel"]
150
+ build-backend = "setuptools.build_meta"
151
+
152
+ [project]
153
+ name = "{dist}"
154
+ version = "0.1.0"
155
+ description = "{title}"
156
+ readme = "README.md"
157
+ requires-python = ">=3.11"
158
+ authors = [{{ name = "Author", email = "author@example.com" }}]
159
+ license = {{ text = "Apache-2.0" }}
160
+ dependencies = ["encre-harness>=0.5.0"]
161
+
162
+ [project.entry-points."ea.plugins"]
163
+ {ep_name} = "{module}.plugin:create_plugin"
164
+
165
+ [tool.setuptools.packages.find]
166
+ where = ["."]
167
+ include = ["{module}*"]
168
+
169
+ [tool.setuptools.package-data]
170
+ {module} = ["ui/**/*", "skills/**/*"]
171
+ """
172
+
173
+ _PLUGIN_PY = '''"""{title} — Encre Agent plugin."""
174
+
175
+ from __future__ import annotations
176
+
177
+ from encre.plugins.types import EncrePlugin, PluginManifest
178
+
179
+
180
+ class {cls}Plugin(EncrePlugin):
181
+ manifest = PluginManifest(
182
+ name="{name}",
183
+ version="0.1.0",
184
+ description="{title}",
185
+ icon="puzzle",
186
+ author="Author",
187
+ license="Apache-2.0",
188
+ tags=["example"],
189
+ tier="user",
190
+ min_ea_version="0.5.0",
191
+ permissions=["tools:register"],
192
+ provides_tools=[],
193
+ )
194
+
195
+ def get_tools(self):
196
+ return []
197
+
198
+
199
+ def create_plugin() -> {cls}Plugin:
200
+ """Entry point factory (ea.plugins)."""
201
+ return {cls}Plugin()
202
+ '''
203
+
204
+ _README_TMPL = """\
205
+ # {dist}
206
+
207
+ Encre Agent plugin `{name}`, built with `encre-plugin`.
208
+
209
+ - `encre-plugin build` — policy check + wheel/sdist + sha256 digests
210
+ - `encre-plugin verify` — isolated install smoke test
211
+ - `encre-plugin publish` — PyPI upload + registry PR
212
+
213
+ Frontend assets (if any) must be PREBUILT into `{module}/ui/` before
214
+ publishing; the installer never runs a build step.
215
+ """
216
+
217
+
218
+ def cmd_init(args: argparse.Namespace) -> None:
219
+ name = args.name
220
+ dist = name if name.startswith(PACKAGE_PREFIX) else f"{PACKAGE_PREFIX}{name}"
221
+ module = normalize_module(dist)
222
+ root = (Path(args.dir) if args.dir else Path.cwd()) / dist
223
+ if root.exists():
224
+ _fail(f"{root} already exists")
225
+ title = name.replace("-", " ").strip().capitalize()
226
+ cls = "".join(part.capitalize() for part in module.replace("_", " ").split() if part.isalnum()) or "Plugin"
227
+ (root / module).mkdir(parents=True)
228
+ (root / module / "ui").mkdir()
229
+ (root / module / "ui" / ".gitkeep").write_text("", encoding="utf-8")
230
+ (root / "pyproject.toml").write_text(_PYPROJECT_TMPL.format(dist=dist, title=title, ep_name=name, module=module), encoding="utf-8")
231
+ (root / module / "__init__.py").write_text(f'"""{dist}."""\n', encoding="utf-8")
232
+ (root / module / "plugin.py").write_text(_PLUGIN_PY.format(title=title, cls=cls, name=name), encoding="utf-8")
233
+ (root / "README.md").write_text(_README_TMPL.format(dist=dist, name=name, module=module), encoding="utf-8")
234
+ print(f"created {dist}/ (module {module}) — edit {module}/plugin.py, then run: encre-plugin build {dist}")
235
+
236
+
237
+ # ── build ─────────────────────────────────────────────────────────────
238
+
239
+
240
+ def _build_one(pkg_dir: Path, *, allowed_tiers: tuple[str, ...] | None = None, no_isolation: bool = False) -> dict[str, Any]:
241
+ """Validate + build one package and return its catalog entry.
242
+
243
+ Writes ``dist/ea-build-info.json`` alongside the wheel so the publish
244
+ stage never re-hashes. ``allowed_tiers`` widens the gate for the vendor
245
+ pipeline; ``no_isolation`` skips the per-package venv/setuptools download
246
+ (the batch build reuses the interpreter's already-present build backend).
247
+ Raises SystemExit on policy or build failure.
248
+ """
249
+ py = read_pyproject(pkg_dir)
250
+ manifest = _load_manifest(pkg_dir)
251
+ group = (py.get("project", {}).get("entry-points") or {}).get("ea.plugins") or {}
252
+ problems = validate_for_publish(py, manifest, extra_entry_points=max(0, len(group) - 1), allowed_tiers=allowed_tiers)
253
+ if problems:
254
+ print(f"publish policy violations ({pkg_dir.name}):")
255
+ for p in problems:
256
+ print(f" - {p}")
257
+ raise SystemExit(1)
258
+ dist = str(py["project"]["name"])
259
+ host = _host_python()
260
+ if no_isolation:
261
+ # A no-isolation build runs the backend in the host interpreter, so
262
+ # wheel/setuptools must be importable there (the batch build relies
263
+ # on this to skip a per-package venv + network fetch 249 times over).
264
+ _ensure_pytools(host, "build", "wheel", "setuptools")
265
+ else:
266
+ _ensure_pytools(host, "build")
267
+ dist_dir = pkg_dir / "dist"
268
+ # Clean stale distributions from earlier builds: leftover old-version
269
+ # wheels would otherwise leak into the artifacts dict and make twine
270
+ # re-upload files PyPI already has ("file already exists" abort).
271
+ if dist_dir.is_dir():
272
+ for stale in dist_dir.iterdir():
273
+ if stale.suffix in {".whl", ".gz"} or stale.name == BUILD_INFO:
274
+ stale.unlink()
275
+ cmd = [host, "-m", "build", "--outdir", "dist"] + (["--no-isolation"] if no_isolation else [])
276
+ # PYTHONSAFEPATH: `python -m build` otherwise prepends the package dir to
277
+ # sys.path, where a repo-level build.py would shadow pypa-build.
278
+ r = _run(cmd, cwd=pkg_dir, env={**os.environ, "PYTHONSAFEPATH": "1"})
279
+ if r.returncode != 0:
280
+ print(f"python -m build failed:\n{r.stdout}\n{r.stderr}", file=sys.stderr)
281
+ raise SystemExit(1)
282
+ artifacts = {
283
+ f.name: {"sha256": sha256_file(f), "size": f.stat().st_size}
284
+ for f in sorted((pkg_dir / "dist").iterdir())
285
+ if f.suffix in {".whl", ".gz"} and f.name != BUILD_INFO
286
+ }
287
+ if not artifacts:
288
+ print("build produced no wheel/sdist", file=sys.stderr)
289
+ raise SystemExit(1)
290
+ entry = build_catalog_entry(manifest, pypi_package=dist, artifacts=artifacts, published_at=_now())
291
+ info = pkg_dir / "dist" / BUILD_INFO
292
+ info.write_text(json.dumps(entry, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
293
+ return entry
294
+
295
+
296
+ def cmd_build(args: argparse.Namespace) -> None:
297
+ entry = _build_one(Path(args.pkg_dir).resolve())
298
+ print(f"built {len(entry['artifacts'])} artifact(s); catalog entry -> dist/{BUILD_INFO}")
299
+
300
+
301
+ # ── vendor (batch build the official packages) ────────────────────────
302
+
303
+
304
+ def cmd_vendor(args: argparse.Namespace) -> None:
305
+ """Batch-build every official EA package into a registry-ready staging dir.
306
+
307
+ Walks ``--src`` for ``ea-tool-*`` / ``ea-skill-*`` / ``ea-plugin-*``
308
+ directories, builds each wheel with the vendor tier gate (bundled
309
+ ``system-default`` allowed), and drops ``catalog.d/<name>.json`` + the
310
+ wheels under ``--out``. This is a GENERATOR: it never uploads to PyPI
311
+ or opens a PR — run ``encre-plugin publish`` (or the registry PR flow)
312
+ per package after reviewing the staging output.
313
+ """
314
+ src = Path(args.src).resolve()
315
+ out = Path(args.out).resolve()
316
+ catalog_dir = out / "catalog.d"
317
+ wheels_dir = out / "wheels"
318
+ catalog_dir.mkdir(parents=True, exist_ok=True)
319
+ wheels_dir.mkdir(parents=True, exist_ok=True)
320
+ if not src.is_dir():
321
+ _fail(f"no such package source: {src}")
322
+
323
+ dirs = [
324
+ d
325
+ for d in sorted(src.iterdir())
326
+ if d.is_dir()
327
+ and (d / "pyproject.toml").is_file()
328
+ and d.name.startswith(RESERVED_PREFIXES)
329
+ and (not args.filter or any(f in d.name for f in args.filter))
330
+ ]
331
+ if not dirs:
332
+ _fail(f"no {RESERVED_PREFIXES} packages under {src}")
333
+
334
+ built, failed = 0, []
335
+ for pkg in dirs:
336
+ try:
337
+ entry = _build_one(pkg, allowed_tiers=VENDOR_TIERS, no_isolation=not args.isolation)
338
+ except SystemExit:
339
+ failed.append(pkg.name)
340
+ if not args.keep_going:
341
+ _fail(f"{pkg.name} failed (use --keep-going to continue past failures)")
342
+ continue
343
+ name = str(entry["name"])
344
+ (catalog_dir / f"{name}.json").write_text(json.dumps(entry, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
345
+ for fname in entry["artifacts"]:
346
+ src_file = pkg / "dist" / fname
347
+ if src_file.is_file():
348
+ shutil.copy2(src_file, wheels_dir / fname)
349
+ built += 1
350
+
351
+ print(f"vendor build: {built} built, {len(failed)} failed -> {out}")
352
+ if failed:
353
+ print("failed packages: " + ", ".join(failed), file=sys.stderr)
354
+ print(
355
+ "next: review catalog.d/, then `encre-plugin publish` each wheel "
356
+ "(twine + registry PR); or point the registry CI at this staging dir."
357
+ )
358
+
359
+
360
+ # ── verify ────────────────────────────────────────────────────────────
361
+
362
+ _PROBE = """
363
+ import importlib, json, sys
364
+ module_name, _, factory = sys.argv[1].partition(":")
365
+ plugin = getattr(importlib.import_module(module_name), factory)()
366
+ print(json.dumps(plugin.manifest.to_dict()))
367
+ """
368
+
369
+
370
+ def cmd_verify(args: argparse.Namespace) -> None:
371
+ pkg_dir = Path(args.pkg_dir).resolve()
372
+ py = read_pyproject(pkg_dir)
373
+ ep = find_ea_entrypoint(py)
374
+ if ep is None:
375
+ _fail("no ea.plugins entry point to verify")
376
+ wheels = sorted((pkg_dir / "dist").glob("*.whl"))
377
+ if not wheels:
378
+ _fail("no wheel in dist/ — run `encre-plugin build` first")
379
+ tmp = Path(tempfile.mkdtemp(prefix="ea-verify-"))
380
+ try:
381
+ py_bin = "Scripts/python.exe" if os.name == "nt" else "bin/python"
382
+ host = _host_python()
383
+ # System site-packages stay visible: the probe venv must prove the
384
+ # WHEEL is well-formed, not re-resolve the Encre Agent SDK (which is
385
+ # not on PyPI yet during the 0.5.x transition — a bare venv could
386
+ # only fail for reasons unrelated to the package under test).
387
+ r = _run([host, "-m", "venv", "--system-site-packages", str(tmp)])
388
+ if r.returncode != 0:
389
+ _fail(f"venv creation failed: {r.stderr}")
390
+ venv_py = tmp / py_bin
391
+ r = _run([str(venv_py), "-m", "pip", "install", "--quiet", str(wheels[-1])] + (["--no-deps"] if args.no_deps else []))
392
+ if r.returncode != 0:
393
+ _fail(f"wheel install failed:\n{r.stderr}")
394
+ r = _run([str(venv_py), "-c", _PROBE, ep[1]])
395
+ if r.returncode != 0:
396
+ hint = ""
397
+ if "No module named 'encre'" in r.stderr or "ModuleNotFoundError" in r.stderr:
398
+ hint = (
399
+ "\n hint: the probe venv cannot see the Encre Agent SDK — until "
400
+ "`encre-harness` is on PyPI, run verify with\n PYTHONPATH "
401
+ "pointing at your local harness/ and core/ source trees."
402
+ )
403
+ _fail(f"entry point probe failed:\n{r.stderr}{hint}")
404
+ manifest = json.loads(r.stdout)
405
+ if not manifest.get("name") or not manifest.get("version"):
406
+ _fail("probe manifest is missing name/version")
407
+ print(f"verify ok: {manifest.get('name')} v{manifest.get('version')} imports and declares a valid manifest")
408
+ finally:
409
+ shutil.rmtree(tmp, ignore_errors=True)
410
+
411
+
412
+ # ── publish ───────────────────────────────────────────────────────────
413
+
414
+ # Canonical registry (central repository) for the Encre Agent plugin market.
415
+ REGISTRY_URL = "https://github.com/mf2023/ea-cwh.git"
416
+
417
+
418
+ def _registry_cache_dir() -> Path:
419
+ """Persistent home for the CLI-managed registry clone."""
420
+ base = os.environ.get("LOCALAPPDATA") or os.environ.get("XDG_CACHE_HOME")
421
+ root = Path(base) if base else Path.home() / ".cache"
422
+ return root / "encre-plugin-cli" / "registry"
423
+
424
+
425
+ def _resolve_registry(args: argparse.Namespace) -> Path:
426
+ """Local checkout to open the PR from: --registry-dir, else a cached clone.
427
+
428
+ A missing cache clone is fetched shallowly from ``--registry-url``; an
429
+ existing one is re-synced onto the remote's default branch so the entry
430
+ commit is always cut from current main.
431
+ """
432
+ import re
433
+
434
+ url = args.registry_url
435
+ if not re.fullmatch(r"(https?://|file://|ssh://|git@)[^\s]+", url or ""):
436
+ _fail(f"--registry-url does not look like a git remote: {url!r}")
437
+ if args.registry_dir:
438
+ registry = Path(args.registry_dir).resolve()
439
+ if not (registry / ".git").exists():
440
+ _fail(f"{registry} is not a git checkout")
441
+ else:
442
+ registry = _registry_cache_dir()
443
+ if not (registry / ".git").exists():
444
+ registry.parent.mkdir(parents=True, exist_ok=True)
445
+ r = _run(["git", "clone", "--quiet", url, str(registry)])
446
+ if r.returncode != 0:
447
+ _fail(f"cloning the registry failed:\n{r.stderr}")
448
+ for cmd in (
449
+ ["git", "fetch", "--quiet", "origin"],
450
+ ["git", "checkout", "--quiet", "-f", "main"],
451
+ ["git", "reset", "--quiet", "--hard", "origin/main"],
452
+ ):
453
+ r = _run(cmd, cwd=registry)
454
+ if r.returncode != 0:
455
+ _fail(f"`{' '.join(cmd)}` in {registry} failed:\n{r.stderr}")
456
+ return registry
457
+
458
+
459
+ def cmd_publish(args: argparse.Namespace) -> None:
460
+ pkg_dir = Path(args.pkg_dir).resolve()
461
+ info_path = pkg_dir / "dist" / BUILD_INFO
462
+ if not info_path.exists():
463
+ _fail(f"dist/{BUILD_INFO} missing — run `encre-plugin build` first")
464
+ entry = json.loads(info_path.read_text(encoding="utf-8"))
465
+ dist_files = [str(f) for f in sorted((pkg_dir / "dist").iterdir()) if f.suffix in {".whl", ".gz"}]
466
+ if args.dry_run:
467
+ print(f"[dry-run] would upload: {dist_files}")
468
+ print(json.dumps(entry, indent=2, ensure_ascii=False))
469
+ return
470
+ if args.no_upload:
471
+ print("skipping PyPI upload (--no-upload); registry PR half only")
472
+ else:
473
+ host = _host_python()
474
+ _ensure_pytools(host, "twine")
475
+ r = _run([host, "-m", "twine", "upload", *dist_files])
476
+ if r.returncode != 0:
477
+ _fail(f"twine upload failed:\n{r.stdout}\n{r.stderr}")
478
+ print(f"uploaded {entry['pypi_package']} {entry['version']} to PyPI")
479
+ registry = _resolve_registry(args)
480
+ _registry_pr(registry, entry)
481
+
482
+
483
+ def _registry_pr(registry: Path, entry: dict[str, Any]) -> None:
484
+ """Drop the catalog entry into the checkout and open the registry PR.
485
+
486
+ The branch is cut from origin/main BEFORE the entry is written, so the
487
+ PR can only ever carry the single catalogue file — never leftover
488
+ worktree state.
489
+ """
490
+ name, version = str(entry["name"]), str(entry["version"])
491
+ branch = f"publish/{name}-{version}"
492
+ r = _run(["git", "checkout", "-B", branch, "origin/main"], cwd=registry)
493
+ if r.returncode != 0:
494
+ _fail(f"cannot branch the registry checkout:\n{r.stderr}")
495
+ cdir = registry / "catalog.d"
496
+ cdir.mkdir(exist_ok=True)
497
+ target = cdir / f"{name}.json"
498
+ target.write_text(json.dumps(entry, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
499
+ rel = str(target.relative_to(registry))
500
+ for cmd in (
501
+ ["git", "add", rel],
502
+ ["git", "commit", "-m", f"catalog: {name} {version}"],
503
+ ["git", "push", "-u", "origin", branch],
504
+ ):
505
+ r = _run(cmd, cwd=registry)
506
+ if r.returncode != 0:
507
+ print(f"warning: `{' '.join(cmd)}` failed:\n{r.stderr}", file=sys.stderr)
508
+ print("entry written locally; open the PR manually", file=sys.stderr)
509
+ return
510
+ body = (
511
+ f"Published `{entry['pypi_package']}=={version}` to PyPI.\n\n"
512
+ f"CI verifies the merged catalog schema and every artifact digest "
513
+ f"against PyPI before this may merge."
514
+ )
515
+ r = _run(["gh", "pr", "create", "--fill", "--title", f"catalog: {name} {version}", "--body", body, "--head", branch], cwd=registry)
516
+ if r.returncode != 0:
517
+ print(f"warning: gh pr create failed:\n{r.stderr}", file=sys.stderr)
518
+ print(f"branch `{branch}` pushed — open the PR manually", file=sys.stderr)
519
+ else:
520
+ print(r.stdout.strip())
521
+
522
+
523
+ # ── catalog ───────────────────────────────────────────────────────────
524
+
525
+
526
+ def cmd_catalog_regen(args: argparse.Namespace) -> None:
527
+ from ea_cwh import validate_catalog
528
+
529
+ registry = Path(args.registry)
530
+ files = sorted((registry / "catalog.d").glob("*.json"))
531
+ if not files:
532
+ _fail(f"no entry files under {registry / 'catalog.d'}")
533
+ entries = [json.loads(f.read_text(encoding="utf-8")) for f in files]
534
+ doc = merge_catalog(entries, registry_url=args.registry_url)
535
+ problems = validate_catalog(doc)
536
+ if problems:
537
+ print("catalog violations (refusing to write):", file=sys.stderr)
538
+ for p in problems:
539
+ print(f" - {p}", file=sys.stderr)
540
+ raise SystemExit(1)
541
+ out = Path(args.out)
542
+ out.parent.mkdir(parents=True, exist_ok=True)
543
+ out.write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
544
+ print(f"wrote {len(doc['plugins'])} entries -> {out}")
545
+
546
+
547
+ def cmd_catalog_check(args: argparse.Namespace) -> None:
548
+ from ea_cwh import validate_catalog
549
+
550
+ doc = json.loads(Path(args.file).read_text(encoding="utf-8"))
551
+ problems = validate_catalog(doc)
552
+ for p in problems:
553
+ print(f" - {p}")
554
+ if problems:
555
+ raise SystemExit(1)
556
+ print("catalog ok")
557
+
558
+
559
+ # ── plumbing ──────────────────────────────────────────────────────────
560
+
561
+
562
+ def _now() -> str:
563
+ from datetime import datetime
564
+
565
+ return datetime.now(UTC).isoformat(timespec="seconds")
566
+
567
+
568
+ def main(argv: list[str] | None = None) -> None:
569
+ parser = argparse.ArgumentParser(prog="encre-plugin", description="Encre Agent plugin market unified CLI")
570
+ sub = parser.add_subparsers(dest="command", required=True)
571
+
572
+ p = sub.add_parser("init", help="scaffold an ea-plugin-* package")
573
+ p.add_argument("name")
574
+ p.add_argument("--dir", help="parent directory (default: cwd)")
575
+ p.set_defaults(fn=cmd_init)
576
+
577
+ p = sub.add_parser("build", help="policy check + wheel/sdist + sha256 digests")
578
+ p.add_argument("pkg_dir", nargs="?", default=".")
579
+ p.set_defaults(fn=cmd_build)
580
+
581
+ p = sub.add_parser("vendor", help="batch-build the official EA packages into a registry-ready staging dir")
582
+ p.add_argument("--src", default="core/ea_tools", help="official package source tree (default: core/ea_tools)")
583
+ p.add_argument("--out", default="dist/vendor", help="staging output dir (default: dist/vendor)")
584
+ p.add_argument("--filter", action="append", help="only packages whose name contains this substring (repeatable)")
585
+ p.add_argument("--keep-going", action="store_true", help="continue past per-package failures")
586
+ p.add_argument("--isolation", action="store_true", help="build each wheel in a fresh isolated env (slower, needs network)")
587
+ p.set_defaults(fn=cmd_vendor)
588
+
589
+ p = sub.add_parser("verify", help="isolated install + entry-point smoke test")
590
+ p.add_argument("pkg_dir", nargs="?", default=".")
591
+ p.add_argument("--no-deps", action="store_true", help="skip dependency resolution")
592
+ p.set_defaults(fn=cmd_verify)
593
+
594
+ p = sub.add_parser("publish", help="upload to PyPI + registry PR")
595
+ p.add_argument("pkg_dir", nargs="?", default=".")
596
+ p.add_argument("--registry-dir", help="existing registry checkout (default: cached clone of --registry-url)")
597
+ p.add_argument("--registry-url", default=REGISTRY_URL, help=f"registry git remote (default: {REGISTRY_URL})")
598
+ p.add_argument("--no-upload", action="store_true", help="skip twine; open the registry PR only")
599
+ p.add_argument("--dry-run", action="store_true", help="print instead of uploading")
600
+ p.set_defaults(fn=cmd_publish)
601
+
602
+ p = sub.add_parser("catalog", help="central index maintenance")
603
+ csub = p.add_subparsers(dest="catalog_command", required=True)
604
+ c = csub.add_parser("regen", help="merge catalog.d/*.json into catalog.json")
605
+ c.add_argument("--registry", required=True)
606
+ c.add_argument("--out", required=True)
607
+ c.add_argument("--registry-url", default="https://pypi.org")
608
+ c.set_defaults(fn=cmd_catalog_regen)
609
+ c = csub.add_parser("check", help="validate a catalog.json file")
610
+ c.add_argument("file")
611
+ c.set_defaults(fn=cmd_catalog_check)
612
+
613
+ args = parser.parse_args(argv)
614
+ args.fn(args)
615
+
616
+
617
+ if __name__ == "__main__":
618
+ main()
ea_plugin_cli/rules.py ADDED
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # Copyright © 2025-2026 Wenze Wei. All Rights Reserved.
4
+ #
5
+ # This file is part of Encre.
6
+ # The Encre project belongs to the Dunimd Team.
7
+ #
8
+ # Licensed under the Apache License, Version 2.0 (the "License");
9
+ # you may not use this file except in compliance with the License.
10
+ # You may obtain a copy of the License at
11
+ #
12
+ # http://www.apache.org/licenses/LICENSE-2.0
13
+ #
14
+ # Unless required by applicable law or agreed to in writing, software
15
+ # distributed under the License is distributed on an "AS IS" BASIS,
16
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17
+ # See the License for the specific language governing permissions and
18
+ # limitations under the License.
19
+ #
20
+ # DISCLAIMER: Users must comply with applicable AI regulations.
21
+ # Non-compliance may result in service termination or legal liability.
22
+
23
+ """Shared publish-time rules for the plugin central repository.
24
+
25
+ Pure functions over already-parsed data (pyproject dict, manifest dict,
26
+ artifact digests) so both the CLI and the registry-repo CI can import
27
+ this module without encre-harness or any plugin code being installed.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import hashlib
33
+ import re
34
+ import tomllib
35
+ from datetime import UTC
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ #: Community prefix: what ``encre-plugin init`` scaffolds and what authors
40
+ #: are expected to use.
41
+ PACKAGE_PREFIX = "ea-plugin-"
42
+
43
+ #: Reserved PyPI namespaces accepted by the central repository. ea-tool-/
44
+ #: ea-skill- are the vendor prefixes the Encre build pipeline publishes the
45
+ #: official packages under — one registry, three namespaces, same gates.
46
+ RESERVED_PREFIXES = ("ea-plugin-", "ea-tool-", "ea-skill-")
47
+
48
+ #: Only third-party tiers may enter the central repository by default; the
49
+ #: vendor pipeline widens this to the bundled system-default tier.
50
+ CENTRAL_TIERS = {"user"}
51
+ VENDOR_TIERS = ("user", "system-default")
52
+
53
+ _ENTRY_TARGET_RE = re.compile(r"^[A-Za-z_][\w.]*:[A-Za-z_]\w*$")
54
+
55
+
56
+ def normalize_module(name: str) -> str:
57
+ """Turn a plugin name into a PEP 508-ish importable module slug."""
58
+ return re.sub(r"[^0-9a-zA-Z]+", "_", name).strip("_").lower()
59
+
60
+
61
+ def sha256_file(path: Path, chunk: int = 1 << 20) -> str:
62
+ """Hex sha256 digest of a distribution file (streamed)."""
63
+ h = hashlib.sha256()
64
+ with path.open("rb") as fh:
65
+ while True:
66
+ block = fh.read(chunk)
67
+ if not block:
68
+ break
69
+ h.update(block)
70
+ return h.hexdigest()
71
+
72
+
73
+ def read_pyproject(pkg_dir: Path) -> dict[str, Any]:
74
+ """Parse ``pyproject.toml`` from a plugin source directory.
75
+
76
+ Raises:
77
+ FileNotFoundError: the file is missing.
78
+ tomllib.TOMLDecodeError: the file is malformed.
79
+ """
80
+ with (pkg_dir / "pyproject.toml").open("rb") as fh:
81
+ return tomllib.load(fh)
82
+
83
+
84
+ def find_ea_entrypoint(py: dict[str, Any]) -> tuple[str, str] | None:
85
+ """Return the plugin's ``(ep_name, "module:factory")`` EA entry point.
86
+
87
+ Looks at ``[project.entry-points."ea.plugins"]`` — the same group the
88
+ registry's entry-point discovery consumes. Returns ``None`` when the
89
+ package declares no EA entry point.
90
+ """
91
+ group = (py.get("project", {}).get("entry-points") or {}).get("ea.plugins") or {}
92
+ if not group:
93
+ return None
94
+ # A package publishing several plugins is not supported by the central
95
+ # repository (one catalog entry per distribution); take the first and
96
+ # let validation flag multi-entry packages explicitly.
97
+ ((name, target),) = group.items()
98
+ return str(name), str(target)
99
+
100
+
101
+ def validate_for_publish(
102
+ py: dict[str, Any],
103
+ manifest: dict[str, Any],
104
+ *,
105
+ extra_entry_points: int = 0,
106
+ allowed_tiers: tuple[str, ...] | None = None,
107
+ ) -> list[str]:
108
+ """Check a plugin package against the central-repository policy.
109
+
110
+ Args:
111
+ py: The parsed ``pyproject.toml``.
112
+ manifest: ``PluginManifest.to_dict()`` output from the package.
113
+ extra_entry_points: Count of additional ``ea.plugins`` entries
114
+ beyond the first (multi-plugin wheels).
115
+ allowed_tiers: Tiers permitted into the index; ``None`` means the
116
+ community default (``user`` only). The vendor pipeline passes
117
+ :data:`VENDOR_TIERS` so bundled ``system-default`` packages can
118
+ be published to the same registry.
119
+
120
+ Returns:
121
+ Violation messages; empty means the package may be published.
122
+ """
123
+ problems: list[str] = []
124
+ tiers = set(allowed_tiers) if allowed_tiers else set(CENTRAL_TIERS)
125
+ project = py.get("project", {})
126
+ dist = str(project.get("name", ""))
127
+ if not dist.startswith(RESERVED_PREFIXES):
128
+ problems.append(f"PyPI distribution name {dist!r} must start with one of {RESERVED_PREFIXES}")
129
+ ep = find_ea_entrypoint(py)
130
+ if ep is None:
131
+ problems.append('missing [project.entry-points."ea.plugins"] declaration')
132
+ elif extra_entry_points:
133
+ problems.append("multi-plugin wheels are not supported (one catalog entry per distribution)")
134
+ elif not _ENTRY_TARGET_RE.match(ep[1]):
135
+ problems.append(f"entry point target {ep[1]!r} must be 'module:factory'")
136
+
137
+ name = str(manifest.get("name", "")).strip()
138
+ version = str(manifest.get("version", "")).strip()
139
+ if not name:
140
+ problems.append("manifest.name is required")
141
+ if ep is not None and name:
142
+ # Market identity contract: after install the registry keys the plugin
143
+ # by its entry-point name, and the catalog addresses it by
144
+ # manifest.name — those must agree or a market install registers under
145
+ # one name and confirms/updates/uninstalls under another. Community
146
+ # scaffold makes them identical (``demo``/``demo``); the official
147
+ # vendor packages key the entry point by the bare suffix
148
+ # (``agentmail``) while manifest.name carries the reserved namespace
149
+ # (``ea-skill-agentmail``), so the ep key may equal the name minus its
150
+ # leading reserved prefix.
151
+ bare = next((name.removeprefix(p) for p in RESERVED_PREFIXES if name.startswith(p)), name)
152
+ if ep[0] != name and ep[0] != bare:
153
+ problems.append(f"entry-point key {ep[0]!r} must equal manifest.name {name!r} (or its namespace-stripped form {bare!r})")
154
+ if not version:
155
+ problems.append("manifest.version is required")
156
+ elif version != str(project.get("version", "")):
157
+ problems.append(f"manifest.version {version!r} != pyproject version {project.get('version')!r}")
158
+ if not str(manifest.get("description", "")).strip():
159
+ problems.append("manifest.description is required for market display")
160
+ if not str(manifest.get("author", "")).strip():
161
+ problems.append("manifest.author is required for market display")
162
+ if not str(manifest.get("license", "")).strip():
163
+ problems.append("manifest.license is required")
164
+ tier = str(manifest.get("tier", ""))
165
+ if tier not in tiers:
166
+ problems.append(f"manifest.tier {tier!r} not allowed in the central repository (use one of {sorted(tiers)})")
167
+ for key in ("permissions", "tags", "dependencies"):
168
+ value = manifest.get(key, [])
169
+ if not isinstance(value, list):
170
+ problems.append(f"manifest.{key} must be a list")
171
+ if not str(manifest.get("min_ea_version", "")).strip() and not manifest.get("engines"):
172
+ problems.append("manifest must declare min_ea_version or engines['encre']")
173
+
174
+ # Frontend rule: anything the plugin wants rendered in the desktop must
175
+ # ship PREBUILT inside the wheel — no build steps at install time.
176
+ wants_ui = bool(manifest.get("provides_ui") or (manifest.get("contributes") or {}).get("ui"))
177
+ if wants_ui:
178
+ pkg_data = py.get("tool", {}).get("setuptools", {}).get("package-data") or {}
179
+ patterns = [p for plist in pkg_data.values() for p in (plist if isinstance(plist, list) else [plist])]
180
+ if not any("ui" in str(p) for p in patterns):
181
+ problems.append("plugin declares UI but package-data ships no ui/ assets (frontend must be prebuilt into the wheel)")
182
+ return problems
183
+
184
+
185
+ def build_catalog_entry(
186
+ manifest: dict[str, Any],
187
+ *,
188
+ pypi_package: str,
189
+ artifacts: dict[str, dict[str, Any]],
190
+ published_at: str,
191
+ ) -> dict[str, Any]:
192
+ """Assemble the ea-cwh catalog entry for one published plugin.
193
+
194
+ Args:
195
+ manifest: ``PluginManifest.to_dict()`` output.
196
+ pypi_package: The reserved-prefix PyPI distribution name.
197
+ artifacts: ``{filename: {"sha256": ..., "size": ...}}``.
198
+ published_at: ISO-8601 UTC timestamp of the PyPI upload.
199
+
200
+ Returns:
201
+ A catalog entry document (validated downstream by ea-cwh's
202
+ ``validate_catalog`` before it reaches the market).
203
+ """
204
+ provides = {
205
+ "tools": list(manifest.get("provides_tools") or []),
206
+ "skills": list(manifest.get("provides_skills") or []),
207
+ "backends": list(manifest.get("provides_backends") or []),
208
+ "context": list(manifest.get("provides_context") or []),
209
+ "ui": list(manifest.get("provides_ui") or []),
210
+ "gateways": list(manifest.get("provides_gateways") or []),
211
+ "memory": list(manifest.get("provides_memory") or []),
212
+ }
213
+ return {
214
+ "name": manifest.get("name", ""),
215
+ "pypi_package": pypi_package,
216
+ "version": manifest.get("version", ""),
217
+ "description": manifest.get("description", ""),
218
+ "author": manifest.get("author", ""),
219
+ "icon": manifest.get("icon", ""),
220
+ "license": manifest.get("license", ""),
221
+ "homepage": manifest.get("homepage", ""),
222
+ "repository": manifest.get("repository", ""),
223
+ "docs": manifest.get("docs", ""),
224
+ "email": manifest.get("email", ""),
225
+ "tags": list(manifest.get("tags") or []),
226
+ "permissions": list(manifest.get("permissions") or []),
227
+ "provides": {k: v for k, v in provides.items() if v},
228
+ "tier": manifest.get("tier", "user"),
229
+ "min_encre_version": manifest.get("min_ea_version", "") or (manifest.get("engines") or {}).get("encre", ""),
230
+ "artifacts": artifacts,
231
+ "published_at": published_at,
232
+ "yanked": False,
233
+ }
234
+
235
+
236
+ def merge_catalog(entries: list[dict[str, Any]], *, registry_url: str = "https://pypi.org") -> dict[str, Any]:
237
+ """Merge per-plugin entries into a full catalog document.
238
+
239
+ Sorting by plugin name keeps regenerated documents diff-stable, so CI
240
+ diffs show real changes only.
241
+ """
242
+ from ea_cwh import SCHEMA_VERSION # schema lives in the index package
243
+
244
+ return {
245
+ "schema_version": SCHEMA_VERSION,
246
+ "generated_at": _utc_now(),
247
+ "registry": registry_url,
248
+ "plugins": sorted(entries, key=lambda e: str(e.get("name", ""))),
249
+ }
250
+
251
+
252
+ def _utc_now() -> str:
253
+ from datetime import datetime
254
+
255
+ return datetime.now(UTC).isoformat(timespec="seconds")
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: ea-plugin-cli
3
+ Version: 0.20261001.3
4
+ Summary: Encre Agent plugin market unified CLI — scaffold, build, verify and publish ea-plugin-* packages
5
+ Author-email: Dunimd Team <dunimd@outlook.com>
6
+ License: Apache-2.0
7
+ Keywords: encre,plugin,market,cli,publish
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: build>=1.0
15
+ Requires-Dist: twine>=5.0
16
+ Requires-Dist: ea-cwh>=0.1.0
17
+
18
+ <div align="center">
19
+
20
+ # ea-plugin-cli
21
+
22
+ **Encre Agent** Plugin Market Unified CLI
23
+
24
+ [PyPI](https://pypi.org/project/ea-plugin-cli/) | [Changelog](https://github.com/mf2023/ea-cwh/releases)
25
+
26
+ <a href="https://github.com/mf2023/ea-plugin-cli" target="_blank">
27
+ <img alt="GitHub" src="https://img.shields.io/badge/GitHub-ea--plugin--cli-181717?style=flat-square&logo=github"/>
28
+ </a>
29
+ <a href="https://pypi.org/project/ea-plugin-cli/" target="_blank">
30
+ <img alt="PyPI" src="https://img.shields.io/badge/PyPI-ea--plugin--cli-3775A9?style=flat-square&logo=pypi"/>
31
+ </a>
32
+ <a href="https://www.python.org/" target="_blank">
33
+ <img alt="Python" src="https://img.shields.io/badge/Python-3.11%2B-3776AB?style=flat-square&logo=python&logoColor=white"/>
34
+ </a>
35
+ <a href="https://www.apache.org/licenses/LICENSE-2.0" target="_blank">
36
+ <img alt="License" src="https://img.shields.io/badge/License-Apache%202.0-blue.svg?style=flat-square"/>
37
+ </a>
38
+
39
+ The single toolchain that takes a plugin from scaffold to the central registry. After publishing, the registry CI merges catalog entries into the `ea-cwh` index package, which the market backend renders locally.
40
+
41
+ </div>
42
+
43
+ <h2 align="center">🤖 What is ea-plugin-cli?</h2>
44
+
45
+ `encre-plugin` covers the full plugin lifecycle for the **Encre Agent** plugin market:
46
+
47
+ - **Scaffold** — generate a ready-to-publish `ea-plugin-<name>` package skeleton
48
+ - **Build** — enforce central-registry policy, compile a wheel, digest every artifact
49
+ - **Verify** — smoke-test the wheel in an isolated venv (install, import, manifest probe)
50
+ - **Publish** — upload to PyPI, then open the central-registry catalog PR automatically
51
+
52
+ ### Installation
53
+
54
+ ```
55
+ Ready to run: python build.py cli compiles a single-file executable for the
56
+ current platform (build/cli/encre-plugin[.exe] — no pip install required; it
57
+ shells out to a host Python found on PATH for build/venv/twine and
58
+ auto-installs any missing components on first use)
59
+
60
+ Or as a module: pip install ea-plugin-cli # development: pip install -e cli/
61
+ ```
62
+
63
+ <h2 align="center">⭐ Commands</h2>
64
+
65
+ | Command | Purpose |
66
+ |:------|:------|
67
+ | `encre-plugin init <name>` | Scaffold `ea-plugin-<name>` (pyproject + `ea.plugins` entry point + `create_plugin` factory + `ui/` directory) |
68
+ | `encre-plugin build [pkg_dir]` | Run central-registry policy checks → `python -m build` (wheel + sdist) → sha256 per artifact → write `dist/ea-build-info.json` (draft catalog entry) |
69
+ | `encre-plugin vendor` | Official batch build: walk `core/ea_tools/*` (accepts the `ea-tool-`/`ea-skill-` namespaces and the `system-default` tier), build + digest each package with the same policy, stage `catalog.d/<name>.json` and wheels into `dist/vendor/` (staging only — no upload, no PR). Invoked by `python build.py vendor` |
70
+ | `encre-plugin verify [pkg_dir]` | Install the wheel into a throwaway venv, import the entry-point factory and probe the manifest |
71
+ | `encre-plugin publish [pkg_dir]` | `twine upload` to PyPI, then open a central-registry PR: the entry lands in the registry checkout's `catalog.d/`, git branch is cut from origin/main, committed, pushed, `gh pr create` |
72
+ | `encre-plugin catalog regen` | Merge the registry's `catalog.d/*.json` → validate → write the `ea-cwh` `catalog.json` |
73
+ | `encre-plugin catalog check <file>` | Validate a single `catalog.json` |
74
+
75
+ <h2 align="center">📦 publish ↔ Central Registry</h2>
76
+
77
+ - `--registry-dir <dir>` — use an existing registry checkout; when omitted, the CLI clones `https://github.com/mf2023/ea-cwh.git` into a local cache (`%LOCALAPPDATA%` / `~/.cache` → `encre-plugin-cli/registry`) and hard-syncs it to origin/main every run
78
+ - `--registry-url <git url>` — override the default remote (point at a fork or a local bare repository to rehearse the whole flow)
79
+ - `--no-upload` — skip the twine upload, run the registry-PR half only
80
+ - `--dry-run` — print the files that would be uploaded and the catalog entry; no remote action
81
+ - The PR branch carries exactly one file (`catalog.d/<name>.json`): the branch is cut from origin/main **before** the entry is written, so no dirty worktree state can leak into the PR
82
+ - Transition note: `verify` needs `encre-harness` to be importable inside the probe venv; for local development either `pip install -e harness/` or use `--no-deps` plus a `PYTHONPATH` pointing at the source trees
83
+
84
+ <h2 align="center">🛡️ Central Registry Policy (enforced at build time)</h2>
85
+
86
+ | # | Rule |
87
+ |:------|:------|
88
+ | 1 | The PyPI distribution name must start with a reserved prefix — `ea-plugin-` (community) or `ea-tool-` / `ea-skill-` (vendor namespaces) |
89
+ | 2 | Must declare `[project.entry-points."ea.plugins"]`; **one wheel carries exactly one plugin** |
90
+ | 3 | Manifest `name/version/description/author/license` must be non-empty, `manifest.version` must match the pyproject version, and `tier` must be `user` (vendor builds may also ship `system-default`) |
91
+ | 4 | Must declare `min_ea_version` or `engines["encre"]` |
92
+ | 5 | Packages that declare UI (`provides_ui` / `contributes.ui`) must ship it **pre-compiled** under `ui/` and include it as package data — the install side never executes builds |
93
+
94
+ <h2 align="center">🔧 Environment Variables</h2>
95
+
96
+ - PyPI credentials: `TWINE_USERNAME` / `TWINE_PASSWORD` (CI uses trusted publishing and needs neither)
97
+ - The automatic PR requires the registry checkout to have `git remote add origin` and a logged-in `gh` CLI
98
+
99
+ <h2 align="center">🌏 Community & License</h2>
100
+
101
+ - Issues and PRs are welcome!
102
+ - Main repository: https://github.com/mf2023/encre-agent
103
+ - Central registry: https://github.com/mf2023/ea-cwh
104
+ - CLI repository: https://github.com/mf2023/ea-plugin-cli
105
+
106
+ <div align="center">
107
+
108
+ ## 📄 License
109
+
110
+ <p align="center">
111
+ <a href="https://www.apache.org/licenses/LICENSE-2.0">
112
+ <img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg" alt="Apache License 2.0">
113
+ </a>
114
+ </p>
115
+
116
+ This project uses the **Apache License 2.0**.
117
+
118
+ </div>
@@ -0,0 +1,8 @@
1
+ ea_plugin_cli/__init__.py,sha256=iUjfCbXvPUy4nhFxKEzCEpJAoX_jDpEvsKnlBs-gtyY,1167
2
+ ea_plugin_cli/__main__.py,sha256=SkVFtZCdPP7J4J6JuFvjWYV8Zr4SCxzoCSmmgjN2Fqg,26328
3
+ ea_plugin_cli/rules.py,sha256=NRGLDox7NmKX2dh2SfuwsRXJGjhPKqvxqdn6ZdipLW8,10871
4
+ ea_plugin_cli-0.20261001.3.dist-info/METADATA,sha256=AuYuDZDo_QfwfXMO2n7lH0VXQEbTaI68jDmA4FQa87o,6582
5
+ ea_plugin_cli-0.20261001.3.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
+ ea_plugin_cli-0.20261001.3.dist-info/entry_points.txt,sha256=ct1tkX5vIM1EEl3mxo0tZ6YTlG2lBJcSRmD2mrtobOk,61
7
+ ea_plugin_cli-0.20261001.3.dist-info/top_level.txt,sha256=llwJxWiCzBPMaxoOp5m7p0ATl-lR6vfiN3NevjGmMsE,14
8
+ ea_plugin_cli-0.20261001.3.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ encre-plugin = ea_plugin_cli.__main__:main
@@ -0,0 +1 @@
1
+ ea_plugin_cli