Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- The Claude plugin folder now carries the server code at `client-plugin/server/src/plexus/`, so an install of the folder alone starts. `python scripts/build_client_package.py --sync-vendored` rewrites that copy from `src/`, and a test fails when it drifts. The launcher no longer looks outside the plugin folder; a missing copy prints a one-line message and exits with status 1. A test keeps every code and text file under 256 KiB, images under 2 MB, and the folder at 512 files or fewer.
- The skill and the MCP tool descriptions say "tool" where they said "organ".
- The Claude plugin manifest adds the plugin directory listing fields: display name, keywords, homepage, repository, documentation, support, privacy and terms links, and a 1024 px icon. Plexus takes no launch settings, so the manifest declares no `userConfig`. Portable and Codex manifests are unchanged.
- The client README adds a data and network table derived from the code: Plexus reads built-in and caller-selected manifests, stores nothing, makes no network call and sends no telemetry. Stale binding instructions are removed.
- The client README and PRIVACY.md add a "What this plugin runs and handles" section covering hooks, the exact launch command, network, files and environment variables. A test fails if the served package starts importing a network or process module or reading the environment.
Expand Down
2 changes: 2 additions & 0 deletions client-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Discovery, wiring, plans and routes describe manifests. A declared edge does not
## Install
The source ZIP requires Python 3.11 or newer. Extract the entire archive, then point a local stdio MCP client at an absolute Python executable with arguments `-I -S -B server/serve.py` using the absolute script path. Plexus takes no path, account or grant at launch. The source package is an advanced installation, not self-contained.

The plugin folder carries its own copy of the server code in `server/src`, so a directory install needs nothing from the rest of the repository. If that copy is missing, the server prints "plexus: the server code is missing from the plugin folder. Reinstall the plugin." and exits.

In Claude Code, enabling the plugin asks for nothing. Plexus needs no settings, so the Claude manifest declares no `userConfig` and launches `python3 -I -S -B ${CLAUDE_PLUGIN_ROOT}/server/serve.py` with no further arguments. Replace `python3` with a trusted absolute Python path where necessary.

The Windows x64 native ZIP includes Python and needs no separate Python or Node installation. Extract everything and use the absolute `server/plexus-local.exe` path with no arguments. A client supporting binary MCPB extensions may open the matching MCPB, which has no settings to enter. Both archives use identical executable bytes.
Expand Down
6 changes: 5 additions & 1 deletion client-plugin/server/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@

TOOL = 'plexus'
if not getattr(sys, 'frozen', False):
sys.path.insert(0, str(Path(__file__).resolve().parent / 'src'))
source = Path(__file__).resolve().parent / 'src'
if not (source / TOOL / 'mcp.py').is_file():
sys.stderr.write(f'{TOOL}: the server code is missing from the plugin folder. Reinstall the plugin.\n')
raise SystemExit(1)
sys.path.insert(0, str(source))


def explicit_path(value, *, directory=False):
Expand Down
27 changes: 27 additions & 0 deletions client-plugin/server/src/plexus/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""plexus: capability discovery + auto-wiring for agent toolchains.

Point it at a set of tools that ship interop manifests and declarative discovery
wires producer to consumer into a pipeline, every edge tagged `declared` and
carrying the module its producer names. Discovery does not import or run cited
tools. Optional probe helpers in `plexus.registry` launch owned MCP servers only
when explicitly called. Zero runtime dependencies.
"""
from .graph import to_dot, to_mermaid
from .manifest import Manifest, Port, validate
from .mesh import Edge, Mesh, discover
from .plan import plan_to, route
from .receipt import build_plan_receipt, verify_plan
from .registry import builtin_manifests, load_dir
from .run import pipeline_script

__version__ = "0.3.0"

__all__ = [
"Manifest", "Port", "validate",
"Mesh", "Edge", "discover",
"plan_to", "route",
"build_plan_receipt", "verify_plan",
"to_mermaid", "to_dot", "pipeline_script",
"builtin_manifests", "load_dir",
"__version__",
]
4 changes: 4 additions & 0 deletions client-plugin/server/src/plexus/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from plexus.cli import main

if __name__ == "__main__":
raise SystemExit(main())
139 changes: 139 additions & 0 deletions client-plugin/server/src/plexus/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""cli.py — plexus command line.

plexus discover [--builtin] [--dir DIR] # the mesh: organs, edges, orphans
plexus wiring [...] # capability -> (producer, consumer) pairs
plexus plan --goal ORGAN [...] # upstream pipeline that feeds ORGAN
plexus route --from A --to B [...] # capability path from A to B
plexus validate [...] # manifest problems (exit 1 if any)
"""
from __future__ import annotations

import argparse
import json
from datetime import datetime, timezone

from . import __version__
from .graph import to_dot, to_mermaid
from .manifest import content_hash, duplicate_organs, validate
from .mesh import discover
from .plan import plan_to, route
from .registry import builtin_manifests, export_all, load_dir
from .run import pipeline_script


def _load(args) -> list:
mans = list(builtin_manifests()) if (args.builtin or not args.dir) else []
if args.dir:
mans += load_dir(args.dir)
return mans


