uni-diff-patch 0.0.1__tar.gz

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,194 @@
1
+ Metadata-Version: 2.3
2
+ Name: uni-diff-patch
3
+ Version: 0.0.1
4
+ Summary: MCP server and CLI tool for generating valid unified diff patches deterministically
5
+ Author: LimLLL
6
+ Author-email: LimLLL <github@awebapp.useforall.com>
7
+ Requires-Dist: mpatch
8
+ Requires-Dist: mcp[cli]
9
+ Requires-Dist: click
10
+ Requires-Dist: pydantic>=2.13.4
11
+ Requires-Python: >=3.12
12
+ Description-Content-Type: text/markdown
13
+
14
+ # uni-diff-patch
15
+
16
+ MCP server + CLI tool for generating valid unified diff patches deterministically.
17
+
18
+ LLM provides content or search/replace pairs, the tool computes correct diffs via [mpatch](https://github.com/Romelium/mpatch) (Rust) — no more broken hunks, no more `git apply` failures.
19
+
20
+ ---
21
+
22
+ MCP 服务器 + CLI 工具,确定性生成合法的 unified diff patch。
23
+
24
+ LLM 只需提供文件内容或搜索替换对,工具通过 [mpatch](https://github.com/Romelium/mpatch) (Rust) 计算正确的 diff — 不再有坏掉的 hunk,`git apply` 不再失败。
25
+
26
+ ## Install / 安装
27
+
28
+ ```bash
29
+ uv add uni-diff-patch # as dependency / 作为依赖
30
+ uvx uni-diff-patch --help # run directly / 直接运行
31
+ ```
32
+
33
+ ## CLI / 命令行
34
+
35
+ ```bash
36
+ # Generate patch from JSON spec / 从 JSON spec 生成 patch
37
+ uni-diff-patch generate --spec changes.json -o output.diff
38
+
39
+ # Generate with work_dir (for absolute paths) / 指定工作目录(用于绝对路径)
40
+ uni-diff-patch generate --spec changes.json --work-dir /path/to/project
41
+
42
+ # Validate a patch / 验证 patch
43
+ uni-diff-patch validate output.diff
44
+ uni-diff-patch validate output.diff --git-check --work-dir /path/to/repo
45
+
46
+ # Start MCP server / 启动 MCP 服务器
47
+ uni-diff-patch serve
48
+ ```
49
+
50
+ ## MCP Configuration / MCP 配置
51
+
52
+ ```json
53
+ {
54
+ "mcpServers": {
55
+ "uni-diff-patch": {
56
+ "command": "uvx",
57
+ "args": ["uni-diff-patch", "serve"]
58
+ }
59
+ }
60
+ }
61
+ ```
62
+
63
+ ## JSON Spec Format / JSON Spec 格式
64
+
65
+ All modes are specified in a `changes` array. Each change requires `file_path` and `mode`.
66
+
67
+ 所有模式通过 `changes` 数组指定,每个变更需要 `file_path` 和 `mode`。
68
+
69
+ ### file_content — Full new content / 完整新内容
70
+
71
+ Tool reads original from disk and diffs. / 工具从磁盘读取原文件并生成 diff。
72
+
73
+ ```json
74
+ {
75
+ "changes": [{
76
+ "file_path": "src/main.py",
77
+ "mode": "file_content",
78
+ "new_content": "def main():\n print('hello')\n"
79
+ }]
80
+ }
81
+ ```
82
+
83
+ ### search_replace — Targeted replacements / 精准替换(最省 token)
84
+
85
+ ```json
86
+ {
87
+ "changes": [{
88
+ "file_path": "src/main.py",
89
+ "mode": "search_replace",
90
+ "replacements": [
91
+ {"search": "print('hello')", "replace": "print('world')"},
92
+ {"search": "old_func()", "replace": "new_func()", "line_hint": 42}
93
+ ]
94
+ }]
95
+ }
96
+ ```
97
+
98
+ `line_hint`: approximate line number for disambiguation when `search` matches multiple locations.
99
+
100
+ `line_hint`:当 `search` 匹配多处时,用大致行号消歧(选最近的匹配)。
101
+
102
+ ### create_file — New file / 新建文件
103
+
104
+ ```json
105
+ {
106
+ "changes": [{
107
+ "file_path": "src/new_module.py",
108
+ "mode": "create_file",
109
+ "new_content": "# New module\ndef init():\n pass\n"
110
+ }]
111
+ }
112
+ ```
113
+
114
+ ### delete_file — Remove file / 删除文件
115
+
116
+ ```json
117
+ {
118
+ "changes": [{
119
+ "file_path": "src/deprecated.py",
120
+ "mode": "delete_file"
121
+ }]
122
+ }
123
+ ```
124
+
125
+ ### text_pair — Both old and new text / 直接传入新旧文本(不读磁盘)
126
+
127
+ ```json
128
+ {
129
+ "changes": [{
130
+ "file_path": "virtual/path.py",
131
+ "mode": "text_pair",
132
+ "old_text": "x = 1\n",
133
+ "new_text": "x = 2\n"
134
+ }]
135
+ }
136
+ ```
137
+
138
+ ### rename_file — Rename/move / 重命名/移动文件
139
+
140
+ Optionally with content change. / 可同时修改内容。
141
+
142
+ ```json
143
+ {
144
+ "changes": [{
145
+ "file_path": "src/old_name.py",
146
+ "mode": "rename_file",
147
+ "new_path": "src/new_name.py",
148
+ "new_content": "# Optional modified content\n"
149
+ }]
150
+ }
151
+ ```
152
+
153
+ ### chmod — Permission change / 权限变更
154
+
155
+ Optionally with content change. / 可同时修改内容。
156
+
157
+ ```json
158
+ {
159
+ "changes": [{
160
+ "file_path": "scripts/deploy.sh",
161
+ "mode": "chmod",
162
+ "old_mode": "100644",
163
+ "new_mode": "100755"
164
+ }]
165
+ }
166
+ ```
167
+
168
+ ### Multi-file patch / 多文件合并 patch
169
+
170
+ ```json
171
+ {
172
+ "changes": [
173
+ {"file_path": "src/a.py", "mode": "file_content", "new_content": "..."},
174
+ {"file_path": "src/b.py", "mode": "search_replace", "replacements": [{"search": "old", "replace": "new"}]},
175
+ {"file_path": "src/c.py", "mode": "create_file", "new_content": "..."}
176
+ ],
177
+ "output_path": "changes.diff"
178
+ }
179
+ ```
180
+
181
+ ## Optional Fields / 可选字段
182
+
183
+ | Field / 字段 | Default / 默认值 | Description / 说明 |
184
+ |-------|---------|------------|
185
+ | `encoding` | `"utf-8"` | File encoding for disk-read modes / 磁盘读取模式的文件编码 |
186
+ | `context_lines` | `3` | Context lines in diff / diff 中的上下文行数 |
187
+ | `output_path` | — | Top-level, writes patch to file / 顶层字段,将 patch 写入文件 |
188
+
189
+ ## Development / 开发
190
+
191
+ ```bash
192
+ uv sync
193
+ uv run pytest tests/ -v
194
+ ```
@@ -0,0 +1,181 @@
1
+ # uni-diff-patch
2
+
3
+ MCP server + CLI tool for generating valid unified diff patches deterministically.
4
+
5
+ LLM provides content or search/replace pairs, the tool computes correct diffs via [mpatch](https://github.com/Romelium/mpatch) (Rust) — no more broken hunks, no more `git apply` failures.
6
+
7
+ ---
8
+
9
+ MCP 服务器 + CLI 工具,确定性生成合法的 unified diff patch。
10
+
11
+ LLM 只需提供文件内容或搜索替换对,工具通过 [mpatch](https://github.com/Romelium/mpatch) (Rust) 计算正确的 diff — 不再有坏掉的 hunk,`git apply` 不再失败。
12
+
13
+ ## Install / 安装
14
+
15
+ ```bash
16
+ uv add uni-diff-patch # as dependency / 作为依赖
17
+ uvx uni-diff-patch --help # run directly / 直接运行
18
+ ```
19
+
20
+ ## CLI / 命令行
21
+
22
+ ```bash
23
+ # Generate patch from JSON spec / 从 JSON spec 生成 patch
24
+ uni-diff-patch generate --spec changes.json -o output.diff
25
+
26
+ # Generate with work_dir (for absolute paths) / 指定工作目录(用于绝对路径)
27
+ uni-diff-patch generate --spec changes.json --work-dir /path/to/project
28
+
29
+ # Validate a patch / 验证 patch
30
+ uni-diff-patch validate output.diff
31
+ uni-diff-patch validate output.diff --git-check --work-dir /path/to/repo
32
+
33
+ # Start MCP server / 启动 MCP 服务器
34
+ uni-diff-patch serve
35
+ ```
36
+
37
+ ## MCP Configuration / MCP 配置
38
+
39
+ ```json
40
+ {
41
+ "mcpServers": {
42
+ "uni-diff-patch": {
43
+ "command": "uvx",
44
+ "args": ["uni-diff-patch", "serve"]
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ ## JSON Spec Format / JSON Spec 格式
51
+
52
+ All modes are specified in a `changes` array. Each change requires `file_path` and `mode`.
53
+
54
+ 所有模式通过 `changes` 数组指定,每个变更需要 `file_path` 和 `mode`。
55
+
56
+ ### file_content — Full new content / 完整新内容
57
+
58
+ Tool reads original from disk and diffs. / 工具从磁盘读取原文件并生成 diff。
59
+
60
+ ```json
61
+ {
62
+ "changes": [{
63
+ "file_path": "src/main.py",
64
+ "mode": "file_content",
65
+ "new_content": "def main():\n print('hello')\n"
66
+ }]
67
+ }
68
+ ```
69
+
70
+ ### search_replace — Targeted replacements / 精准替换(最省 token)
71
+
72
+ ```json
73
+ {
74
+ "changes": [{
75
+ "file_path": "src/main.py",
76
+ "mode": "search_replace",
77
+ "replacements": [
78
+ {"search": "print('hello')", "replace": "print('world')"},
79
+ {"search": "old_func()", "replace": "new_func()", "line_hint": 42}
80
+ ]
81
+ }]
82
+ }
83
+ ```
84
+
85
+ `line_hint`: approximate line number for disambiguation when `search` matches multiple locations.
86
+
87
+ `line_hint`:当 `search` 匹配多处时,用大致行号消歧(选最近的匹配)。
88
+
89
+ ### create_file — New file / 新建文件
90
+
91
+ ```json
92
+ {
93
+ "changes": [{
94
+ "file_path": "src/new_module.py",
95
+ "mode": "create_file",
96
+ "new_content": "# New module\ndef init():\n pass\n"
97
+ }]
98
+ }
99
+ ```
100
+
101
+ ### delete_file — Remove file / 删除文件
102
+
103
+ ```json
104
+ {
105
+ "changes": [{
106
+ "file_path": "src/deprecated.py",
107
+ "mode": "delete_file"
108
+ }]
109
+ }
110
+ ```
111
+
112
+ ### text_pair — Both old and new text / 直接传入新旧文本(不读磁盘)
113
+
114
+ ```json
115
+ {
116
+ "changes": [{
117
+ "file_path": "virtual/path.py",
118
+ "mode": "text_pair",
119
+ "old_text": "x = 1\n",
120
+ "new_text": "x = 2\n"
121
+ }]
122
+ }
123
+ ```
124
+
125
+ ### rename_file — Rename/move / 重命名/移动文件
126
+
127
+ Optionally with content change. / 可同时修改内容。
128
+
129
+ ```json
130
+ {
131
+ "changes": [{
132
+ "file_path": "src/old_name.py",
133
+ "mode": "rename_file",
134
+ "new_path": "src/new_name.py",
135
+ "new_content": "# Optional modified content\n"
136
+ }]
137
+ }
138
+ ```
139
+
140
+ ### chmod — Permission change / 权限变更
141
+
142
+ Optionally with content change. / 可同时修改内容。
143
+
144
+ ```json
145
+ {
146
+ "changes": [{
147
+ "file_path": "scripts/deploy.sh",
148
+ "mode": "chmod",
149
+ "old_mode": "100644",
150
+ "new_mode": "100755"
151
+ }]
152
+ }
153
+ ```
154
+
155
+ ### Multi-file patch / 多文件合并 patch
156
+
157
+ ```json
158
+ {
159
+ "changes": [
160
+ {"file_path": "src/a.py", "mode": "file_content", "new_content": "..."},
161
+ {"file_path": "src/b.py", "mode": "search_replace", "replacements": [{"search": "old", "replace": "new"}]},
162
+ {"file_path": "src/c.py", "mode": "create_file", "new_content": "..."}
163
+ ],
164
+ "output_path": "changes.diff"
165
+ }
166
+ ```
167
+
168
+ ## Optional Fields / 可选字段
169
+
170
+ | Field / 字段 | Default / 默认值 | Description / 说明 |
171
+ |-------|---------|------------|
172
+ | `encoding` | `"utf-8"` | File encoding for disk-read modes / 磁盘读取模式的文件编码 |
173
+ | `context_lines` | `3` | Context lines in diff / diff 中的上下文行数 |
174
+ | `output_path` | — | Top-level, writes patch to file / 顶层字段,将 patch 写入文件 |
175
+
176
+ ## Development / 开发
177
+
178
+ ```bash
179
+ uv sync
180
+ uv run pytest tests/ -v
181
+ ```
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "uni-diff-patch"
3
+ version = "0.0.1"
4
+ description = "MCP server and CLI tool for generating valid unified diff patches deterministically"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "LimLLL", email = "github@awebapp.useforall.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "mpatch",
12
+ "mcp[cli]",
13
+ "click",
14
+ "pydantic>=2.13.4",
15
+ ]
16
+
17
+ [project.scripts]
18
+ uni-diff-patch = "uni_diff_patch.cli:main"
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.9.27,<0.10.0"]
22
+ build-backend = "uv_build"
23
+
24
+ [dependency-groups]
25
+ dev = [
26
+ "pytest>=9.1.1",
27
+ ]
@@ -0,0 +1,46 @@
1
+ """uni-diff-patch: MCP server and CLI for generating valid unified diff patches."""
2
+
3
+ from uni_diff_patch.core.diff_engine import (
4
+ DiffGenerationError,
5
+ GenerateResult,
6
+ generate_patch,
7
+ )
8
+ from uni_diff_patch.core.search_replace import (
9
+ AmbiguousMatchError,
10
+ NoMatchError,
11
+ Replacement,
12
+ SearchReplaceError,
13
+ )
14
+ from uni_diff_patch.core.validator import ValidationResult, validate_patch
15
+ from uni_diff_patch.models import (
16
+ ChmodChange,
17
+ CreateFileChange,
18
+ DeleteFileChange,
19
+ FileChange,
20
+ FileContentChange,
21
+ RenameFileChange,
22
+ ReplacementModel,
23
+ SearchReplaceChange,
24
+ TextPairChange,
25
+ )
26
+
27
+ __all__ = [
28
+ "ChmodChange",
29
+ "CreateFileChange",
30
+ "DeleteFileChange",
31
+ "DiffGenerationError",
32
+ "FileChange",
33
+ "FileContentChange",
34
+ "GenerateResult",
35
+ "RenameFileChange",
36
+ "ReplacementModel",
37
+ "SearchReplaceChange",
38
+ "TextPairChange",
39
+ "generate_patch",
40
+ "validate_patch",
41
+ "ValidationResult",
42
+ "Replacement",
43
+ "SearchReplaceError",
44
+ "NoMatchError",
45
+ "AmbiguousMatchError",
46
+ ]
@@ -0,0 +1,178 @@
1
+ """CLI entry point for uni-diff-patch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ import click
9
+
10
+ from uni_diff_patch.core.diff_engine import (
11
+ DiffGenerationError,
12
+ generate_patch,
13
+ )
14
+ from uni_diff_patch.core.search_replace import SearchReplaceError
15
+ from uni_diff_patch.core.validator import validate_patch
16
+ from uni_diff_patch.server import _parse_changes
17
+
18
+
19
+ @click.group()
20
+ @click.version_option(package_name="uni-diff-patch")
21
+ def main() -> None:
22
+ """uni-diff-patch: Generate valid unified diff patches deterministically."""
23
+
24
+
25
+ @main.command()
26
+ @click.option(
27
+ "--spec",
28
+ "spec_source",
29
+ required=True,
30
+ help="JSON spec file path, or '-' for stdin.",
31
+ )
32
+ @click.option(
33
+ "-o",
34
+ "--output",
35
+ "output_override",
36
+ default=None,
37
+ help="Override output file path (takes precedence over spec's output_path).",
38
+ )
39
+ @click.option(
40
+ "--work-dir",
41
+ default=None,
42
+ help="Base directory for relativizing absolute paths in diff headers.",
43
+ )
44
+ @click.option(
45
+ "--overwrite",
46
+ is_flag=True,
47
+ help="Overwrite existing output file.",
48
+ )
49
+ def generate(
50
+ spec_source: str,
51
+ output_override: str | None,
52
+ work_dir: str | None,
53
+ overwrite: bool,
54
+ ) -> None:
55
+ """Generate a unified diff patch from a JSON spec.
56
+
57
+ The spec JSON must contain a "changes" array. Each change has:
58
+ file_path, mode, and mode-specific fields.
59
+
60
+ Modes: file_content, search_replace, create_file, delete_file,
61
+ text_pair, rename_file, chmod.
62
+ """
63
+ # 读取 spec
64
+ try:
65
+ if spec_source == "-":
66
+ spec_data = json.load(sys.stdin)
67
+ else:
68
+ with open(spec_source, encoding="utf-8") as f:
69
+ spec_data = json.load(f)
70
+ except (json.JSONDecodeError, FileNotFoundError, OSError) as e:
71
+ raise click.ClickException(f"Failed to read spec: {e}")
72
+
73
+ if not isinstance(spec_data, dict):
74
+ raise click.ClickException("Spec must be a JSON object")
75
+
76
+ raw_changes = spec_data.get("changes")
77
+ if not isinstance(raw_changes, list) or not raw_changes:
78
+ raise click.ClickException("Spec must contain a non-empty 'changes' array")
79
+
80
+ try:
81
+ file_changes = _parse_changes(raw_changes)
82
+ except ValueError as e:
83
+ raise click.ClickException(str(e))
84
+
85
+ # output 优先级:CLI flag > spec 中的 output_path
86
+ final_output = output_override or spec_data.get("output_path")
87
+
88
+ try:
89
+ result = generate_patch(
90
+ file_changes,
91
+ output_path=final_output,
92
+ work_dir=work_dir,
93
+ overwrite=overwrite,
94
+ )
95
+ except (DiffGenerationError, SearchReplaceError) as e:
96
+ raise click.ClickException(str(e))
97
+
98
+ if not result.patch:
99
+ click.echo("No changes detected.", err=True)
100
+ sys.exit(0)
101
+
102
+ if result.skipped_files:
103
+ click.echo(
104
+ f"Skipped (no changes): {', '.join(result.skipped_files)}",
105
+ err=True,
106
+ )
107
+
108
+ if not result.self_valid:
109
+ click.echo("WARNING: generated patch failed self-validation", err=True)
110
+
111
+ if final_output:
112
+ click.echo(
113
+ f"Patch written to {final_output} "
114
+ f"({result.files_changed} file(s) changed)",
115
+ err=True,
116
+ )
117
+ else:
118
+ click.echo(result.patch, nl=False)
119
+
120
+
121
+ @main.command()
122
+ @click.argument("patch_source", default="-")
123
+ @click.option(
124
+ "--git-check", is_flag=True, help="Also run git apply --check."
125
+ )
126
+ @click.option(
127
+ "--work-dir",
128
+ default=None,
129
+ help="Working directory for git apply --check.",
130
+ )
131
+ def validate(patch_source: str, git_check: bool, work_dir: str | None) -> None:
132
+ """Validate a unified diff patch file.
133
+
134
+ Pass a file path or '-' (default) to read from stdin.
135
+ """
136
+ patch_content: str | None = None
137
+ patch_path: str | None = None
138
+
139
+ if patch_source == "-":
140
+ raw = sys.stdin.read()
141
+ if not raw.strip():
142
+ raise click.ClickException("No patch content provided on stdin")
143
+ patch_content = raw
144
+ else:
145
+ patch_path = patch_source
146
+
147
+ result = validate_patch(
148
+ patch_content=patch_content,
149
+ patch_path=patch_path,
150
+ git_check=git_check,
151
+ work_dir=work_dir,
152
+ )
153
+
154
+ if result.valid:
155
+ click.echo(f"VALID — {len(result.files)} file(s), {result.total_hunks} hunk(s)")
156
+ for f in result.files:
157
+ status = " [new]" if f.is_creation else ""
158
+ click.echo(f" {f.file_path}: {f.hunk_count} hunk(s){status}")
159
+ else:
160
+ click.echo("INVALID", err=True)
161
+ if result.parse_error:
162
+ click.echo(f" Parse error: {result.parse_error}", err=True)
163
+ if result.git_apply_error:
164
+ click.echo(f" git apply: {result.git_apply_error}", err=True)
165
+ sys.exit(1)
166
+
167
+ if result.git_apply_ok is True:
168
+ click.echo("git apply --check: OK")
169
+ elif result.git_apply_ok is False:
170
+ click.echo(f"git apply --check: FAILED — {result.git_apply_error}", err=True)
171
+
172
+
173
+ @main.command()
174
+ def serve() -> None:
175
+ """Start the MCP server (stdio transport)."""
176
+ from uni_diff_patch.server import run_server
177
+
178
+ run_server()