CLI Full Plugin Surface Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a CLI-installed plugin surface so qiongli install --profile full --target all --surface plugin installs Qiongli as a local plugin bundle backed by the unified Python full MCP server.
Architecture: Keep marketplace plugin artifacts as the no-Python lite path with bundled Node literature MCP. Add a separate local-plugin install path in the Python CLI that materializes plugin manifests, commands, and skills/qiongli-workflow, then writes MCP config pointing to qiongli mcp serve --transport stdio. Preserve legacy global skill installation through --surface skills and let users request both surfaces explicitly.
Tech Stack: Python 3.12, unittest, existing qiongli.universal_installer, existing qiongli.subject_materializer, existing qiongli.distribution_metadata, Codex personal marketplace JSON, generated .codex-plugin and .claude-plugin manifests.
Scope Decisions
--surface skillskeeps the current behavior: installqiongli-workflowinto global client skill directories and optionally register full MCP at the client level.--surface plugininstalls a local plugin bundle. For Codex, it writes a personal marketplace entry and a plugin root under the marketplace root. For Claude Code, it generates the same plugin payload with.claude-plugin/plugin.jsonand routes full MCP through that manifest; the registration adapter owns the client-specific install command.--surface bothinstalls both the legacy global skill directory and the local plugin bundle.- Marketplace release artifacts keep their existing lite Node literature MCP. The release generator in
tooling/scripts/build_plugin_artifacts.pyshould not be changed to point at the Python full MCP. - Provider secrets remain outside plugin manifests,
.mcp.json, release ZIPs, and research artifacts. - The first stable implementation keeps the CLI default as
--surface skillsto avoid surprising existing users. Documentation recommends--surface pluginfor full local Qiongli.
File Structure
Modify
packages/python-qiongli/src/qiongli/universal_installer.py- Add
SURFACE_CHOICES. - Extend
InstallOptionsandRemoveOptionswithsurface. - Add
pluginto managed install parts. - Wire plugin installation and removal into the existing install/remove flow.
- Avoid duplicate full MCP registrations for targets whose plugin manifest already owns MCP.
- Add
Create
packages/python-qiongli/src/qiongli/local_plugin_installer.py- Own local plugin paths, manifest writing, MCP manifest writing, command wrapper generation, personal marketplace entry updates, and managed plugin removal.
- Keep Codex local marketplace behavior in this module instead of writing Codex cache internals.
- Generate
.codex-plugin/plugin.json, optional.claude-plugin/plugin.json,skills/qiongli-workflow/,commands/*.md, and.mcp.json.
Modify
packages/python-qiongli/src/qiongli/cli.py- Add
--surface skills|plugin|bothtoinstall,upgrade,initwhere relevant, andremove. - Pass
surfacethrough to installer options.
- Add
Modify
tests/test_cli.py- Assert CLI parser passes
surface.
- Assert CLI parser passes
Modify
tests/test_universal_installer.py- Assert plugin surface skips legacy global skill install unless
bothis selected. - Assert full profile with plugin surface installs plugin-backed MCP for Codex without writing duplicate client-level Codex MCP.
- Assert Antigravity and Hermes still receive client-level full MCP config under
--target all --surface plugin.
- Assert plugin surface skips legacy global skill install unless
Create
tests/test_local_plugin_installer.py- Unit-test local plugin manifest content, full MCP manifest content, marketplace path resolution, command wrapper generation, and managed removal.
Modify docs:
docs/reference/cli.mddocs/guide/install.mddocs/zh/guide/install.mdREADME_CN.mddocs/advanced/plugin-first-architecture.mddocs/advanced/qiongli-cli-plugin-structure.html
Task 1: Add Surface Parsing And Data Model
Files:
Modify:
packages/python-qiongli/src/qiongli/universal_installer.pyModify:
packages/python-qiongli/src/qiongli/cli.pyModify:
tests/test_cli.py[ ] Step 1: Write failing CLI parser tests
Append these tests to tests/test_cli.py near the existing install/upgrade parser tests:
def test_install_command_passes_surface_to_installer(self) -> None:
captured = {}
def fake_install(options):
captured["options"] = options
return 0
with mock.patch("qiongli.cli.install", fake_install):
rc = cli.main(
[
"qiongli",
"install",
"--profile",
"full",
"--target",
"codex",
"--surface",
"plugin",
"--dry-run",
]
)
self.assertEqual(rc, 0)
self.assertEqual(captured["options"].surface, "plugin")
def test_upgrade_command_passes_surface_to_installer(self) -> None:
captured = {}
def fake_run_installer(options):
captured["options"] = options
return 0
with tempfile.TemporaryDirectory() as tmp_dir:
archive = Path(tmp_dir) / "repo.tar.gz"
source = Path(tmp_dir) / "source"
source.mkdir()
(source / "scripts").mkdir()
(source / "scripts" / "bootstrap_qiongli.py").write_text("print('ok')\n", encoding="utf-8")
with tarfile.open(archive, "w:gz") as tar:
tar.add(source, arcname="qiongli-source")
with mock.patch("qiongli.cli._resolve_upstream_repo", return_value=("jxpeng98/qiongli", "arg")):
with mock.patch("qiongli.cli._download", side_effect=lambda _url, dest: shutil.copy2(archive, dest)):
with mock.patch("qiongli.cli._run_installer", fake_run_installer):
rc = cli.main(
[
"qiongli",
"upgrade",
"--ref",
"v1.7.0",
"--target",
"codex",
"--surface",
"plugin",
"--dry-run",
]
)
self.assertEqual(rc, 0)
self.assertEqual(captured["options"].surface, "plugin")- [ ] Step 2: Run parser tests and verify failure
Run:
python3 -m unittest tests.test_cli -vExpected: FAIL because InstallOptions has no surface field and the parsers do not define --surface.
- [ ] Step 3: Add installer surface constants and option fields
In packages/python-qiongli/src/qiongli/universal_installer.py, update constants and dataclasses:
TARGET_CHOICES = ("codex", "claude", "antigravity", "hermes", "all")
PROFILE_CHOICES = ("partial", "full")
SURFACE_CHOICES = ("skills", "plugin", "both")
PART_CHOICES = ("globals", "plugin", "project", "cli", "mcp", "doctor")Add surface to InstallOptions:
@dataclass
class InstallOptions:
repo_root: Path
project_dir: Path
subject: str = "core"
coverage: str = "complete"
target: str = "all"
mode: str = "copy"
overwrite: bool = False
install_cli: bool | None = None
cli_dir: Path | None = None
install_mcp: bool | None = None
doctor: bool | None = None
dry_run: bool = False
profile: str | None = None
parts: tuple[str, ...] | None = None
surface: str = "skills"Add surface to RemoveOptions:
@dataclass
class RemoveOptions:
project_dir: Path
target: str = "all"
dry_run: bool = False
parts: tuple[str, ...] | None = None
cli_dir: Path | None = None
surface: str = "skills"When apply_profile() creates a new InstallOptions, carry surface=options.surface.
- [ ] Step 4: Add
--surfaceto CLI parsers
In packages/python-qiongli/src/qiongli/cli.py, import SURFACE_CHOICES from qiongli.universal_installer.
Add this argument to upgrade, install, and remove parsers:
parser.add_argument(
"--surface",
default="skills",
choices=SURFACE_CHOICES,
help="Install or remove surface: skills, plugin, or both.",
)Use the variable name that matches each parser object: upgrade.add_argument, install_parser.add_argument, and remove_parser.add_argument.
Pass it into InstallOptions in cmd_upgrade(), cmd_install(), and cmd_init():
surface=getattr(args, "surface", "skills"),Pass it into RemoveOptions in cmd_remove():
surface=getattr(args, "surface", "skills"),- [ ] Step 5: Run parser tests and verify pass
Run:
python3 -m unittest tests.test_cli -vExpected: PASS for parser tests.
- [ ] Step 6: Commit
git add packages/python-qiongli/src/qiongli/universal_installer.py packages/python-qiongli/src/qiongli/cli.py tests/test_cli.py
git commit -m "feat(installer): add install surface option"Task 2: Implement Local Plugin Payload Builder
Files:
Create:
packages/python-qiongli/src/qiongli/local_plugin_installer.pyCreate:
tests/test_local_plugin_installer.py[ ] Step 1: Write failing local plugin tests
Create tests/test_local_plugin_installer.py:
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from qiongli.local_plugin_installer import (
LocalPluginOptions,
install_local_plugin,
remove_local_plugin,
resolve_codex_plugin_paths,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
class LocalPluginInstallerTests(unittest.TestCase):
def test_resolve_codex_paths_match_marketplace_relative_source(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
marketplace_path = Path(tmp_dir) / ".agents" / "plugins" / "marketplace.json"
paths = resolve_codex_plugin_paths("qiongli", marketplace_path=marketplace_path)
self.assertEqual(paths.marketplace_path, marketplace_path)
self.assertEqual(paths.plugin_root, marketplace_path.parent / "plugins" / "qiongli")
self.assertEqual(paths.marketplace_source_path, "./plugins/qiongli")
def test_install_codex_plugin_writes_full_mcp_manifest(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
marketplace_path = root / ".agents" / "plugins" / "marketplace.json"
result = install_local_plugin(
LocalPluginOptions(
repo_root=REPO_ROOT,
subject="core",
coverage="complete",
target="codex",
mode="copy",
overwrite=False,
dry_run=False,
codex_marketplace_path=marketplace_path,
)
)
plugin_root = result.installed_roots["codex"]
codex_manifest = json.loads((plugin_root / ".codex-plugin" / "plugin.json").read_text(encoding="utf-8"))
mcp_manifest = json.loads((plugin_root / ".mcp.json").read_text(encoding="utf-8"))
marketplace = json.loads(marketplace_path.read_text(encoding="utf-8"))
self.assertEqual(codex_manifest["name"], "qiongli")
self.assertEqual(codex_manifest["skills"], "./skills/")
self.assertEqual(codex_manifest["mcpServers"], "./.mcp.json")
self.assertTrue((plugin_root / "skills" / "qiongli-workflow" / "SKILL.md").is_file())
self.assertTrue((plugin_root / "commands" / "paper.md").is_file())
server = mcp_manifest["mcpServers"]["qiongli"]
self.assertEqual(server["command"], "qiongli")
self.assertEqual(server["args"], ["mcp", "serve", "--transport", "stdio"])
self.assertNotIn("env", server)
self.assertNotIn("qiongli-literature-provider", json.dumps(mcp_manifest))
self.assertEqual(marketplace["name"], "personal")
self.assertEqual(marketplace["plugins"][0]["source"]["path"], "./plugins/qiongli")
def test_install_claude_plugin_writes_full_mcp_manifest(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
claude_root = root / ".qiongli" / "plugins" / "claude-code"
result = install_local_plugin(
LocalPluginOptions(
repo_root=REPO_ROOT,
subject="core",
coverage="complete",
target="claude",
mode="copy",
overwrite=False,
dry_run=False,
claude_plugin_parent=claude_root,
)
)
plugin_root = result.installed_roots["claude"]
manifest = json.loads((plugin_root / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8"))
self.assertEqual(manifest["name"], "qiongli")
server = manifest["mcpServers"]["qiongli"]
self.assertEqual(server["type"], "stdio")
self.assertEqual(server["command"], "qiongli")
self.assertEqual(server["args"], ["mcp", "serve", "--transport", "stdio"])
self.assertTrue((plugin_root / "skills" / "qiongli-workflow" / "SKILL.md").is_file())
def test_remove_local_plugin_removes_managed_codex_payload_and_entry(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
marketplace_path = Path(tmp_dir) / ".agents" / "plugins" / "marketplace.json"
install_local_plugin(
LocalPluginOptions(
repo_root=REPO_ROOT,
subject="core",
coverage="complete",
target="codex",
mode="copy",
overwrite=False,
dry_run=False,
codex_marketplace_path=marketplace_path,
)
)
removed = remove_local_plugin(
target="codex",
dry_run=False,
codex_marketplace_path=marketplace_path,
)
marketplace = json.loads(marketplace_path.read_text(encoding="utf-8"))
self.assertEqual(removed, 1)
self.assertEqual(marketplace["plugins"], [])
self.assertFalse((marketplace_path.parent / "plugins" / "qiongli").exists())
if __name__ == "__main__":
unittest.main()- [ ] Step 2: Run tests and verify failure
Run:
python3 -m unittest tests.test_local_plugin_installer -vExpected: FAIL because qiongli.local_plugin_installer does not exist.
- [ ] Step 3: Create local plugin installer module
Create packages/python-qiongli/src/qiongli/local_plugin_installer.py with these public types and helpers:
from __future__ import annotations
import json
import os
import re
import shutil
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from .distribution_metadata import PluginDefinition, load_plugin_distribution
from .source_layout import RepoLayout
from .subject_materializer import MaterializeOptions, materialize_subject_package
PLUGIN_ID = "qiongli"
SKILL_DIR_NAME = "qiongli-workflow"
QIONGLI_FULL_MCP_SERVER = {
"command": "qiongli",
"args": ["mcp", "serve", "--transport", "stdio"],
}
@dataclass(frozen=True)
class CodexPluginPaths:
marketplace_path: Path
plugin_root: Path
marketplace_source_path: str
@dataclass(frozen=True)
class LocalPluginOptions:
repo_root: Path
subject: str = "core"
coverage: str = "complete"
target: str = "all"
mode: str = "copy"
overwrite: bool = False
dry_run: bool = False
codex_marketplace_path: Path | None = None
claude_plugin_parent: Path | None = None
@dataclass(frozen=True)
class LocalPluginResult:
installed_roots: dict[str, Path] = field(default_factory=dict)
changed: bool = FalseAdd path helpers:
def resolve_codex_plugin_paths(
plugin_id: str = PLUGIN_ID,
*,
marketplace_path: Path | str | None = None,
) -> CodexPluginPaths:
raw_path = marketplace_path or os.environ.get("QIONGLI_CODEX_MARKETPLACE_PATH", "").strip()
path = Path(raw_path).expanduser() if raw_path else Path.home() / ".agents" / "plugins" / "marketplace.json"
path = path.resolve()
source_path = f"./plugins/{plugin_id}"
plugin_root = path.parent / "plugins" / plugin_id
return CodexPluginPaths(
marketplace_path=path,
plugin_root=plugin_root,
marketplace_source_path=source_path,
)
def _claude_plugin_root(plugin_id: str, parent: Path | str | None = None) -> Path:
raw_parent = parent or os.environ.get("QIONGLI_CLAUDE_PLUGIN_PARENT", "").strip()
base = Path(raw_parent).expanduser() if raw_parent else Path.home() / ".qiongli" / "plugins" / "claude-code"
return base.resolve() / plugin_idAdd manifest writers:
def _version(root: Path) -> str:
return (RepoLayout(root).workflow / "VERSION").read_text(encoding="utf-8").strip().lstrip("v")
def _keywords(plugin: PluginDefinition, platform_keyword: str) -> list[str]:
return [*plugin.keywords, *[item for item in (platform_keyword,) if item not in plugin.keywords]]
def _write_json(path: Path, payload: dict[str, Any], *, dry_run: bool) -> None:
if dry_run:
return
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def _write_codex_manifest(dest: Path, plugin: PluginDefinition, version: str, *, dry_run: bool) -> None:
payload = {
"name": plugin.id,
"version": version,
"description": plugin.description,
"author": plugin.author,
"category": plugin.category,
"homepage": plugin.homepage,
"repository": plugin.repository,
"license": plugin.license,
"keywords": _keywords(plugin, "codex-skills"),
"skills": "./skills/",
"mcpServers": "./.mcp.json",
"interface": {
"displayName": plugin.display_name,
"shortDescription": plugin.codex_short_description,
"longDescription": plugin.description,
"developerName": plugin.author["name"],
"category": plugin.category,
"capabilities": ["Write"],
"websiteURL": plugin.repository,
"defaultPrompt": list(plugin.default_prompts),
"brandColor": plugin.brand_color,
},
}
_write_json(dest, payload, dry_run=dry_run)
def _write_claude_manifest(dest: Path, plugin: PluginDefinition, version: str, *, dry_run: bool) -> None:
payload = {
"name": plugin.id,
"description": plugin.description,
"version": version,
"author": plugin.author,
"category": plugin.category,
"homepage": plugin.homepage,
"repository": plugin.repository,
"license": plugin.license,
"keywords": _keywords(plugin, "claude-code-plugins"),
"mcpServers": {
plugin.mcp_server_name: {
**QIONGLI_FULL_MCP_SERVER,
"type": "stdio",
}
},
}
_write_json(dest, payload, dry_run=dry_run)
def _write_codex_mcp_manifest(dest: Path, server_name: str, *, dry_run: bool) -> None:
payload = {
"mcpServers": {
server_name: {
**QIONGLI_FULL_MCP_SERVER,
"startup_timeout_sec": 20,
"tool_timeout_sec": 120,
}
}
}
_write_json(dest, payload, dry_run=dry_run)Add skill and command materialization:
def _remove_existing(path: Path, *, overwrite: bool, dry_run: bool) -> None:
if not (path.exists() or path.is_symlink()):
return
if not overwrite and not _is_managed_plugin_root(path):
raise FileExistsError(f"{path} exists and is not a managed Qiongli plugin; use --overwrite")
if dry_run:
return
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
else:
path.unlink()
def _is_managed_plugin_root(path: Path) -> bool:
codex_manifest = path / ".codex-plugin" / "plugin.json"
claude_manifest = path / ".claude-plugin" / "plugin.json"
for manifest in (codex_manifest, claude_manifest):
if not manifest.is_file():
continue
try:
payload = json.loads(manifest.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
if payload.get("name") == PLUGIN_ID:
return True
return False
def _workflow_description(workflow_path: Path) -> str:
text = workflow_path.read_text(encoding="utf-8")
match = re.search(r"(?ms)^---\n(.*?)\n---", text)
if match:
desc = re.search(r"(?m)^description:\s*(.+)$", match.group(1))
if desc:
return desc.group(1).strip()
return f"Run the {workflow_path.stem} research workflow."
def _write_commands(repo_root: Path, commands_root: Path, *, dry_run: bool) -> None:
if dry_run:
return
workflow_root = RepoLayout(repo_root).workflow / "workflows"
commands_root.mkdir(parents=True, exist_ok=True)
for workflow_path in sorted(workflow_root.glob("*.md")):
text = "\n".join(
[
"---",
f"description: {_workflow_description(workflow_path)}",
"---",
"",
(
"Load the `qiongli` skill from this plugin, then follow "
f"`skills/{SKILL_DIR_NAME}/workflows/{workflow_path.name}`."
),
"",
"Use that workflow as the source of truth for task order, artifacts, and quality gates.",
"",
]
)
(commands_root / workflow_path.name).write_text(text, encoding="utf-8")
def _materialize_skill(repo_root: Path, dest: Path, options: LocalPluginOptions) -> None:
if options.dry_run:
return
materialize_subject_package(
MaterializeOptions(
source=repo_root,
out=dest,
subject=options.subject,
flavor="full",
coverage=options.coverage,
)
)Add marketplace update:
def _load_marketplace(path: Path) -> dict[str, Any]:
if not path.is_file():
return {"name": "personal", "interface": {"displayName": "Personal"}, "plugins": []}
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"{path} must contain a JSON object")
payload.setdefault("name", "personal")
payload.setdefault("interface", {"displayName": "Personal"})
payload.setdefault("plugins", [])
if not isinstance(payload["plugins"], list):
raise ValueError(f"{path} plugins field must be a list")
return payload
def _upsert_marketplace_entry(paths: CodexPluginPaths, *, dry_run: bool) -> None:
payload = _load_marketplace(paths.marketplace_path)
entry = {
"name": PLUGIN_ID,
"source": {"source": "local", "path": paths.marketplace_source_path},
"policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"},
"category": "Education",
}
plugins = payload["plugins"]
for index, current in enumerate(plugins):
if isinstance(current, dict) and current.get("name") == PLUGIN_ID:
plugins[index] = entry
break
else:
plugins.append(entry)
_write_json(paths.marketplace_path, payload, dry_run=dry_run)Add install/remove entrypoints:
def _selected_plugin_targets(target: str) -> tuple[str, ...]:
if target == "all":
return ("codex", "claude")
if target in {"codex", "claude"}:
return (target,)
return ()
def install_local_plugin(options: LocalPluginOptions) -> LocalPluginResult:
repo_root = Path(options.repo_root).expanduser().resolve()
plugin = load_plugin_distribution(repo_root).plugins[PLUGIN_ID]
version = _version(repo_root)
installed: dict[str, Path] = {}
changed = False
for target in _selected_plugin_targets(options.target):
if target == "codex":
paths = resolve_codex_plugin_paths(PLUGIN_ID, marketplace_path=options.codex_marketplace_path)
plugin_root = paths.plugin_root
else:
plugin_root = _claude_plugin_root(PLUGIN_ID, options.claude_plugin_parent)
_remove_existing(plugin_root, overwrite=options.overwrite, dry_run=options.dry_run)
if not options.dry_run:
plugin_root.mkdir(parents=True, exist_ok=True)
if target == "codex":
_write_codex_manifest(plugin_root / ".codex-plugin" / "plugin.json", plugin, version, dry_run=options.dry_run)
_write_codex_mcp_manifest(plugin_root / ".mcp.json", plugin.mcp_server_name, dry_run=options.dry_run)
_upsert_marketplace_entry(paths, dry_run=options.dry_run)
else:
_write_claude_manifest(plugin_root / ".claude-plugin" / "plugin.json", plugin, version, dry_run=options.dry_run)
_materialize_skill(repo_root, plugin_root / "skills" / SKILL_DIR_NAME, options)
_write_commands(repo_root, plugin_root / "commands", dry_run=options.dry_run)
installed[target] = plugin_root
changed = True
return LocalPluginResult(installed_roots=installed, changed=changed)Implement remove_local_plugin():
def remove_local_plugin(
*,
target: str = "all",
dry_run: bool = False,
codex_marketplace_path: Path | str | None = None,
claude_plugin_parent: Path | str | None = None,
) -> int:
removed = 0
for selected in _selected_plugin_targets(target):
if selected == "codex":
paths = resolve_codex_plugin_paths(PLUGIN_ID, marketplace_path=codex_marketplace_path)
plugin_root = paths.plugin_root
if plugin_root.exists() or plugin_root.is_symlink():
if not _is_managed_plugin_root(plugin_root):
continue
if not dry_run:
shutil.rmtree(plugin_root)
removed += 1
if paths.marketplace_path.is_file() and not dry_run:
payload = _load_marketplace(paths.marketplace_path)
payload["plugins"] = [
entry
for entry in payload["plugins"]
if not (isinstance(entry, dict) and entry.get("name") == PLUGIN_ID)
]
_write_json(paths.marketplace_path, payload, dry_run=False)
continue
plugin_root = _claude_plugin_root(PLUGIN_ID, claude_plugin_parent)
if plugin_root.exists() or plugin_root.is_symlink():
if not _is_managed_plugin_root(plugin_root):
continue
if not dry_run:
shutil.rmtree(plugin_root)
removed += 1
return removed- [ ] Step 4: Run local plugin tests and verify pass
Run:
python3 -m unittest tests.test_local_plugin_installer -vExpected: PASS.
- [ ] Step 5: Commit
git add packages/python-qiongli/src/qiongli/local_plugin_installer.py tests/test_local_plugin_installer.py
git commit -m "feat(plugin): generate local full qiongli plugin"Task 3: Wire Plugin Surface Into Universal Installer
Files:
Modify:
packages/python-qiongli/src/qiongli/universal_installer.pyModify:
tests/test_universal_installer.py[ ] Step 1: Write failing installer behavior tests
Append these tests to tests/test_universal_installer.py inside UniversalInstallerTests:
def test_plugin_surface_installs_codex_plugin_without_global_skill(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
temp_root = Path(tmp_dir)
project_dir = temp_root / "project"
project_dir.mkdir(parents=True)
codex_home = temp_root / "codex-home"
marketplace_path = temp_root / ".agents" / "plugins" / "marketplace.json"
env = os.environ.copy()
env["CODEX_HOME"] = str(codex_home)
env["QIONGLI_CODEX_MARKETPLACE_PATH"] = str(marketplace_path)
env["PATH"] = ""
with mock.patch.dict(os.environ, env, clear=True):
result = install(
InstallOptions(
repo_root=REPO_ROOT,
project_dir=project_dir,
target="codex",
profile="full",
install_cli=False,
doctor=False,
surface="plugin",
)
)
plugin_root = marketplace_path.parent / "plugins" / "qiongli"
self.assertEqual(result, 0)
self.assertTrue((plugin_root / ".codex-plugin" / "plugin.json").is_file())
self.assertTrue((plugin_root / "skills" / "qiongli-workflow" / "SKILL.md").is_file())
self.assertFalse((codex_home / "skills" / "qiongli-workflow" / "SKILL.md").exists())
self.assertFalse((codex_home / "config.toml").exists())
def test_both_surface_installs_codex_plugin_and_global_skill(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
temp_root = Path(tmp_dir)
project_dir = temp_root / "project"
project_dir.mkdir(parents=True)
codex_home = temp_root / "codex-home"
marketplace_path = temp_root / ".agents" / "plugins" / "marketplace.json"
env = os.environ.copy()
env["CODEX_HOME"] = str(codex_home)
env["QIONGLI_CODEX_MARKETPLACE_PATH"] = str(marketplace_path)
env["PATH"] = ""
with mock.patch.dict(os.environ, env, clear=True):
result = install(
InstallOptions(
repo_root=REPO_ROOT,
project_dir=project_dir,
target="codex",
profile="full",
install_cli=False,
doctor=False,
surface="both",
)
)
self.assertEqual(result, 0)
self.assertTrue((marketplace_path.parent / "plugins" / "qiongli" / ".codex-plugin" / "plugin.json").is_file())
self.assertTrue((codex_home / "skills" / "qiongli-workflow" / "SKILL.md").is_file())
def test_plugin_surface_all_keeps_full_mcp_for_antigravity_and_hermes(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
temp_root = Path(tmp_dir)
project_dir = temp_root / "project"
project_dir.mkdir(parents=True)
codex_home = temp_root / "codex-home"
claude_home = temp_root / ".claude"
antigravity_home = temp_root / "antigravity-home"
hermes_home = temp_root / "hermes-home"
marketplace_path = temp_root / ".agents" / "plugins" / "marketplace.json"
env = os.environ.copy()
env["HOME"] = str(temp_root)
env["CODEX_HOME"] = str(codex_home)
env["CLAUDE_CODE_HOME"] = str(claude_home)
env["ANTIGRAVITY_HOME"] = str(antigravity_home)
env["HERMES_HOME"] = str(hermes_home)
env["QIONGLI_CODEX_MARKETPLACE_PATH"] = str(marketplace_path)
env["PATH"] = ""
with mock.patch.dict(os.environ, env, clear=True):
result = install(
InstallOptions(
repo_root=REPO_ROOT,
project_dir=project_dir,
target="all",
profile="full",
install_cli=False,
doctor=False,
surface="plugin",
)
)
antigravity_config = json.loads((antigravity_home / "settings.json").read_text(encoding="utf-8"))
hermes_config = json.loads((hermes_home / "settings.json").read_text(encoding="utf-8"))
self.assertEqual(result, 0)
self.assertTrue((marketplace_path.parent / "plugins" / "qiongli" / ".codex-plugin" / "plugin.json").is_file())
self.assertFalse((codex_home / "config.toml").exists())
self.assertEqual(antigravity_config["mcpServers"]["qiongli"]["command"], "qiongli")
self.assertEqual(hermes_config["mcpServers"]["qiongli"]["command"], "qiongli")- [ ] Step 2: Run installer tests and verify failure
Run:
python3 -m unittest tests.test_universal_installer -vExpected: FAIL because surface is not used by the installer flow.
- [ ] Step 3: Add container selection helpers
In packages/python-qiongli/src/qiongli/universal_installer.py, import local plugin helpers:
from .local_plugin_installer import LocalPluginOptions, install_local_plugin, remove_local_pluginAdd helper functions near normalize_parts():
def _default_container_parts(surface: str) -> tuple[str, ...]:
if surface == "skills":
return ("globals",)
if surface == "plugin":
return ("plugin",)
if surface == "both":
return ("globals", "plugin")
raise ValueError(f"Unsupported surface: {surface}")
def _container_parts(options: InstallOptions, selected_parts: tuple[str, ...] | None) -> tuple[bool, bool]:
if selected_parts is not None:
return "globals" in selected_parts, "plugin" in selected_parts
defaults = _default_container_parts(options.surface)
return "globals" in defaults, "plugin" in defaults
def _plugin_managed_mcp_targets(target: str, install_plugin: bool) -> set[str]:
if not install_plugin:
return set()
if target == "all":
return {"codex", "claude"}
if target in {"codex", "claude"}:
return {target}
return set()- [ ] Step 4: Validate surface in
install()andremove()
In install(), after target validation:
if options.surface not in SURFACE_CHOICES:
raise ValueError(f"Unsupported surface: {options.surface}")Carry surface=options.surface when recreating InstallOptions inside apply_profile().
In remove(), validate options.surface the same way after target validation.
- [ ] Step 5: Use surface to select globals and plugin install
Replace the existing install booleans in install():
selected_parts = normalize_parts(options.parts)
install_globals, install_plugin = _container_parts(options, selected_parts)
install_project = False if selected_parts is None else "project" in selected_parts
install_cli = bool(options.install_cli) if selected_parts is None else "cli" in selected_parts
install_mcp = bool(options.install_mcp) if selected_parts is None else "mcp" in selected_parts
doctor = bool(options.doctor) if selected_parts is None else "doctor" in selected_partsPrint surface in the install header:
print(f" surface: {options.surface}")- [ ] Step 6: Install local plugin before MCP client config
After the legacy global skill copy block and before shell CLI installation, add:
if install_plugin:
_print_section("Local Plugin")
plugin_result = install_local_plugin(
LocalPluginOptions(
repo_root=repo_root,
subject=options.subject,
coverage=options.coverage,
target=options.target,
mode=options.mode,
overwrite=options.overwrite,
dry_run=options.dry_run,
)
)
if plugin_result.installed_roots:
for plugin_target, plugin_root in plugin_result.installed_roots.items():
_print_result("Plugin", f"{plugin_target}: {plugin_root}", "ok")
else:
_print_result("Plugin", f"target {options.target} has no plugin surface", "skip")- [ ] Step 7: Avoid duplicate MCP registration for plugin-managed targets
Change _install_mcp_client_config() signature:
def _install_mcp_client_config(options: InstallOptions, *, skip_targets: set[str] | None = None) -> None:Inside it:
skip_targets = skip_targets or set()
for target, label in targets:
normalized = "claude" if target == "claude-code" else target
if normalized in skip_targets:
_print_mcp_config_result(label, "skipped", Path("<plugin manifest>"), "MCP is managed by local plugin manifest")
continue
result = install_mcp_config(target=target, dry_run=options.dry_run)
_print_mcp_config_result(label, result.status, result.path, result.detail)In install(), replace:
if install_mcp:
_install_mcp_client_config(options)with:
if install_mcp:
_install_mcp_client_config(
options,
skip_targets=_plugin_managed_mcp_targets(options.target, install_plugin),
)- [ ] Step 8: Wire plugin removal
In remove(), update selected booleans:
remove_plugin = "plugin" in selected_parts or options.surface in {"plugin", "both"}After global skill removal:
if remove_plugin:
_print_section("Local Plugin")
removed += remove_local_plugin(target=options.target, dry_run=options.dry_run)- [ ] Step 9: Run installer tests and verify pass
Run:
python3 -m unittest tests.test_universal_installer tests.test_local_plugin_installer -vExpected: PASS.
- [ ] Step 10: Commit
git add packages/python-qiongli/src/qiongli/universal_installer.py tests/test_universal_installer.py
git commit -m "feat(installer): wire local plugin surface"Task 4: Preserve Marketplace Lite MCP Contract
Files:
Modify:
tests/test_plugin_manifests.pyNo production code should change in this task unless the test exposes a regression.
[ ] Step 1: Strengthen marketplace plugin tests
In tests/test_plugin_manifests.py, update test_codex_plugin_bundles_qiongli_mcp_server to assert the marketplace artifact remains lite:
self.assertEqual(server["command"], "node")
self.assertEqual(server["args"], ["./mcp/qiongli-literature-provider/index.mjs"])
self.assertNotEqual(server["command"], "qiongli")
self.assertNotIn("mcp serve", json.dumps(server))Update test_claude_plugin_bundles_qiongli_mcp_server similarly:
self.assertEqual(server["command"], "node")
self.assertEqual(
server["args"],
["${CLAUDE_PLUGIN_ROOT}/mcp/qiongli-literature-provider/index.mjs"],
)
self.assertNotEqual(server["command"], "qiongli")
self.assertNotIn("mcp serve", json.dumps(server))- [ ] Step 2: Run marketplace tests
Run:
python3 -m unittest tests.test_plugin_manifests -vExpected: PASS. If this fails because marketplace artifacts switched to full MCP, restore tooling/scripts/build_plugin_artifacts.py to emit the existing Node literature provider for marketplace builds.
- [ ] Step 3: Commit
git add tests/test_plugin_manifests.py
git commit -m "test(plugin): preserve marketplace lite mcp contract"Task 5: Document The New Install Matrix
Files:
Modify:
docs/reference/cli.mdModify:
docs/guide/install.mdModify:
docs/zh/guide/install.mdModify:
README_CN.mdModify:
docs/advanced/plugin-first-architecture.mdModify:
docs/advanced/qiongli-cli-plugin-structure.html[ ] Step 1: Update CLI reference
In docs/reference/cli.md, update the qiongli install synopsis:
qiongli install \
[--subject core|economics|accounting|business|finance|political-economy|geoeconomics|economics-accounting] \
[--coverage complete|focused] \
[--target codex|claude|antigravity|hermes|all] \
[--surface skills|plugin|both] \
[--mode copy|link] \
[--project-dir <path>] \
[--overwrite] \
[--doctor] \
[--dry-run]Add examples:
qiongli install --profile full --target codex --surface plugin
qiongli install --profile full --target all --surface plugin
qiongli install --profile full --target all --surface both
qiongli remove --target codex --surface pluginAdd this behavior note:
`--surface plugin` installs a local plugin bundle backed by the full Python MCP server.
For Codex, the CLI writes a personal marketplace entry and a plugin payload whose `.mcp.json`
launches `qiongli mcp serve --transport stdio`. Marketplace plugins remain the lite no-Python path
and continue to bundle the Node literature provider.- [ ] Step 2: Update install guides
In docs/guide/install.md and docs/zh/guide/install.md, add a matrix row:
| Full local plugin | `qiongli install --profile full --target all --surface plugin` | Full local Qiongli inside a client-native plugin container | Python 3.12+ |In the Chinese guide use:
| 本地 full plugin | `qiongli install --profile full --target all --surface plugin` | 让 CLI 生成客户端 plugin,并接入统一 full MCP | Python 3.12+ |- [ ] Step 3: Update README_CN
In README_CN.md, update the “不知道选什么” path:
如果你要完整本地 Qiongli,优先使用:
```bash
qiongli install --profile full --target all --surface plugin
```
这会生成本地 plugin,并把 MCP 接到 `qiongli mcp serve --transport stdio`。
Marketplace plugin 仍然是无 CLI / 无 Python 环境下的轻量入口。
```- [ ] Step 4: Update architecture docs
In docs/advanced/plugin-first-architecture.md, replace the current compatibility paragraph with:
For full local Qiongli, the CLI can now install a client-native local plugin bundle:
```bash
qiongli install --profile full --target all --surface plugin
```
That local plugin uses the same skill/workflow payload shape as marketplace artifacts, but its MCP
configuration points at the unified Python-backed full MCP server. Marketplace artifacts keep the
bundled Node literature MCP so they remain usable without Python or the Qiongli CLI.
```- [ ] Step 5: Update the HTML explanation
In docs/advanced/qiongli-cli-plugin-structure.html, replace the final note:
这张图描述的是建议改造后的目标结构,不是当前 v1.7.0 的完整现状。with:
这张图描述的是 CLI full plugin surface 的目标结构:CLI local plugin 使用 full MCP;
marketplace plugin 保留 lite literature MCP。- [ ] Step 6: Run documentation checks
Run:
rg -n "surface plugin|full plugin|lite literature|qiongli install --profile full --target all --surface plugin" README_CN.md docsExpected: output includes the README, install guides, CLI reference, architecture doc, and HTML page.
- [ ] Step 7: Commit
git add README_CN.md docs/reference/cli.md docs/guide/install.md docs/zh/guide/install.md docs/advanced/plugin-first-architecture.md docs/advanced/qiongli-cli-plugin-structure.html
git commit -m "docs(installer): document full plugin surface"Task 6: End-To-End Verification
Files:
No new source files.
Update tests only if verification exposes a gap.
[ ] Step 1: Run targeted unit tests
Run:
python3 -m unittest \
tests.test_cli \
tests.test_universal_installer \
tests.test_local_plugin_installer \
tests.test_plugin_manifests \
-vExpected: PASS.
- [ ] Step 2: Run full installer dry-run smoke
Run:
python3 -m qiongli.cli install --profile full --target all --surface plugin --dry-runExpected output includes:
surface: plugin
== Local Plugin ==
Plugin
== MCP Client Config ==Expected output does not include provider secrets.
- [ ] Step 3: Run Codex temp-home install smoke
Run:
tmp="$(mktemp -d)"
HOME="$tmp" \
CODEX_HOME="$tmp/.codex" \
QIONGLI_CODEX_MARKETPLACE_PATH="$tmp/.agents/plugins/marketplace.json" \
PATH="$PATH" \
python3 -m qiongli.cli install --profile full --target codex --surface plugin --no-cli
test -f "$tmp/.agents/plugins/plugins/qiongli/.codex-plugin/plugin.json"
test -f "$tmp/.agents/plugins/plugins/qiongli/.mcp.json"
test -f "$tmp/.agents/plugins/plugins/qiongli/skills/qiongli-workflow/SKILL.md"
python3 -m json.tool "$tmp/.agents/plugins/plugins/qiongli/.mcp.json" >/dev/null
python3 -m json.tool "$tmp/.agents/plugins/marketplace.json" >/dev/nullExpected: command exits 0.
- [ ] Step 4: Inspect Codex full MCP manifest
Run:
tmp="$(mktemp -d)"
HOME="$tmp" \
CODEX_HOME="$tmp/.codex" \
QIONGLI_CODEX_MARKETPLACE_PATH="$tmp/.agents/plugins/marketplace.json" \
PATH="$PATH" \
python3 -m qiongli.cli install --profile full --target codex --surface plugin --no-cli
rg -n '"command": "qiongli"|"mcp"|"serve"|"stdio"|qiongli-literature-provider' "$tmp/.agents/plugins/plugins/qiongli/.mcp.json"Expected:
"command": "qiongli"
"mcp"
"serve"
"stdio"Expected: no qiongli-literature-provider line in the CLI-generated plugin .mcp.json.
- [ ] Step 5: Inspect marketplace lite MCP still uses Node
Run:
python3 scripts/materialize_distribution_payloads.py --target plugin --out /tmp/qiongli-marketplace-check --force
rg -n '"command": "node"|qiongli-literature-provider|"command": "qiongli"' /tmp/qiongli-marketplace-check/plugins/qiongli/.mcp.jsonExpected:
"command": "node"
qiongli-literature-providerExpected: no "command": "qiongli" in the marketplace artifact .mcp.json.
- [ ] Step 6: Run MCP server tool-list smoke
Run:
printf '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}\n' | python3 -m qiongli.bridges.mcp_server_stdioExpected output includes:
qiongli_literature_search
qiongli_orchestrator_route
qiongli_task_plan
qiongli_task_run- [ ] Step 7: Final boundary review
Run:
git diff --name-onlyExpected changed files are limited to:
packages/python-qiongli/src/qiongli/cli.py
packages/python-qiongli/src/qiongli/local_plugin_installer.py
packages/python-qiongli/src/qiongli/universal_installer.py
tests/test_cli.py
tests/test_local_plugin_installer.py
tests/test_plugin_manifests.py
tests/test_universal_installer.py
README_CN.md
docs/reference/cli.md
docs/guide/install.md
docs/zh/guide/install.md
docs/advanced/plugin-first-architecture.md
docs/advanced/qiongli-cli-plugin-structure.htmlAlso verify:
rg -n "OPENAI_API_KEY|ANTHROPIC_API_KEY|SEMANTIC_SCHOLAR_API_KEY|QIONGLI_OPENALEX_EMAIL" \
packages/python-qiongli/src/qiongli tests README_CN.md docsExpected: only documentation or config field names appear; no real secret values appear.
- [ ] Step 8: Commit verification fixes
If verification required small fixes, commit them:
git add packages/python-qiongli/src/qiongli tests README_CN.md docs
git commit -m "fix(installer): harden full plugin surface"If no fixes are needed, do not create an empty commit.
Rollout Notes
- First release keeps
--surface skillsas default. - Recommended full local command becomes:
qiongli install --profile full --target all --surface plugin- Marketplace install remains:
skill workflow + bundled lite Node literature MCP- CLI local plugin install becomes:
skill workflow + full Python MCP + orchestrator + doctor + task plan/runSelf-Review
- Spec coverage: The plan covers CLI parser, installer model, local plugin payload generation, full MCP routing, marketplace-lite preservation, docs, tests, and verification.
- Placeholder scan: No incomplete implementation markers are present.
- Type consistency:
surface,LocalPluginOptions,LocalPluginResult,resolve_codex_plugin_paths,install_local_plugin, andremove_local_pluginare named consistently across tasks. - Repository boundary: Marketplace release generator remains separate. Local plugin payload generation lives in the Python CLI package because it is runtime installer behavior, not published marketplace catalog state.