filemaker-patchwork 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.
patchwork/ops.py ADDED
@@ -0,0 +1,249 @@
1
+ """Every workspace operation, shared by the CLI and the MCP server. Each returns plain text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Callable
9
+
10
+ from patchwork import PatchworkError, objects, place, script
11
+ from patchwork.build import FIELD_TYPES, add_field_action, field_xml, image_calc, layouts_patch, palette as theme_palette, theme_replace
12
+ from patchwork.export import Export, unescape
13
+ from patchwork.uses import find_uses
14
+ from patchwork.workspace import Result, Workspace
15
+
16
+ LISTABLE = ("layouts", "scripts", "value-lists", "custom-functions", "themes", "tables", "table-occurrences", "fields")
17
+
18
+
19
+ def open_file(source: str, account: str, no_password: bool = False, project: str | None = None, force: bool = False) -> str:
20
+ ws = Workspace.create(Path(source), account, no_password, Path(project) if project else None, force)
21
+ return f"Opened {Path(source).name} as v000 in {ws.root}\n" + status(ws)
22
+
23
+
24
+ def status(ws: Workspace) -> str:
25
+ export = ws.export
26
+ counts = ", ".join(f"{len(objects.items(export, k))} {k}" for k in ("layouts", "scripts", "value-lists", "custom-functions"))
27
+ tables = objects.items(export, "tables")
28
+ last = ws.log()[-1]
29
+ return (
30
+ f"source {ws.state['source']}\n"
31
+ f"version v{ws.version:03d} ({last['message']}, {last['time']})\n"
32
+ f"account {ws.state['account']}\n"
33
+ f"contents {len(tables)} tables, {counts}"
34
+ )
35
+
36
+
37
+ def list_objects(ws: Workspace, kind: str = "", table: str = "") -> str:
38
+ export = ws.export
39
+ if kind in ("", "all"):
40
+ return "\n".join(f"{k}: {len(objects.items(export, k))}" for k in LISTABLE if k != "fields") + "\nfields: use `ls fields TABLE`"
41
+ if kind in ("fields", "field"):
42
+ if not table:
43
+ raise PatchworkError("Name the table: ls fields TABLE")
44
+ _, _, catalog = export.field_catalog(table)
45
+ rows = re.findall(r'<Field id="\d+" name="([^"]*)" fieldtype="([^"]*)" datatype="([^"]*)"', catalog)
46
+ label = {"Binary": "Container", "TimeStamp": "Timestamp"}
47
+ return "\n".join(f"{unescape(name)}\t{label.get(datatype, datatype)}{' (calc)' if ftype == 'Calculated' else ''}"
48
+ for name, ftype, datatype in rows)
49
+ return "\n".join(item.name for item in objects.items(export, kind))
50
+
51
+
52
+ def show(ws: Workspace, kind: str, name: str, xml: bool = False) -> str:
53
+ if kind == "script" and not xml:
54
+ return script.to_text(objects.script_steps(ws.export, name)[1]) or f"{name} has no steps."
55
+ return objects.show(ws.export, kind, name)
56
+
57
+
58
+ def describe(result: Result) -> str:
59
+ lines = [f"v{result.version:03d} {result.message}"] + [f" {c}" for c in result.changes[:40]]
60
+ if len(result.changes) > 40:
61
+ lines.append(f" ... and {len(result.changes) - 40} more")
62
+ return "\n".join(lines)
63
+
64
+
65
+ def run(ws: Workspace, actions, message: str, dry_run: bool, expect: str | None = None,
66
+ check: Callable[[Export], str | None] | None = None) -> str:
67
+ result = ws.apply(actions, message, dry_run=dry_run)
68
+ if dry_run:
69
+ return f"Dry run: patch written to {result.patch}"
70
+ if expect and not any(c.startswith(expect) for c in result.changes):
71
+ ws.undo()
72
+ raise PatchworkError(f"FMUpgradeTool reported success but `{expect}` is missing from the result. Version dropped.")
73
+ problem = check(ws.export) if check else None
74
+ if problem:
75
+ ws.undo()
76
+ raise PatchworkError(f"FMUpgradeTool applied the patch but {problem}. Version dropped.")
77
+ return describe(result)
78
+
79
+
80
+ def lines(text: str) -> list[str]:
81
+ return re.split(r"\r\n|\r|\n", text)
82
+
83
+
84
+ def check_value_list(name: str, values: list[str]) -> Callable[[Export], str | None]:
85
+ def check(export: Export) -> str | None:
86
+ found = re.search(r"<CustomValues>\s*<Text><!\[CDATA\[([\s\S]*?)\]\]>", objects.find(export, "value-list", name).xml)
87
+ stored = lines(found.group(1)) if found else []
88
+ return None if stored == values else f"the value list holds {stored} instead of {values}"
89
+ return check
90
+
91
+
92
+ def check_custom_function(name: str, calc: str, params: list[str]) -> Callable[[Export], str | None]:
93
+ def check(export: Export) -> str | None:
94
+ xml = objects.find(export, "custom-function", name).xml
95
+ found = re.search(r"<Calculation>\s*<Text><!\[CDATA\[([\s\S]*?)\]\]>", xml)
96
+ if not found or lines(found.group(1)) != lines(calc):
97
+ return "the formula is missing from the result (FileMaker rejected the calculation)"
98
+ stored = [unescape(p) for p in re.findall(r'<Parameter name="([^"]*)"', xml)]
99
+ return None if stored == params else f"the parameters are {stored} instead of {params}"
100
+ return check
101
+
102
+
103
+ def add_field(ws: Workspace, table: str, name: str, type: str = "Text", calc: str = "", is_global: bool = False,
104
+ comment: str = "", context: str = "", image: str = "", image_width: int = 0, message: str = "", dry_run: bool = False) -> str:
105
+ if type not in FIELD_TYPES:
106
+ raise PatchworkError(f"Unknown field type: {type}. Choose from {', '.join(FIELD_TYPES)}.")
107
+ if image:
108
+ calc, type, is_global = image_calc(Path(image), image_width or None), "Container", True
109
+ xml = field_xml(ws.export, table, name, type, comment, is_global, calc or None, context or None)
110
+ return run(ws, add_field_action(ws.export, table, xml), message or f"add field {table}::{name}", dry_run, f"+ field {table}::{name}")
111
+
112
+
113
+ def add_value_list(ws: Workspace, name: str, values: list[str], message: str = "", dry_run: bool = False) -> str:
114
+ action = objects.add_object(ws.export, "value-list", objects.value_list_xml(name, values))
115
+ return run(ws, action, message or f"add value list {name}", dry_run, f"+ value-list {name}", check_value_list(name, values))
116
+
117
+
118
+ def add_custom_function(ws: Workspace, name: str, calc: str, params: list[str] | None = None, message: str = "", dry_run: bool = False) -> str:
119
+ action = objects.add_object(ws.export, "custom-function", objects.custom_function_xml(name, params or [], calc))
120
+ return run(ws, action, message or f"add custom function {name}", dry_run, f"+ custom-function {name}",
121
+ check_custom_function(name, calc, params or []))
122
+
123
+
124
+ def steps_xml(export: Export, steps: str, existing: str = "") -> str:
125
+ """<Step> XML from FileMaker script text, or the XML itself."""
126
+ return steps if script.is_xml(steps) else script.build(export, steps, existing)
127
+
128
+
129
+ def check_steps(name: str, want: list[str]) -> Callable[[Export], str | None]:
130
+ def check(export: Export) -> str | None:
131
+ got = script.normalized(objects.script_steps(export, name)[1])
132
+ for i, (w, g) in enumerate(zip(want, got), 1):
133
+ if w != g:
134
+ return f"step {i} came back as `{g}` instead of `{w}` (FileMaker dropped or changed a parameter)"
135
+ if len(want) != len(got):
136
+ return f"the script has {len(got)} steps instead of {len(want)}"
137
+ return None
138
+ return check
139
+
140
+
141
+ def add_script(ws: Workspace, name: str, steps: str = "", message: str = "", dry_run: bool = False) -> str:
142
+ xml = steps_xml(ws.export, steps) if steps.strip() else ""
143
+ return run(ws, objects.add_script(ws.export, name, xml), message or f"add script {name}", dry_run, f"+ script {name}",
144
+ check_steps(name, script.normalized(xml)))
145
+
146
+
147
+ def add(ws: Workspace, kind: str, xml: str, message: str = "", dry_run: bool = False) -> str:
148
+ action = objects.add_object(ws.export, kind, xml)
149
+ name = re.search(r'\sname="([^"]*)"', xml)
150
+ return run(ws, action, message or f"add {kind} {name.group(1) if name else ''}".strip(), dry_run)
151
+
152
+
153
+ def set_steps(ws: Workspace, name: str, steps: str, message: str = "", dry_run: bool = False) -> str:
154
+ _, existing = objects.script_steps(ws.export, name)
155
+ xml = steps_xml(ws.export, steps, existing)
156
+ return run(ws, objects.set_steps(ws.export, name, xml), message or f"rewrite steps of {name}", dry_run, f"~ script {name}",
157
+ check_steps(name, script.normalized(xml)))
158
+
159
+
160
+ def append_steps(ws: Workspace, name: str, steps: str, message: str = "", dry_run: bool = False) -> str:
161
+ _, existing = objects.script_steps(ws.export, name)
162
+ xml = steps_xml(ws.export, steps)
163
+ return run(ws, objects.append_steps(ws.export, name, xml), message or f"append steps to {name}", dry_run, f"~ script {name}",
164
+ check_steps(name, script.normalized(existing) + script.normalized(xml)))
165
+
166
+
167
+ def replace(ws: Workspace, kind: str, name: str, xml: str, message: str = "", dry_run: bool = False) -> str:
168
+ return run(ws, objects.replace_object(ws.export, kind, name, xml), message or f"edit {kind} {name}", dry_run)
169
+
170
+
171
+ def place_field(ws: Workspace, layout: str, field: str, near: str, label: str | None = None, side: str = "below",
172
+ message: str = "", dry_run: bool = False) -> str:
173
+ xml, field_uuid = place.place_field(ws.export, layout, field, near, label, side)
174
+
175
+ def check(export: Export) -> str | None:
176
+ found = re.search(rf'<FieldReference [^>]*\bUUID="{field_uuid}"', export.layout(layout).xml)
177
+ return None if found else f"{field} is not on {layout!r} in the result"
178
+
179
+ return run(ws, objects.replace_object(ws.export, "layout", layout, xml), message or f"place {field} on {layout}",
180
+ dry_run, f"~ layout {layout}", check)
181
+
182
+
183
+ def uses(ws: Workspace, kind: str, name: str) -> str:
184
+ return find_uses(ws.export, kind, name).report()
185
+
186
+
187
+ def delete(ws: Workspace, kind: str, name: str, message: str = "", dry_run: bool = False, force: bool = False) -> str:
188
+ found = find_uses(ws.export, kind, name)
189
+ if found.references and not force:
190
+ raise PatchworkError(f"{found.report()}\nNot deleted: these would point at nothing. Check them first, "
191
+ "then delete with --force (MCP: force=true). `undo` brings it back either way.")
192
+ result = run(ws, objects.delete_object(ws.export, kind, name), message or f"delete {kind} {name}", dry_run, f"- {kind} {name}")
193
+ if found.references or found.mentions:
194
+ result += f"\nWarning, review these: {found.report()}"
195
+ return result
196
+
197
+
198
+ def retheme(ws: Workspace, theme: str = "", colors: dict | None = None, fonts: dict | None = None, rename: str = "",
199
+ message: str = "", dry_run: bool = False) -> str:
200
+ export = ws.export
201
+ label = theme or export.main_theme()
202
+ action = theme_replace(export, label, rename or None, colors or {}, fonts or {})
203
+ return run(ws, action, message or f"edit theme {export.theme(label).name}", dry_run)
204
+
205
+
206
+ def recolor_layouts(ws: Workspace, colors: dict | None = None, fonts: dict | None = None, swap_field: list[str] | None = None,
207
+ only: list[str] | None = None, message: str = "", dry_run: bool = False) -> str:
208
+ patch, names = layouts_patch(ws.export, only or None, colors or {}, fonts or {}, tuple(swap_field) if swap_field else None)
209
+ if not names:
210
+ raise PatchworkError("No layout would change.")
211
+ return run(ws, patch, message or f"edit {len(names)} layout(s)", dry_run)
212
+
213
+
214
+ def push(ws: Workspace, patch_xml: str, message: str = "", dry_run: bool = False) -> str:
215
+ if "<FMUpgradeToolPatch" not in patch_xml:
216
+ raise PatchworkError("Expected a full patch (root <FMUpgradeToolPatch>).")
217
+ return run(ws, patch_xml, message or "push patch", dry_run)
218
+
219
+
220
+ def palette(ws: Workspace, theme: str = "") -> str:
221
+ block = ws.export.theme(theme or ws.export.main_theme())
222
+ return f"{block.name}\n" + "\n".join(f"{color} {count}x" for color, count in theme_palette(block.xml))
223
+
224
+
225
+ def undo(ws: Workspace) -> str:
226
+ return f"Back to v{ws.undo():03d}"
227
+
228
+
229
+ def log(ws: Workspace) -> str:
230
+ lines = []
231
+ for entry in ws.log():
232
+ lines.append(f"v{entry['n']:03d} {entry['time']} {entry['message']}")
233
+ lines += [f" {c}" for c in entry.get("changes", [])[:10]]
234
+ return "\n".join(lines)
235
+
236
+
237
+ def save(ws: Workspace, dest: str, force: bool = False) -> str:
238
+ return f"Saved v{ws.version:03d} to {ws.save(Path(dest), force)}"
239
+
240
+
241
+ def load_json(value: str | dict | None) -> dict:
242
+ if not value:
243
+ return {}
244
+ if isinstance(value, dict):
245
+ return value
246
+ path = Path(value)
247
+ if path.is_file():
248
+ return json.loads(path.read_text(encoding="utf-8"))
249
+ return json.loads(value)
patchwork/place.py ADDED
@@ -0,0 +1,201 @@
1
+ """Put a field on a layout by copying one that is already there, with its label.
2
+
3
+ FMUpgradeTool skips a lone LayoutObject add or replace, so the whole layout is replaced.
4
+ Bounds in a Save-as-XML export are absolute (from the top of the layout), and a field
5
+ points at its label with <Accessibility><Label>ID</Label>.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+
13
+ from patchwork import PatchworkError, objects
14
+ from patchwork.export import Export, escape, unescape
15
+
16
+ OBJECT = re.compile(r"(?m)^(\t*)<LayoutObject [^>]*>[\s\S]*?^\1</LayoutObject>")
17
+ BOUNDS = re.compile(r'<Bounds top="(-?[\d.]+)" left="(-?[\d.]+)" bottom="(-?[\d.]+)" right="(-?[\d.]+)"></Bounds>')
18
+ GAP = 10
19
+ SLACK = 4
20
+
21
+
22
+ @dataclass
23
+ class Box:
24
+ top: float
25
+ left: float
26
+ bottom: float
27
+ right: float
28
+
29
+ def moved(self, dy: float, dx: float) -> "Box":
30
+ return Box(self.top + dy, self.left + dx, self.bottom + dy, self.right + dx)
31
+
32
+ def grown(self, dy: float) -> "Box":
33
+ return Box(self.top, self.left, self.bottom + dy, self.right)
34
+
35
+ def union(self, other: "Box") -> "Box":
36
+ return Box(min(self.top, other.top), min(self.left, other.left), max(self.bottom, other.bottom), max(self.right, other.right))
37
+
38
+ def overlaps(self, other: "Box") -> bool:
39
+ return self.top < other.bottom and other.top < self.bottom and self.left < other.right and other.left < self.right
40
+
41
+ def tag(self) -> str:
42
+ return f'<Bounds top="{num(self.top)}" left="{num(self.left)}" bottom="{num(self.bottom)}" right="{num(self.right)}"></Bounds>'
43
+
44
+
45
+ @dataclass
46
+ class Obj:
47
+ start: int
48
+ end: int
49
+ xml: str
50
+ id: str
51
+ box: Box
52
+
53
+
54
+ def num(value: float) -> str:
55
+ return str(int(value)) if value == int(value) else f"{value:g}"
56
+
57
+
58
+ def own_bounds(xml: str) -> Box:
59
+ found = BOUNDS.search(xml)
60
+ if not found:
61
+ raise PatchworkError("Layout object without bounds.")
62
+ return Box(*(float(v) for v in found.groups()))
63
+
64
+
65
+ def top_level(part: str, offset: int) -> list[Obj]:
66
+ """Objects directly in a part (not inside button bars, portals or groups)."""
67
+ found = []
68
+ depth = None
69
+ for match in OBJECT.finditer(part):
70
+ indent = len(match.group(1))
71
+ depth = indent if depth is None else min(depth, indent)
72
+ for match in OBJECT.finditer(part):
73
+ if len(match.group(1)) == depth:
74
+ xml = match.group(0)
75
+ ident = re.search(r'\sid="(\d+)"', xml[: xml.index(">")])
76
+ found.append(Obj(offset + match.start(), offset + match.end(), xml, ident.group(1) if ident else "", own_bounds(xml)))
77
+ return found
78
+
79
+
80
+ def field_target(export: Export, field: str) -> tuple[str, str, str, str]:
81
+ """(field id, field name, field UUID, TableOccurrenceReference) for TO::Field."""
82
+ occurrence, name = objects.split_field(field)
83
+ to = objects.find(export, "table-occurrence", occurrence)
84
+ base = re.search(r'<BaseTableReference [^>]*\bname="([^"]*)"', to.xml)
85
+ if not base:
86
+ raise PatchworkError(f"Table occurrence {occurrence} has no base table in the export.")
87
+ field_id, field_uuid = export.field(unescape(base.group(1)), name)
88
+ return field_id, name, field_uuid, export.table_occurrence(occurrence)
89
+
90
+
91
+ def place_field(export: Export, layout: str, field: str, near: str, label: str | None = None,
92
+ side: str = "below") -> tuple[str, str]:
93
+ """(whole new layout XML, field UUID). `near` is a field already on the layout, as Name or TO::Name."""
94
+ block = export.layout(layout)
95
+ xml = block.xml
96
+ field_id, field_name, field_uuid, to_ref = field_target(export, field)
97
+ near_name = near.split("::", 1)[-1]
98
+
99
+ parts = list(re.finditer(r"<Part type=\"([^\"]*)\"[\s\S]*?</Part>", xml))
100
+ for part in parts:
101
+ objs = top_level(part.group(0), part.start())
102
+ anchor = next((o for o in objs if re.search(rf'<FieldReference [^>]*\bname="{re.escape(escape(near_name))}"', o.xml)
103
+ and (near_name == near or f'name="{escape(near.split("::")[0])}"' in o.xml)), None)
104
+ if anchor:
105
+ break
106
+ else:
107
+ raise PatchworkError(f"No field {near!r} directly on layout {layout!r}. Name a field that is on it, e.g. one from `patchwork show layout`.")
108
+ if re.search(rf'<FieldReference [^>]*\bUUID="{field_uuid}"', part.group(0)):
109
+ raise PatchworkError(f"{field} is already on {layout!r}.")
110
+
111
+ label_id = re.search(r"<Accessibility>\s*<Label>(\d+)</Label>", anchor.xml)
112
+ caption = next((o for o in objs if label_id and o.id == label_id.group(1) and 'type="Text"' in o.xml[:200]), None)
113
+ group = anchor.box.union(caption.box) if caption else anchor.box
114
+ if side == "below":
115
+ line = group.bottom - SLACK
116
+ under = [o.box.top - group.bottom for o in objs
117
+ if o.box.top >= line and o.box.left < group.right and group.left < o.box.right]
118
+ gap = min(under) if under and 0 <= min(under) <= 40 else GAP
119
+ dy, dx = group.bottom - group.top + gap, 0.0
120
+ elif side == "right":
121
+ line, dy, dx = None, 0.0, group.right - group.left + GAP
122
+ else:
123
+ raise PatchworkError("side is below or right.")
124
+
125
+ placed = group.moved(dy, dx)
126
+ width = float(re.search(r'<Layout [^>]*\bwidth="([\d.]+)"', xml).group(1))
127
+ if placed.right > width:
128
+ raise PatchworkError(f"No room right of {near!r}: it would go past the edge of the layout. Use side=below.")
129
+ staying = [o for o in objs if line is None or o.box.top < line]
130
+ blocking = [o for o in staying if o.box.overlaps(placed) and not stretches(o)]
131
+ if blocking:
132
+ names = [re.search(r'<FieldReference [^>]*\bname="([^"]*)"', o.xml) for o in blocking]
133
+ what = ", ".join(f"field {n.group(1)}" if n else f"object {o.id}" for n, o in zip(names, blocking))
134
+ raise PatchworkError(f"No room {side} {near!r}: it would cover {what}. "
135
+ f"Try side={'right' if side == 'below' else 'below'} or another field.")
136
+
137
+ next_id = max((int(i) for i in re.findall(r'<LayoutObject [^>]*?\bid="(\d+)"', export.section("LayoutCatalog"))), default=0) + 1
138
+ new_field = copy(anchor.xml, str(next_id), dy, dx)
139
+ new_field = re.sub(r"<FieldReference [^>]*>[\s\S]*?</FieldReference>",
140
+ f'<FieldReference id="{field_id}" name="{escape(field_name)}" repetition="1" UUID="{field_uuid}">'
141
+ f"\n{indent_of(anchor.xml, 'TableOccurrenceReference')}{to_ref}\n{indent_of(anchor.xml, 'FieldReference')}</FieldReference>",
142
+ new_field, count=1)
143
+ new_field = re.sub(r"\s*<TabOrder [\s\S]*?</TabOrder>", "", new_field)
144
+ added = [new_field]
145
+ if caption:
146
+ text = escape_cdata(label if label is not None else field_name)
147
+ new_caption = copy(caption.xml, str(next_id + 1), dy, dx)
148
+ new_caption = re.sub(r"<Data><!\[CDATA\[[\s\S]*?\]\]></Data>", lambda _: f"<Data><![CDATA[{text}]]></Data>", new_caption, count=1)
149
+ new_field = new_field.replace(f"<Label>{caption.id}</Label>", f"<Label>{next_id + 1}</Label>")
150
+ added = [new_caption, new_field]
151
+
152
+ edits = [(anchor.end, anchor.end, "".join(f"\n{a}" for a in added))]
153
+ if line is not None:
154
+ for o in objs:
155
+ if o.box.top >= line:
156
+ edits.append((o.start, o.end, shift(o.xml, dy)))
157
+ elif o.box.bottom > line and stretches(o):
158
+ edits.append((o.start, o.end, BOUNDS.sub(lambda m: Box(*(float(v) for v in m.groups())).grown(dy).tag(), o.xml, count=1)))
159
+ definition = re.search(r'<Definition [^>]*\bsize="([\d.]+)"', part.group(0))
160
+ if definition:
161
+ at = part.start() + definition.start(1)
162
+ edits.append((at, at + len(definition.group(1)), num(float(definition.group(1)) + dy)))
163
+ for later in parts[parts.index(part) + 1:]:
164
+ moved = BOUNDS.sub(lambda m: Box(*(float(v) for v in m.groups())).moved(dy, 0).tag(), later.group(0))
165
+ moved = re.sub(r'(<Definition [^>]*\babsolute=")([\d.]+)', lambda m: m.group(1) + num(float(m.group(2)) + dy), moved, count=1)
166
+ edits.append((later.start(), later.end(), moved))
167
+ for start, end, text in sorted(edits, key=lambda e: e[0], reverse=True):
168
+ xml = xml[:start] + text + xml[end:]
169
+ count = re.compile(r'(<ObjectList membercount=")(\d+)(">)')
170
+ first = count.search(xml, part.start())
171
+ xml = xml[: first.start(2)] + str(int(first.group(2)) + len(added)) + xml[first.end(2):]
172
+ return xml, field_uuid
173
+
174
+
175
+ def stretches(o: Obj) -> bool:
176
+ """Background shapes grow with the row instead of moving."""
177
+ return bool(re.match(r'\t*<LayoutObject [^>]*\btype="(Rectangle|Rounded Rectangle)"', o.xml))
178
+
179
+
180
+ def shift(xml: str, dy: float) -> str:
181
+ return BOUNDS.sub(lambda m: Box(*(float(v) for v in m.groups())).moved(dy, 0).tag(), xml)
182
+
183
+
184
+ def copy(xml: str, new_id: str, dy: float, dx: float) -> str:
185
+ head_end = xml.index(">")
186
+ head = re.sub(r'\shash="[^"]*"', "", xml[:head_end])
187
+ head = re.sub(r'\sid="\d+"', f' id="{new_id}"', head, count=1)
188
+ body = objects.fresh_uuids(xml[head_end:])
189
+ body = BOUNDS.sub(lambda m: Box(*(float(v) for v in m.groups())).moved(dy, dx).tag(), body)
190
+ return head + body
191
+
192
+
193
+ def indent_of(xml: str, tag: str) -> str:
194
+ found = re.search(rf"\n(\t*)<{tag}\b", xml)
195
+ return found.group(1) if found else ""
196
+
197
+
198
+ def escape_cdata(text: str) -> str:
199
+ if "]]>" in text:
200
+ raise PatchworkError("The label cannot contain ']]>'.")
201
+ return text
patchwork/project.py ADDED
@@ -0,0 +1,96 @@
1
+ """`patchwork init`: a project folder wired for Claude Code, Cursor, Codex and Google Antigravity.
2
+
3
+ Antigravity and Codex read AGENTS.md; Claude Code reads CLAUDE.md, which imports it.
4
+ Skills go to .cursor/skills, .claude/skills and .agents/skills (Antigravity, Codex).
5
+ Existing files are never overwritten, so `init` can wire an existing folder.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import shutil
12
+ import sys
13
+ from importlib import resources
14
+ from pathlib import Path
15
+
16
+ SKILL_HOMES = (".cursor/skills", ".claude/skills", ".agents/skills")
17
+
18
+
19
+ class Init:
20
+ def __init__(self, root: Path):
21
+ self.root = root
22
+ self.written: list[str] = []
23
+ self.kept: list[str] = []
24
+
25
+ def write(self, relative: str, text: str) -> None:
26
+ path = self.root / relative
27
+ if path.exists():
28
+ self.kept.append(relative)
29
+ return
30
+ path.parent.mkdir(parents=True, exist_ok=True)
31
+ path.write_text(text, encoding="utf-8")
32
+ self.written.append(relative)
33
+
34
+ def report(self) -> str:
35
+ lines = [f"Project ready: {self.root}"]
36
+ lines += [f" + {name}" for name in self.written]
37
+ if self.kept:
38
+ lines.append(f" kept {len(self.kept)} existing file(s): {', '.join(self.kept)}")
39
+ return "\n".join(lines)
40
+
41
+
42
+ def init_project(directory: Path, name: str, skills: bool = True) -> Init:
43
+ root = directory.resolve()
44
+ root.mkdir(parents=True, exist_ok=True)
45
+ init = Init(root)
46
+ values = {"NAME": name.strip() or root.name}
47
+ init.write("AGENTS.md", template("AGENTS.md", values))
48
+ init.write("CLAUDE.md", "@AGENTS.md\n")
49
+ init.write("standards/naming.md", template("naming.md", values))
50
+ init.write(".gitignore", template("gitignore", values))
51
+ init.write("patches/.gitkeep", "")
52
+ for relative, text in mcp_configs(root).items():
53
+ init.write(relative, text)
54
+ if skills:
55
+ source = resources.files("patchwork") / "skills"
56
+ for home in SKILL_HOMES:
57
+ for skill in source.iterdir():
58
+ if skill.is_dir():
59
+ init.write(f"{home}/{skill.name}/SKILL.md", (skill / "SKILL.md").read_text(encoding="utf-8"))
60
+ return init
61
+
62
+
63
+ def server_command() -> tuple[str, list[str]]:
64
+ """`patchwork` when agents can find it on PATH, otherwise this Python with `-m patchwork`."""
65
+ if shutil.which("patchwork"):
66
+ return "patchwork", []
67
+ return sys.executable, ["-m", "patchwork"]
68
+
69
+
70
+ def mcp_configs(root: Path) -> dict[str, str]:
71
+ command, prefix = server_command()
72
+ server = {"command": command, "args": [*prefix, "mcp"]}
73
+ cursor = {"mcpServers": {"patchwork": {**server, "args": [*prefix, "-C", "${workspaceFolder}", "mcp"],
74
+ "env": {"PATCHWORK_PASSWORD": "${env:PATCHWORK_PASSWORD}"}}}}
75
+ claude = {"mcpServers": {"patchwork": {**server, "env": {"PATCHWORK_PASSWORD": "${PATCHWORK_PASSWORD:-}"}}}}
76
+ antigravity = {"mcpServers": {"patchwork": {**server, "cwd": str(root)}}}
77
+ codex = (
78
+ "[mcp_servers.patchwork]\n"
79
+ f"command = {json.dumps(command)}\n"
80
+ f"args = {json.dumps(server['args'])}\n"
81
+ f"cwd = {json.dumps(str(root))}\n"
82
+ 'env_vars = ["PATCHWORK_PASSWORD"]\n'
83
+ )
84
+ return {
85
+ ".cursor/mcp.json": json.dumps(cursor, indent=2) + "\n",
86
+ ".mcp.json": json.dumps(claude, indent=2) + "\n",
87
+ ".agents/mcp_config.json": json.dumps(antigravity, indent=2) + "\n",
88
+ ".codex/config.toml": codex,
89
+ }
90
+
91
+
92
+ def template(name: str, values: dict[str, str]) -> str:
93
+ text = (resources.files("patchwork") / "templates" / name).read_text(encoding="utf-8")
94
+ for key, value in values.items():
95
+ text = text.replace("{{" + key + "}}", value)
96
+ return text