def _receipt(mesh) -> dict:
"""A re-runnable provenance receipt for the mesh: which manifests were read,
from where, and a content hash a stranger can recompute from the same bytes."""
return {
"plexus_version": __version__,
"generated_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"manifests": [{"organ": m.organ, "source": m.source,
"sha256": content_hash(m)}
for m in sorted(mesh.manifests.values(), key=lambda m: m.organ)],
}


def _mesh_json(mesh) -> dict:
return {
"organs": mesh.organs,
"edges": [{"producer": e.producer, "consumer": e.consumer,
"capability": e.capability, "self_loop": e.self_loop,
"via": e.producer_module, "evidence": e.evidence}
for e in mesh.edges],
"orphans": mesh.orphans(),
"collisions": mesh.collisions,
"receipt": _receipt(mesh),
}


def _add_source_flags(p):
p.add_argument("--builtin", action="store_true", help="include the built-in flagship manifests")
p.add_argument("--dir", default="", help="load *.interop.json manifests from this directory")


def main(argv: "list[str] | None" = None) -> int:
ap = argparse.ArgumentParser(prog="plexus", description=__doc__)
ap.add_argument("--version", action="version", version=f"plexus {__version__}")
sub = ap.add_subparsers(dest="cmd", required=True)

for name in ("discover", "wiring", "validate"):
sp = sub.add_parser(name)
_add_source_flags(sp)
pp = sub.add_parser("plan"); _add_source_flags(pp)
pp.add_argument("--goal", required=True, help="organ you want to feed")
rp = sub.add_parser("route"); _add_source_flags(rp)
rp.add_argument("--from", dest="src", required=True)
rp.add_argument("--to", dest="dst", required=True)
vp = sub.add_parser("verify"); _add_source_flags(vp)
vp.add_argument("--plan", required=True,
help="a plan/route JSON file (from `plexus plan`/`route`) to re-derive "
"and re-verify against the current mesh")
gp = sub.add_parser("graph"); _add_source_flags(gp)
gp.add_argument("--format", default="mermaid", choices=["mermaid", "dot"])
up = sub.add_parser("run"); _add_source_flags(up)
up.add_argument("--goal", required=True, help="organ you want to feed")
ep = sub.add_parser("export")
ep.add_argument("--dir", default="manifests", help="write <organ>.interop.json files here")
sub.add_parser("mcp") # stdio MCP server: discover/wiring/plan/route as tools

args = ap.parse_args(argv)

if args.cmd == "mcp":
from .mcp import serve
return serve()
if args.cmd == "export":
print("\n".join(export_all(args.dir)))
return 0

mans = _load(args)

if args.cmd == "discover":
print(json.dumps(_mesh_json(discover(mans)), indent=2))
return 0
if args.cmd == "wiring":
print(json.dumps(discover(mans).wiring(), indent=2))
return 0
if args.cmd == "plan":
print(json.dumps(plan_to(discover(mans), args.goal), indent=2))
return 0
if args.cmd == "route":
print(json.dumps(route(discover(mans), args.src, args.dst), indent=2))
return 0
if args.cmd == "verify":
from .receipt import verify_plan
with open(args.plan, encoding="utf-8") as f:
plan = json.load(f)
ok = verify_plan(plan, discover(mans))
print(json.dumps({"verified": ok, "target": plan.get("target"),
"source": plan.get("source")}, indent=2))
return 0 if ok else 1
if args.cmd == "graph":
mesh = discover(mans)
print(to_mermaid(mesh) if args.format == "mermaid" else to_dot(mesh))
return 0
if args.cmd == "run":
print(pipeline_script(discover(mans), args.goal))
return 0
if args.cmd == "validate":
problems = {m.organ: validate(m) for m in mans}
report = {k: v for k, v in problems.items() if v}
dups = duplicate_organs(mans)
if dups:
# A set-level clash the per-organ dict above cannot show (its own key
# collapses the duplicates); name it so a collision can never launder
# to {"ok": true}.
report["duplicate_organs"] = dups
print(json.dumps(report or {"ok": True}, indent=2))
return 1 if report else 0
return 2


if __name__ == "__main__":
raise SystemExit(main())
38 changes: 38 additions & 0 deletions client-plugin/server/src/plexus/graph.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""graph.py — render the mesh as a diagram (Mermaid / Graphviz DOT).

