diff --git a/README.md b/README.md index c1ed780af8..bbc98a6d9c 100644 --- a/README.md +++ b/README.md @@ -2064,6 +2064,22 @@ Git URLs are rejected because the stock Docker builder does not forward host SSH credentials. Installing from a loopback URL is allowed for local tooling but warns, because `127.0.0.1` recorded in the lock is a different machine inside the Docker builder. +For scripts, `deerflow extensions list --json` returns a JSON array in configuration +order, with `name`, `package`, `entry_point`, `enabled`, and `required` fields. An +unspecified package is `"-"`; an empty inventory is `[]`. Private plugin configuration +and source metadata are excluded. The default `list` output remains a table. +For example, from `backend/`, check that `demo` is configured as enabled and required: + +```bash +inventory=$(uv run --frozen --no-group extensions deerflow extensions list --json) || exit 1 +printf '%s\n' "$inventory" | + jq -e 'any(.[]; .name == "demo" and .enabled == true and .required == true)' +``` + +This reports configured state without executing plugins or changing files; it does +not verify package installation or successful Gateway loading. Configuration errors +exit non-zero with diagnostics on stderr and no inventory on stdout. + A managed package declares exactly one standard PEP 621 entry point: ```toml diff --git a/backend/packages/harness/deerflow/extensions/AGENTS.md b/backend/packages/harness/deerflow/extensions/AGENTS.md index 4c2746ff3f..bf6389fe83 100644 --- a/backend/packages/harness/deerflow/extensions/AGENTS.md +++ b/backend/packages/harness/deerflow/extensions/AGENTS.md @@ -22,12 +22,19 @@ Packaged extensions use one PEP 621 entry point in the `deerflow.extensions` group, for example `example = "deerflow_extension_example:install"`. The operator CLI is dispatched from the existing `deerflow` console script to `extensions/cli.py` and exposes only these -surfaces: `install SOURCE [--yes]`, `upgrade SOURCE [--yes]`, `list`, `enable NAME`, +surfaces: `install SOURCE [--yes]`, `upgrade SOURCE [--yes]`, `list [--json]`, `enable NAME`, `disable NAME`, and `remove NAME`. `NAME` resolves against the entry-point name, distribution name, or `module:install` value. The root `make extension-*` targets are convenience wrappers; because they execute from `backend/`, documentation should use absolute local source paths with `SOURCE=` unless backend-relative behavior is intentional. +`list --json` serializes the existing `list_configured()` inventory as an ordered +array with only `name`, `package`, `entry_point`, `enabled`, and `required`. Keep +the manager's `"-"` package fallback and schema defaults/coercion. Validate the +whole inventory before writing stdout; failures use the existing stderr/non-zero +path. Listing must not resolve entry points, mutate files, or expose private config +or source metadata. CLI coverage lives in `tests/test_extension_list_json.py`. + `ExtensionManager` owns the package/config transaction. Install runs a controlled `uv add --project --group extensions --no-workspace --no-sync -- `, updates the dedicated `[dependency-groups].extensions` list and `uv.lock`, discovers exactly one packaging entry diff --git a/backend/packages/harness/deerflow/extensions/cli.py b/backend/packages/harness/deerflow/extensions/cli.py index d349dda54b..328e126102 100644 --- a/backend/packages/harness/deerflow/extensions/cli.py +++ b/backend/packages/harness/deerflow/extensions/cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import json import os import subprocess import sys @@ -51,7 +52,8 @@ def build_parser() -> argparse.ArgumentParser: enable = commands.add_parser("enable", help="enable an installed extension") enable.add_argument("--name-env", action="store_true", help=argparse.SUPPRESS) enable.add_argument("name", help="extension name, distribution, or module:install entry point") - commands.add_parser("list", help="list configured extensions") + list_command = commands.add_parser("list", help="list configured extensions") + list_command.add_argument("--json", action="store_true", help="output configured extension inventory as JSON") remove = commands.add_parser("remove", help="uninstall an extension and remove its config entry") remove.add_argument("--name-env", action="store_true", help=argparse.SUPPRESS) remove.add_argument("name", help="extension name, distribution, or module:install entry point") @@ -104,6 +106,19 @@ def main(argv: Sequence[str] | None = None) -> int: return 0 if args.command == "list": configured = manager.list_configured() + if args.json: + inventory = [ + { + "name": extension.name, + "package": extension.distribution, + "entry_point": extension.use, + "enabled": extension.enabled, + "required": extension.required, + } + for extension in configured + ] + print(json.dumps(inventory, indent=2)) + return 0 print("NAME\tSTATE\tPACKAGE\tENTRY POINT") for extension in configured: state = "enabled" if extension.enabled else "disabled" diff --git a/backend/tests/test_extension_list_json.py b/backend/tests/test_extension_list_json.py new file mode 100644 index 0000000000..54dad67ebb --- /dev/null +++ b/backend/tests/test_extension_list_json.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml + +from deerflow.tui.cli import main as deerflow_main + + +@pytest.fixture +def checkout(tmp_path: Path, monkeypatch) -> Path: + root = tmp_path / "deer-flow" + (root / "backend").mkdir(parents=True) + (root / "backend" / "pyproject.toml").write_text('[project]\nname = "inventory-test"\n', encoding="utf-8") + monkeypatch.setenv("DEER_FLOW_PROJECT_ROOT", str(root)) + monkeypatch.delenv("DEER_FLOW_CONFIG_PATH", raising=False) + return root + + +def test_json_inventory_preserves_order_defaults_and_only_public_fields(checkout: Path, capsys) -> None: + plugins = [ + { + "name": '演示 "extension"\nsecond line', + "package": "deerflow-extension-demo", + "use": "not_installed_demo:install", + "enabled": "false", + "required": "true", + "config": {"token": "private-test-value", "nested": {"path": "/private/source"}}, + }, + {"use": "not_installed_other:install"}, + ] + (checkout / "config.yaml").write_text(yaml.safe_dump({"plugins": plugins}), encoding="utf-8") + + assert deerflow_main(["extensions", "list", "--json"]) == 0 + + captured = capsys.readouterr() + assert captured.err == "" + inventory = json.loads(captured.out) + assert inventory == [ + { + "name": plugins[0]["name"], + "package": "deerflow-extension-demo", + "entry_point": "not_installed_demo:install", + "enabled": False, + "required": True, + }, + {"name": "not_installed_other:install", "package": "-", "entry_point": "not_installed_other:install", "enabled": True, "required": False}, + ] + for extension in inventory: + assert isinstance(extension["enabled"], bool) + assert isinstance(extension["required"], bool) + + +@pytest.mark.parametrize("config", ["plugins: []\n", "plugins:\n", "config_version: 1\n"]) +def test_json_empty_inventory(checkout: Path, capsys, config: str) -> None: + (checkout / "config.yaml").write_text(config, encoding="utf-8") + + assert deerflow_main(["extensions", "list", "--json"]) == 0 + + captured = capsys.readouterr() + assert json.loads(captured.out) == [] + assert captured.err == "" + + +@pytest.mark.parametrize("args", [[], ["--json"]], ids=["table", "json"]) +@pytest.mark.parametrize( + "config", + [ + None, + "plugins: [\n", + "- not-a-mapping\n", + "plugins: {}\n", + "plugins: []\nplugins: []\n", + "plugins:\n - use: valid:install\n - name: missing-use\n", + ], + ids=["missing-file", "invalid-yaml", "invalid-root", "invalid-plugins", "duplicate-key", "invalid-later-entry"], +) +def test_list_config_errors_have_no_partial_stdout(checkout: Path, capsys, args: list[str], config: str | None) -> None: + if config is not None: + (checkout / "config.yaml").write_text(config, encoding="utf-8") + + assert deerflow_main(["extensions", "list", *args]) == 1 + + captured = capsys.readouterr() + assert captured.out == "" + assert "extension command failed:" in captured.err + assert "Traceback" not in captured.err + + +def test_default_table_output_is_unchanged(checkout: Path, capsys) -> None: + (checkout / "config.yaml").write_text( + "plugins:\n - name: demo\n package: demo-package\n use: demo:install\n required: true\n - use: other:install\n enabled: false\n", + encoding="utf-8", + ) + + assert deerflow_main(["extensions", "list"]) == 0 + + captured = capsys.readouterr() + assert captured.out == "NAME\tSTATE\tPACKAGE\tENTRY POINT\ndemo\tenabled\tdemo-package\tdemo:install\nother:install\tdisabled\t-\tother:install\n" + assert captured.err == "" + + +def test_console_json_uses_selected_config_without_executing_plugins_or_writing_files(checkout: Path) -> None: + (checkout / "config.yaml").write_text("plugins: []\n", encoding="utf-8") + selected = checkout / "deployment.yaml" + selected.write_text("plugins:\n - use: must_not_import:install\n required: true\n", encoding="utf-8") + # Importing this configured entry point would fail the command and leave evidence. + (checkout / "must_not_import.py").write_text( + "from pathlib import Path\nPath('plugin-executed').touch()\nraise RuntimeError('plugin must not run')\n", + encoding="utf-8", + ) + before = {path.relative_to(checkout): path.read_bytes() for path in checkout.rglob("*") if path.is_file()} + env = {**os.environ, "DEER_FLOW_CONFIG_PATH": str(selected), "PYTHONPATH": str(checkout)} + console = Path(sys.executable).parent / ("deerflow.exe" if os.name == "nt" else "deerflow") + + result = subprocess.run([str(console), "extensions", "list", "--json"], cwd=checkout, env=env, capture_output=True, text=True, encoding="utf-8", timeout=30) + + assert result.returncode == 0, result.stderr + assert result.stderr == "" + assert json.loads(result.stdout) == [{"name": "must_not_import:install", "package": "-", "entry_point": "must_not_import:install", "enabled": True, "required": True}] + assert {path.relative_to(checkout): path.read_bytes() for path in checkout.rglob("*") if path.is_file()} == before