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.
- ea_plugin_cli/__init__.py +30 -0
- ea_plugin_cli/__main__.py +618 -0
- ea_plugin_cli/rules.py +255 -0
- ea_plugin_cli-0.20261001.3.dist-info/METADATA +118 -0
- ea_plugin_cli-0.20261001.3.dist-info/RECORD +8 -0
- ea_plugin_cli-0.20261001.3.dist-info/WHEEL +5 -0
- ea_plugin_cli-0.20261001.3.dist-info/entry_points.txt +2 -0
- ea_plugin_cli-0.20261001.3.dist-info/top_level.txt +1 -0
|
@@ -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 @@
|
|
|
1
|
+
ea_plugin_cli
|