Discovery is only useful if you can see it. This turns the mesh into a picture:
organs as nodes, capability-labeled edges as producer -> consumer arrows, with
self-loops and the cross-tool wiring visible at a glance. Mermaid renders in any
Markdown viewer; DOT feeds Graphviz. Pure string output, zero deps.
"""
from __future__ import annotations

from .mesh import Mesh


def _san(node: str) -> str:
"""A Mermaid/DOT-safe node id (organs are simple slugs already, but be safe)."""
return "".join(c if c.isalnum() else "_" for c in node)


def to_mermaid(mesh: Mesh, *, direction: str = "LR") -> str:
"""A Mermaid flowchart. Cross-tool edges are solid; self-loops dotted."""
lines = [f"flowchart {direction}"]
for organ in mesh.organs:
lines.append(f' {_san(organ)}["{organ}"]')
for e in mesh.edges:
arrow = "-.->" if e.self_loop else "-->"
lines.append(f' {_san(e.producer)} {arrow}|"{e.capability}"| {_san(e.consumer)}')
return "\n".join(lines)


def to_dot(mesh: Mesh) -> str:
"""A Graphviz DOT digraph."""
lines = ["digraph plexus {", ' rankdir=LR;', ' node [shape=box];']
for organ in mesh.organs:
lines.append(f' "{organ}";')
for e in mesh.edges:
attrs = f'label="{e.capability}"' + (", style=dotted" if e.self_loop else "")
lines.append(f' "{e.producer}" -> "{e.consumer}" [{attrs}];')
lines.append("}")
return "\n".join(lines)
117 changes: 117 additions & 0 deletions client-plugin/server/src/plexus/manifest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""manifest.py — what a tool declares about how it plugs into others.

A manifest is a tool's interop contract: the capabilities it EMITS (re-checkable
artifacts another tool could consume) and the capabilities it CONSUMES (inputs it
accepts). A capability is a stable string: a schema id like
"mneme.crucible-export/2" or a shared-spine kind like
"project-telos.flagship-action/v1". Matching is by capability string, so an edge
forms when a producer DECLARES a capability the consumer DECLARES it accepts.

Every port carries `module` (a file:function pointer) the producer names as the
source. plexus does not import, resolve, or run that pointer, so the mesh is
DECLARED, not probed: the module is a self-reported citation to follow, not a
verified receipt. A manifest is a plain dict/JSON, so any tool in any language
can ship one; nothing here imports the tools it describes.
"""
from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass, field


@dataclass(frozen=True)
class Port:
"""One capability a tool emits or consumes."""
capability: str # the match key (schema id or shared kind)
title: str = "" # human label
module: str = "" # evidence: repo-relative file:function
summary: str = ""
consumable_as: tuple = () # extra capabilities an EMIT also satisfies
# (e.g. mneme.crucible-export/2 -> crucible.thesis)

def satisfies(self, capability: str) -> bool:
return capability == self.capability or capability in self.consumable_as


@dataclass
class Manifest:
organ: str
invoke: dict = field(default_factory=dict) # {cli, mcp_server, python_import}
emits: list = field(default_factory=list) # list[Port]
consumes: list = field(default_factory=list) # list[Port]
evidence: list = field(default_factory=list) # file paths the manifest was grounded on
source: str = "" # provenance: "builtin:registry" or the file path read

@staticmethod
def from_dict(d: dict) -> "Manifest":
def ports(key):
out = []
for p in d.get(key, []):
if isinstance(p, str):
out.append(Port(capability=p))
elif isinstance(p, dict):
out.append(Port(
capability=p.get("capability", ""), title=p.get("title", ""),
module=p.get("module", ""), summary=p.get("summary", ""),
consumable_as=tuple(p.get("consumable_as", ()))))
return out
# Tolerant by contract: a missing organ or capability becomes an empty
# string that validate() reports; from_dict never raises on malformed input.
return Manifest(organ=d.get("organ", ""), invoke=d.get("invoke", {}),
emits=ports("emits"), consumes=ports("consumes"),
evidence=list(d.get("evidence", [])))

def to_dict(self) -> dict:
def dump(ports):
return [{"capability": p.capability, "title": p.title, "module": p.module,
"summary": p.summary, "consumable_as": list(p.consumable_as)}
for p in ports]
return {"organ": self.organ, "invoke": self.invoke,
"emits": dump(self.emits), "consumes": dump(self.consumes),
"evidence": self.evidence}


def content_hash(m) -> str:
"""sha256 over the manifest's canonical content (its to_dict, sorted keys).
Source is deliberately excluded, so the hash binds WHAT was declared, not
where it was read; a stranger can recompute it from the same bytes."""
canonical = json.dumps(m.to_dict(), sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()


def duplicate_organs(manifests: list) -> list:
"""Organ ids declared by more than one manifest in the set, sorted unique.
A set-level check: identity is self-declared, so a collision must be NAMED,
never silently resolved last-writer-wins."""
seen, dups = set(), set()
for m in manifests:
if m.organ in seen:
dups.add(m.organ)
seen.add(m.organ)
return sorted(dups)


def validate(m) -> list:
"""Problems with a manifest (empty list == valid). A validator reports
malformed input; it never raises on it."""
issues = []
if not isinstance(m, Manifest):
return ["not a Manifest"]
if not m.organ or not isinstance(m.organ, str):
issues.append("organ id missing or not a string")
seen = set()
for side in ("emits", "consumes"):
for i, p in enumerate(getattr(m, side)):
if not isinstance(p, Port) or not p.capability:
issues.append(f"{side}[{i}] has no capability")
continue
if side == "emits" and not p.module:
# The producing module is the evidence the "cites its module"
# claim rests on; an emit without it is a receipt-less claim.
issues.append(f"emits[{i}] {p.capability!r} has no module evidence")
key = (side, p.capability)
if key in seen:
issues.append(f"{side} declares {p.capability!r} twice")
seen.add(key)
return issues
Loading
Loading