system-diff 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
1
+ """`system-diff mcp`: system-diff as an MCP server over stdio, for Claude Code and other MCP clients.
2
+
3
+ An optional extra (`pip install "system-diff[mcp]"`): the official MCP SDK is its only dependency. The tools read the
4
+ repository the client runs in, and write pictures under .git/system-diff/, which is never committed. They return the
5
+ exact text (headline, plan) for the model to explain from, and the picture's path for the person.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Literal, Optional, Union
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field
13
+
14
+ try: # the MCP SDK's 2.x name; 1.x called it FastMCP, with the same constructor, decorator and run()
15
+ from mcp.server.mcpserver import MCPServer as Server
16
+ except ImportError: # pragma: no cover - mcp 1.x
17
+ from mcp.server.fastmcp import FastMCP as Server
18
+
19
+ from . import compare as cmp
20
+ from . import config, graph, pictures
21
+ from .extract import extract
22
+ from .sources import GitSource, git, repo_root
23
+
24
+ INSTRUCTIONS = (
25
+ "system-diff shows what a change does to a system's structure: its parts, entry points, connections, outside "
26
+ "systems and stored data. Use system_diff after changing the code, to tell the person in plain words what changed "
27
+ "in the system and to point them at the picture; base 'checkpoint' is what they last marked as seen. Use "
28
+ "system_map for the whole system, and draw for a picture of something else (a process, a sequence). Call "
29
+ "checkpoint only when the person says they have seen and understood the current state."
30
+ )
31
+
32
+
33
+ class Code(BaseModel):
34
+ code: str = Field(description="shown in a monospace font, e.g. a route or a table")
35
+
36
+
37
+ Line = Union[str, Code]
38
+
39
+
40
+ class _Spec(BaseModel):
41
+ model_config = ConfigDict(populate_by_name=True, extra="forbid")
42
+
43
+
44
+ class Node(_Spec):
45
+ id: str
46
+ col: str = Field(description="the key of one of `columns`")
47
+ title: str
48
+ sub: str = ""
49
+ type: Literal["component", "store", "host"] = "component"
50
+ style: Literal["", "new", "legacy", "shared", "undeclared"] = ""
51
+ lines: List[Line] = []
52
+
53
+
54
+ class Edge(_Spec):
55
+ from_: str = Field(alias="from")
56
+ to: str
57
+ kind: Literal["call", "data", "network"] = "call"
58
+
59
+
60
+ class Stage(_Spec):
61
+ title: str
62
+ lines: List[Line] = []
63
+ hi: bool = Field(False, description="highlight this stage")
64
+
65
+
66
+ class Loop(_Spec):
67
+ from_: int = Field(alias="from", description="index of the stage it goes back from")
68
+ to: int = Field(description="index of the stage it goes back to")
69
+ label: str = ""
70
+
71
+
72
+ class Exit(_Spec):
73
+ label: str
74
+ note: str = ""
75
+
76
+
77
+ class Gate(_Spec):
78
+ text: str = Field(description="where a person comes in, e.g. 'A person approves'")
79
+ from_: Optional[List[int]] = Field(None, alias="from", description="indexes of the stages that lead to it")
80
+
81
+
82
+ class Lane(_Spec):
83
+ id: str
84
+ title: str
85
+ sub: str = ""
86
+ person: bool = False
87
+
88
+
89
+ class Step(_Spec):
90
+ from_: Optional[str] = Field(None, alias="from", description="a lane id (a message step)")
91
+ to: Optional[str] = Field(None, description="a lane id (a message step)")
92
+ text: Optional[str] = Field(None, description="what is sent (a message step)")
93
+ kind: Optional[Literal["call", "data", "network"]] = None
94
+ note: Optional[str] = Field(None, description="a note across lanes instead of a message")
95
+ over: Optional[List[str]] = Field(None, description="the lane ids a note spans")
96
+
97
+
98
+ class Spec(_Spec):
99
+ """One picture. `map` needs columns and nodes (edges optional); `stages` needs stages; `sequence` needs lanes
100
+ and steps."""
101
+ picture: Literal["map", "stages", "sequence"]
102
+ label: str = ""
103
+ columns: Optional[List[List[str]]] = Field(None, description='map: [["logic", "Logic"], ["store", "Stored data"]]')
104
+ nodes: Optional[List[Node]] = None
105
+ edges: Optional[List[Edge]] = None
106
+ stages: Optional[List[Stage]] = None
107
+ loops: Optional[List[Loop]] = None
108
+ exits: Optional[List[Exit]] = None
109
+ gate: Optional[Gate] = None
110
+ lanes: Optional[List[Lane]] = None
111
+ steps: Optional[List[Step]] = None
112
+
113
+
114
+ def _out(root: Path, *parts: str) -> Path:
115
+ """A folder under .git/system-diff (the repository's own git dir, worktrees included)."""
116
+ gitdir = Path(git("rev-parse", "--absolute-git-dir", cwd=root).strip())
117
+ out = gitdir.joinpath("system-diff", *parts)
118
+ out.mkdir(parents=True, exist_ok=True)
119
+ return out
120
+
121
+
122
+ def tool_map(root: Path, ref: str = ".") -> Dict[str, Any]:
123
+ sha, label = cmp.resolve(root, ref)
124
+ source = GitSource(sha, root)
125
+ cfg = config.load(source, root)
126
+ bp = extract(source, cfg)
127
+ path = _out(root, "mcp") / "system.svg"
128
+ pictures.write_if_changed(path, graph.system_svg(bp, cfg))
129
+ comps = bp["components"]
130
+ return {"of": label, "picture": str(path),
131
+ "parts": [{"id": k, "label": v["label"], "column": graph.part_kind(k, v, bp, cfg), "files": v["files"]}
132
+ for k, v in sorted(comps.items(), key=lambda kv: graph.part_order(kv[1], kv[0]))],
133
+ "connections": [f"{comps.get(s, {}).get('label', s)} → {comps.get(t, {}).get('label', t)}"
134
+ for s, t in (k.split(">") for k in bp["flows"])],
135
+ "entry_points": len(bp["routes"]), "tables": len(bp["tables"]),
136
+ "outside_systems": [v["label"] for v in bp["externals"].values()],
137
+ "read_but_not_deployed": [v["label"] for v in bp["dormant"].values()],
138
+ "undeclared_parts": sorted(k for k, v in comps.items() if not v["declared"])}
139
+
140
+
141
+ def tool_diff(root: Path, base: str = "checkpoint", head: str = ".") -> Dict[str, Any]:
142
+ c = cmp.compare(root, base, head)
143
+ svg = graph.diff_svg(c.diff, c.before, c.after, c.cfg)
144
+ path = _out(root, "mcp") / "diff.svg"
145
+ if svg:
146
+ pictures.write_if_changed(path, svg)
147
+ elif path.is_file():
148
+ path.unlink()
149
+ return {"from": c.base, "to": c.head, "headline": c.diff["headline"], "plan": c.diff["plan"],
150
+ "changes": len(c.diff["changes"]), "impacts": c.diff["impacts"],
151
+ "picture": str(path) if svg else None,
152
+ "live_page": "run `system-diff watch` for a page that redraws as the code changes"}
153
+
154
+
155
+ def tool_draw(root: Path, spec: Spec, name: str = "picture") -> Dict[str, Any]:
156
+ data = spec.model_dump(by_alias=True, exclude_none=True)
157
+ problems = pictures.check(data)
158
+ if problems:
159
+ return {"error": "the spec could not be drawn: " + "; ".join(problems), "do": "fix these and draw again"}
160
+ try:
161
+ svg = pictures.draw(data)
162
+ except (KeyError, IndexError, TypeError, ValueError) as exc:
163
+ return {"error": f"the spec could not be drawn: {type(exc).__name__}: {exc}",
164
+ "do": "check that every edge, loop, gate and step names an id or index the spec has"}
165
+ safe = "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in name)[:60] or "picture"
166
+ path = _out(root, "pictures") / f"{safe}.svg"
167
+ pictures.write_if_changed(path, svg)
168
+ return {"picture": str(path)}
169
+
170
+
171
+ def build() -> Server:
172
+ server = Server("system-diff", instructions=INSTRUCTIONS)
173
+
174
+ @server.tool()
175
+ def system_map(ref: str = ".") -> Dict[str, Any]:
176
+ """The system's structure: its parts by column, the connections between them, how many entry points and
177
+ tables, and its outside systems. `ref`: a commit, or "." for the working tree. Returns the picture's path."""
178
+ return tool_map(repo_root(), ref)
179
+
180
+ @server.tool()
181
+ def system_diff(base: str = "checkpoint", head: str = ".") -> Dict[str, Any]:
182
+ """What changed in the system from `base` to `head`: one sentence, the exact list (each line with its impact,
183
+ e.g. new-interface, breaking-interface, data-schema-change, test-gap) and a picture of only what changed.
184
+ `base` "checkpoint" is what the person last marked as seen (HEAD if nothing yet); `head` "." is the working
185
+ tree, uncommitted files included. Explain it from the plan, never beyond it."""
186
+ return tool_diff(repo_root(), base, head)
187
+
188
+ @server.tool()
189
+ def draw(spec: Spec, name: str = "picture") -> Dict[str, Any]:
190
+ """Draw a picture in system-diff's look: a `map` (boxes in columns), `stages` (a process left to right, with
191
+ loops back, exits and where a person comes in) or a `sequence` (who talks to whom, in order). Returns the
192
+ SVG's path, or what is wrong with the spec."""
193
+ return tool_draw(repo_root(), spec, name)
194
+
195
+ @server.tool()
196
+ def checkpoint(ref: str = "") -> Dict[str, Any]:
197
+ """Mark the working tree (or `ref`) as seen by the person, so the next system_diff from "checkpoint" shows
198
+ only what is new to them. Only when the person says they have seen and understood the current state."""
199
+ sha = cmp.checkpoint(repo_root(), ref or None)
200
+ return {"checkpoint": sha[:9], "of": ref or "the working tree"}
201
+
202
+ return server
203
+
204
+
205
+ def main() -> None:
206
+ build().run("stdio")
system_diff/narrate.py ADDED
@@ -0,0 +1,141 @@
1
+ """The optional plain-words summary on top of the exact diff, written by an LLM; the pictures never depend on it.
2
+
3
+ Which LLM: the one your Claude Code uses (its settings files' `env` blocks, layered as Claude Code layers them, with
4
+ ANTHROPIC_* in the environment over all of them); else an OpenAI-compatible gateway exported in your own shell
5
+ (SYSTEM_DIFF_LLM_BASE_URL / _API_KEY / _MODEL, or LLM_*). Only the headline, the exact plan and the commit messages are
6
+ sent: never the code. Nothing answers, no summary.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ import socket
14
+ import ssl
15
+ import sys
16
+ import urllib.error
17
+ import urllib.request
18
+ from pathlib import Path
19
+ from typing import Any, Dict, List, Optional, Tuple
20
+ from urllib.parse import urlparse
21
+
22
+ DEFAULT_MODEL = "claude-sonnet-5-5"
23
+
24
+ SYSTEM = (
25
+ "You explain to the reviewers of a pull request what it does to a system's architecture. You are given an "
26
+ "exact, machine-extracted structural diff and the commit messages. Write two parts, plain words, no headings: "
27
+ "1) two to four sentences on what changes in the system and why; 2) up to four bullet points a reviewer should "
28
+ "check, each grounded in a line of the diff. Never mention a component, endpoint, table or system that is not "
29
+ "in the diff. If the diff shows no structural change, say so in one sentence."
30
+ )
31
+
32
+
33
+ def claude_settings_files(root: Path, extra: List[str] = ()) -> List[Tuple[Path, str]]:
34
+ """Claude Code settings that can carry the gateway, lowest precedence first: user, user-local, project,
35
+ project-local, then any the config adds."""
36
+ home, repo = Path.home() / ".claude", root / ".claude"
37
+ files = [(home / "settings.json", "~/.claude/settings.json"),
38
+ (home / "settings.local.json", "~/.claude/settings.local.json"),
39
+ (repo / "settings.json", ".claude/settings.json"),
40
+ (repo / "settings.local.json", ".claude/settings.local.json")]
41
+ return files + [(Path(os.path.expanduser(p)), p) for p in extra]
42
+
43
+
44
+ def claude_llm(root: Path, extra: List[str] = ()) -> Optional[Dict[str, Any]]:
45
+ """The gateway your Claude Code uses; whatever model it names is the model used."""
46
+ env: Dict[str, str] = {}
47
+ source = ""
48
+ for path, label in claude_settings_files(root, extra):
49
+ try:
50
+ block = json.loads(path.read_text(encoding="utf-8")).get("env", {}) or {}
51
+ except (OSError, ValueError):
52
+ continue
53
+ env.update(block)
54
+ if block.get("ANTHROPIC_BASE_URL") or block.get("ANTHROPIC_API_KEY"):
55
+ source = label
56
+ for k, v in os.environ.items():
57
+ if k.startswith("ANTHROPIC_") and v:
58
+ env[k] = v
59
+ if k in ("ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"):
60
+ source = "ANTHROPIC_* in the environment"
61
+ token = env.get("ANTHROPIC_AUTH_TOKEN") or env.get("ANTHROPIC_API_KEY", "")
62
+ if not token:
63
+ return None
64
+ base = (env.get("ANTHROPIC_BASE_URL") or "https://api.anthropic.com").rstrip("/")
65
+ return {"base_url": base, "token": token, "model": env.get("ANTHROPIC_MODEL", DEFAULT_MODEL), "source": source}
66
+
67
+
68
+ def shell_llm() -> Optional[Dict[str, Any]]:
69
+ """An OpenAI-compatible LLM someone exported in their own shell on purpose — never a committed file."""
70
+ env = os.environ
71
+ base = env.get("SYSTEM_DIFF_LLM_BASE_URL") or env.get("LLM_BASE_URL", "")
72
+ key = env.get("SYSTEM_DIFF_LLM_API_KEY") or env.get("LLM_API_KEY", "")
73
+ model = env.get("SYSTEM_DIFF_LLM_MODEL") or env.get("LLM_MODEL", "")
74
+ if base and key and model:
75
+ return {"kind": "openai", "base_url": base.rstrip("/"), "token": key, "model": model,
76
+ "verify": env.get("LLM_SSL_VERIFY", "true").lower() == "true"}
77
+ return None
78
+
79
+
80
+ def ask(s: Dict[str, Any], user: str, max_tokens: int, timeout: int) -> str:
81
+ """One tool-less completion, OpenAI-compatible or Anthropic Messages."""
82
+ if s.get("kind") == "openai":
83
+ url = s["base_url"] + "/chat/completions"
84
+ body: Dict[str, Any] = {"model": s["model"], "max_tokens": max_tokens,
85
+ "messages": [{"role": "system", "content": SYSTEM}, {"role": "user", "content": user}]}
86
+ headers = {"content-type": "application/json", "authorization": "Bearer " + s["token"]}
87
+ else:
88
+ url = s["base_url"] + "/v1/messages"
89
+ body = {"model": s["model"], "max_tokens": max_tokens, "system": SYSTEM,
90
+ "messages": [{"role": "user", "content": user}]}
91
+ if s.get("thinking"):
92
+ # A reasoning model given no thinking budget can think until max_tokens and return no text at all.
93
+ body["thinking"] = {"type": "enabled", "budget_tokens": s["thinking"]}
94
+ headers = {"content-type": "application/json", "anthropic-version": "2023-06-01",
95
+ "authorization": "Bearer " + s["token"], "x-api-key": s["token"]}
96
+ # Fail in seconds, not after the read timeout, when this machine cannot reach the gateway at all.
97
+ u = urlparse(url)
98
+ socket.create_connection((u.hostname, u.port or (443 if u.scheme == "https" else 80)), timeout=5).close()
99
+ context = None if s.get("verify", True) else ssl._create_unverified_context()
100
+ req = urllib.request.Request(url, data=json.dumps(body).encode(), method="POST", headers=headers)
101
+ with urllib.request.urlopen(req, timeout=timeout, context=context) as r:
102
+ data = json.loads(r.read())
103
+ if s.get("kind") == "openai":
104
+ text = ((data.get("choices") or [{}])[0].get("message") or {}).get("content") or ""
105
+ else:
106
+ text = "".join(b.get("text", "") for b in data.get("content", []) if b.get("type") == "text")
107
+ return re.sub(r"<think>.*?</think>", "", text, flags=re.S).strip()
108
+
109
+
110
+ def narrate(d: dict, commits: str, root: Path, cfg: Dict[str, Any], timeout: int = 120) -> Optional[str]:
111
+ """The summary, naming the model that wrote it; None when no LLM answers from here."""
112
+ user = f"Headline: {d['headline']}\n\nStructural diff (exact):\n{d['plan']}\n\nCommit messages:\n{commits[:4000]}"
113
+ claude = claude_llm(root, cfg.get("narrate", {}).get("settings", []))
114
+ if claude:
115
+ # With a thinking budget first (some gateways' reasoning models need one); without it if refused.
116
+ for attempt in ({**claude, "thinking": 4000}, claude):
117
+ try:
118
+ text = ask(attempt, user, 12000, timeout)
119
+ except urllib.error.HTTPError as exc:
120
+ if exc.code == 400 and attempt.get("thinking"):
121
+ continue # this model or gateway takes no thinking parameter
122
+ print(f"system-diff: {claude['model']} via {claude['source']}: HTTP {exc.code}", file=sys.stderr)
123
+ break
124
+ except (OSError, ValueError) as exc: # not reachable from here, or not JSON
125
+ print(f"system-diff: {claude['model']} via {claude['source']}: {type(exc).__name__}: {str(exc)[:120]}",
126
+ file=sys.stderr)
127
+ break
128
+ if text:
129
+ return f"{text[:1500]}\n\n_Summary written by {claude['model']} ({claude['source']}) from the exact diff below._"
130
+ shell = shell_llm()
131
+ if shell:
132
+ for tokens in (2500, 12000):
133
+ try:
134
+ text = ask(shell, user, tokens, timeout)
135
+ except (OSError, ValueError) as exc:
136
+ print(f"system-diff: {shell['model']} via {shell['base_url']}: {type(exc).__name__}: {str(exc)[:120]}",
137
+ file=sys.stderr)
138
+ break
139
+ if text:
140
+ return f"{text[:1500]}\n\n_Summary written by {shell['model']} (an LLM set in this shell) from the exact diff below._"
141
+ return None