xcoding 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.
xcode/hooks.py ADDED
@@ -0,0 +1,74 @@
1
+ """Hooks + settings: .xcode/settings.json lets you run shell commands after the
2
+ agent does things (e.g. auto-format after every edit), set env vars, and seed
3
+ permission rules. Mirrors the spirit of Claude Code's settings.json hooks.
4
+
5
+ Example .xcode/settings.json:
6
+ {
7
+ "env": {"PYTHONWARNINGS": "ignore"},
8
+ "hooks": {
9
+ "after_write": ["ruff format {path}"],
10
+ "after_edit": ["ruff format {path}"],
11
+ "after_command": []
12
+ },
13
+ "permissions": {"tools": ["read_file"], "commands": ["git", "ls"]}
14
+ }
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import subprocess
22
+ from pathlib import Path
23
+
24
+ SETTINGS = Path(".xcode") / "settings.json"
25
+
26
+
27
+ class Settings:
28
+ def __init__(self, path: Path = SETTINGS):
29
+ self.data: dict = {}
30
+ if path.exists():
31
+ try:
32
+ self.data = json.loads(path.read_text(encoding="utf-8"))
33
+ except Exception:
34
+ self.data = {}
35
+ for k, v in (self.data.get("env") or {}).items():
36
+ os.environ.setdefault(k, str(v))
37
+
38
+ @property
39
+ def loaded(self) -> bool:
40
+ return bool(self.data)
41
+
42
+ def hooks_for(self, event: str) -> list[str]:
43
+ return (self.data.get("hooks") or {}).get(event, []) or []
44
+
45
+ def seed_permissions(self, perms) -> None:
46
+ block = self.data.get("permissions") or {}
47
+ for t in block.get("tools", []):
48
+ perms.tools.add(t)
49
+ for c in block.get("commands", []):
50
+ perms.cmd_prefixes.add(c)
51
+
52
+
53
+ def run_hooks(settings: Settings, event: str, **vars) -> str:
54
+ """Run every command registered for an event. Returns a note for the model
55
+ if anything ran (so it sees formatter output / failures)."""
56
+ cmds = settings.hooks_for(event)
57
+ if not cmds:
58
+ return ""
59
+ notes = []
60
+ for tmpl in cmds:
61
+ try:
62
+ cmd = tmpl.format(**vars)
63
+ except (KeyError, IndexError):
64
+ cmd = tmpl
65
+ try:
66
+ p = subprocess.run(cmd, shell=True, capture_output=True,
67
+ text=True, timeout=60)
68
+ tag = "ok" if p.returncode == 0 else f"exit {p.returncode}"
69
+ out = (p.stdout + p.stderr).strip()
70
+ notes.append(f"[hook {event}: {cmd}] {tag}"
71
+ + (f"\n{out[:500]}" if out else ""))
72
+ except Exception as e:
73
+ notes.append(f"[hook {event}: {cmd}] failed: {e}")
74
+ return "\n".join(notes)
xcode/input_bar.py ADDED
@@ -0,0 +1,357 @@
1
+ """Claude-Code-style input bar: rules above and below the ❯ prompt, with a
2
+ status line below (mode, then the current file + token count).
3
+
4
+ The rules are sized to FIT THE TEXT on their line — not the full terminal
5
+ width. The top rule matches the `❯ ` prompt + its ghost placeholder; the
6
+ bottom rule matches the status line. The status line packs its segments
7
+ together with small separators rather than pushing the token count to the
8
+ far edge.
9
+
10
+ There are NO blank lines between the prompt and the status line — the bar is
11
+ exactly: rule / `❯` line / rule / status. On Win32 prompt_toolkit otherwise
12
+ stuffs a column of blanks in there; `_compact_layout()` pins the input window
13
+ to its content height so that slack falls below the bar instead.
14
+
15
+ ⚠️ DO NOT revert these rules back to full terminal width (`"─" * w`),
16
+ re-introduce a large `pad` gap in the status line, or remove
17
+ `_compact_layout()`. "Fit the words, no blank lines" is the intended look and
18
+ must stay this way. See `_rule()`, `_compact_layout()`, `_message()`,
19
+ `_toolbar()`.
20
+
21
+ Live mode cycling via shift+tab. Falls back to a plain prompt if
22
+ prompt_toolkit isn't available (or isn't on a real console).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import random
28
+ import shutil
29
+
30
+ MODES = ["normal", "auto", "plan"]
31
+ _LABEL = {
32
+ "normal": ("·· normal", "ask before changes"),
33
+ "auto": ("⏵⏵ auto", "run & write without asking"),
34
+ "plan": ("◷ plan", "read-only, no changes"),
35
+ }
36
+
37
+ # Faded "ghost" tips shown inside the empty prompt — they rotate each turn and
38
+ # vanish the moment you start typing.
39
+ PLACEHOLDERS = [
40
+ 'Try "fix typecheck errors"',
41
+ 'Try "how does <filepath> work?"',
42
+ 'Try "add tests for the auth module"',
43
+ 'Try "explain this stack trace"',
44
+ 'Try "refactor this function to be smaller"',
45
+ 'Try "what does this regex do?"',
46
+ 'Try "write a commit message for my changes"',
47
+ 'Try "find where we handle login"',
48
+ 'Try "why is this test flaky?"',
49
+ 'Try "add a --verbose flag to the CLI"',
50
+ 'Ask me to "make it more compact"',
51
+ 'Use @file to attach a file to your message',
52
+ 'Press shift+tab to cycle normal → auto → plan',
53
+ 'Type / to see every slash command',
54
+ 'Hit ctrl+z to undo your last edit',
55
+ 'Run /theme matrix to go full hacker mode',
56
+ ]
57
+
58
+ try:
59
+ from prompt_toolkit import PromptSession
60
+ from prompt_toolkit.application import Application
61
+ from prompt_toolkit.completion import Completer, Completion
62
+ from prompt_toolkit.formatted_text import FormattedText
63
+ from prompt_toolkit.key_binding import KeyBindings
64
+ from prompt_toolkit.layout import HSplit, Layout, Window
65
+ from prompt_toolkit.layout.controls import FormattedTextControl
66
+ from prompt_toolkit.styles import Style
67
+ AVAILABLE = True
68
+ except Exception: # pragma: no cover
69
+ AVAILABLE = False
70
+
71
+
72
+ def _enable_shift_enter() -> None:
73
+ """Make Shift+Enter distinguishable from Enter on the Windows console.
74
+
75
+ prompt_toolkit's Win32 input reader collapses Shift+Enter into a plain
76
+ Enter (there is no ShiftEnter key). We wrap it so that, when Shift is held
77
+ on Return, the key is re-tagged as Ctrl+J — which we then bind to insert a
78
+ newline. Enter (Ctrl+M) is left alone and still submits.
79
+ """
80
+ try:
81
+ from prompt_toolkit.input.win32 import ConsoleInputReader
82
+ from prompt_toolkit.keys import Keys
83
+ except Exception:
84
+ return # not on Windows / no win32 reader — nothing to patch
85
+ orig = ConsoleInputReader._event_to_key_presses
86
+ if getattr(orig, "_xcode_patched", False):
87
+ return
88
+ SHIFT_PRESSED = 0x0010
89
+
90
+ def patched(self, ev):
91
+ presses = orig(self, ev)
92
+ if ev.ControlKeyState & SHIFT_PRESSED:
93
+ for kp in presses:
94
+ if kp.key in (Keys.ControlM, getattr(Keys, "Enter", Keys.ControlM)):
95
+ kp.key = Keys.ControlJ
96
+ return presses
97
+
98
+ patched._xcode_patched = True
99
+ ConsoleInputReader._event_to_key_presses = patched
100
+
101
+
102
+ if AVAILABLE:
103
+ _enable_shift_enter()
104
+
105
+
106
+ def cycle(mode: str) -> str:
107
+ return MODES[(MODES.index(mode) + 1) % len(MODES)] if mode in MODES else "normal"
108
+
109
+
110
+ if AVAILABLE:
111
+ class SlashCompleter(Completer):
112
+ """Claude-Code-style dropdown: type `/` to see commands + descriptions."""
113
+
114
+ def __init__(self, commands):
115
+ self.commands = commands # list of (name, description)
116
+
117
+ def get_completions(self, document, complete_event):
118
+ text = document.text_before_cursor
119
+ if not text.startswith("/") or " " in text:
120
+ return
121
+ for name, desc in self.commands:
122
+ if name.startswith(text):
123
+ yield Completion(name, start_position=-len(text),
124
+ display=name, display_meta=desc)
125
+ else: # pragma: no cover
126
+ SlashCompleter = None
127
+
128
+
129
+ def select_menu(question: str, options: list) -> str:
130
+ """Show an inline single-choice menu, Claude-Code style. Navigate with
131
+ ↑/↓ or w/s (or j/k), Enter to pick, Esc to cancel (returns the first
132
+ option). Returns the chosen *label*.
133
+
134
+ `options` may be plain strings, or (label, description) pairs — the
135
+ description is shown dimmed under each choice.
136
+ """
137
+ norm: list[tuple[str, str]] = []
138
+ for o in options:
139
+ if isinstance(o, (tuple, list)):
140
+ label = str(o[0]).strip()
141
+ desc = str(o[1]).strip() if len(o) > 1 else ""
142
+ else:
143
+ label, desc = str(o).strip(), ""
144
+ if label:
145
+ norm.append((label, desc))
146
+ if not norm:
147
+ return ""
148
+ labels = [lbl for lbl, _ in norm]
149
+
150
+ if not AVAILABLE: # plain numbered fallback
151
+ print(question)
152
+ for i, (lbl, desc) in enumerate(norm, 1):
153
+ print(f" {i}. {lbl}" + (f" — {desc}" if desc else ""))
154
+ try:
155
+ s = input("pick a number › ").strip()
156
+ return labels[int(s) - 1] if s.isdigit() and 1 <= int(s) <= len(labels) else labels[0]
157
+ except Exception:
158
+ return labels[0]
159
+
160
+ idx = [0]
161
+
162
+ def render():
163
+ frags = [("bold", f" {question}\n")]
164
+ for i, (lbl, desc) in enumerate(norm):
165
+ if i == idx[0]:
166
+ frags.append(("bold fg:ansiwhite", f" ❯ {lbl}\n"))
167
+ else:
168
+ frags.append(("", f" {lbl}\n"))
169
+ if desc:
170
+ frags.append(("fg:ansibrightblack", f" {desc}\n"))
171
+ frags.append(("fg:ansibrightblack",
172
+ " ↑/↓ or w/s · enter to select · esc to cancel"))
173
+ return FormattedText(frags)
174
+
175
+ kb = KeyBindings()
176
+
177
+ @kb.add("up")
178
+ @kb.add("k")
179
+ @kb.add("w")
180
+ def _(e):
181
+ idx[0] = (idx[0] - 1) % len(labels)
182
+
183
+ @kb.add("down")
184
+ @kb.add("j")
185
+ @kb.add("s")
186
+ def _(e):
187
+ idx[0] = (idx[0] + 1) % len(labels)
188
+
189
+ @kb.add("enter")
190
+ def _(e):
191
+ e.app.exit(result=idx[0])
192
+
193
+ @kb.add("escape")
194
+ @kb.add("c-c")
195
+ def _(e):
196
+ e.app.exit(result=None)
197
+
198
+ app = Application(
199
+ layout=Layout(HSplit([
200
+ Window(FormattedTextControl(render), always_hide_cursor=True)])),
201
+ key_bindings=kb, full_screen=False)
202
+ try:
203
+ res = app.run()
204
+ except Exception:
205
+ return labels[0]
206
+ return labels[res] if res is not None else labels[0]
207
+
208
+
209
+ def _width(default: int = 100) -> int:
210
+ try:
211
+ from prompt_toolkit.application.current import get_app
212
+ return get_app().output.get_size().columns
213
+ except Exception:
214
+ try:
215
+ return shutil.get_terminal_size((default, 24)).columns
216
+ except Exception:
217
+ return default
218
+
219
+
220
+ def _rule(text: str) -> str:
221
+ """A horizontal rule sized to FIT `text` — never the full terminal width.
222
+
223
+ Length = the display width of the line it accompanies, capped to the
224
+ terminal so a very long line can't overflow and wrap into a blank pad row.
225
+ """
226
+ n = max(1, min(len(text), _width() - 1))
227
+ return "─" * n
228
+
229
+
230
+ class InputBar:
231
+ def __init__(self, uic, on_mode_change=lambda m: None, commands=None):
232
+ self.uic = uic
233
+ self.on_mode_change = on_mode_change
234
+ self.commands = commands or []
235
+ self.budget = 8000
236
+ self.model = ""
237
+ self.tokens = lambda: 0
238
+ self._ph_text = "" # current ghost placeholder (sizes the top rule)
239
+ self._session = None
240
+ if AVAILABLE:
241
+ try:
242
+ self._build()
243
+ except Exception:
244
+ self._session = None # not a real console → plain fallback
245
+
246
+ def _build(self) -> None:
247
+ kb = KeyBindings()
248
+
249
+ @kb.add("s-tab") # shift+tab cycles the mode live
250
+ def _(event):
251
+ self.uic.mode = cycle(self.uic.mode)
252
+ self.on_mode_change(self.uic.mode)
253
+ event.app.invalidate()
254
+
255
+ @kb.add("c-c")
256
+ def _(event):
257
+ event.app.exit(exception=KeyboardInterrupt)
258
+
259
+ @kb.add("c-z") # undo the last edit in the input
260
+ def _(event):
261
+ event.current_buffer.undo()
262
+
263
+ @kb.add("c-j") # Shift+Enter (remapped) / Ctrl+J → newline, not submit
264
+ def _(event):
265
+ event.current_buffer.insert_text("\n")
266
+
267
+ style = Style.from_dict({
268
+ "bottom-toolbar": "bg:default noreverse",
269
+ "completion-menu.completion": "bg:default",
270
+ "completion-menu.completion.current": "bg:ansiwhite fg:ansiblack",
271
+ "completion-menu.meta.completion": "bg:default fg:ansibrightblack",
272
+ "completion-menu.meta.completion.current": "bg:ansibrightblack fg:ansiwhite",
273
+ })
274
+ self._session = PromptSession(
275
+ key_bindings=kb, style=style, message=self._message,
276
+ bottom_toolbar=self._toolbar,
277
+ completer=SlashCompleter(self.commands) if SlashCompleter else None,
278
+ complete_while_typing=True,
279
+ # Don't reserve a tall blank block for the completion dropdown —
280
+ # that's what left all those empty lines under the prompt.
281
+ reserve_space_for_menu=0)
282
+ self._compact_layout()
283
+
284
+ def _compact_layout(self) -> None:
285
+ """Kill the blank lines between the ❯ line and the status toolbar.
286
+
287
+ On Win32, prompt_toolkit reserves every row below the cursor
288
+ (renderer: `_min_available_height = get_rows_below_cursor_position()`)
289
+ and lets the input window stretch to fill it — which stacks a column
290
+ of blank lines between the prompt and the bottom toolbar. Pinning the
291
+ input window to its content height makes that slack fall BELOW the bar
292
+ instead, so the bar stays tight: rule, ❯ line, rule, status — and only
293
+ ONE line per row of words. ⚠️ Don't remove this; the gap comes back.
294
+ """
295
+ try:
296
+ from prompt_toolkit.filters import to_filter
297
+ from prompt_toolkit.layout import walk
298
+ from prompt_toolkit.layout.controls import BufferControl
299
+ buf = self._session.default_buffer
300
+ for cont in walk(self._session.layout.container):
301
+ ctrl = getattr(cont, "content", None)
302
+ if isinstance(ctrl, BufferControl) and ctrl.buffer is buf:
303
+ cont.dont_extend_height = to_filter(True)
304
+ break
305
+ except Exception:
306
+ pass
307
+
308
+ # ---- the top rule + prompt --------------------------------------------
309
+ def _message(self):
310
+ # Rule fits the prompt line: "❯ " + the ghost placeholder. NOT full
311
+ # width — see the module docstring; do not change this back.
312
+ prompt_line = "❯ " + (self._ph_text or "")
313
+ return FormattedText([
314
+ ("fg:ansibrightblack", _rule(prompt_line) + "\n"),
315
+ ("bold fg:ansiwhite", "❯ "),
316
+ ])
317
+
318
+ # ---- the bottom rule + status line ------------------------------------
319
+ def _toolbar(self):
320
+ label, _hint = _LABEL.get(self.uic.mode, _LABEL["normal"])
321
+ seg_mode = f" {label} mode on (shift+tab to cycle)"
322
+ seg_agents = " · ← for agents"
323
+
324
+ n = self.tokens()
325
+ tok = f"{n:,} tokens"
326
+ fname = getattr(self.uic, "last_file", None)
327
+ hint = " · ⇧⏎ newline"
328
+ # Pack the segments together with small separators — no big pad gap.
329
+ right = " · " + (f"⧉ {fname} · " if fname else "") + tok + hint
330
+ status_line = seg_mode + seg_agents + right
331
+
332
+ return FormattedText([
333
+ ("fg:ansibrightblack", _rule(status_line) + "\n"),
334
+ ("bold fg:ansiwhite", seg_mode),
335
+ ("fg:ansibrightblack", seg_agents),
336
+ ("fg:ansibrightblack", right),
337
+ ])
338
+
339
+ # ---- ask ---------------------------------------------------------------
340
+ def ask(self, model: str, tokens_fn, budget: int) -> str:
341
+ self.model = model
342
+ self.tokens = tokens_fn
343
+ self.budget = budget
344
+ if self._session is not None:
345
+ # Pick the placeholder now so the top rule can size itself to it.
346
+ self._ph_text = random.choice(PLACEHOLDERS)
347
+ ph = FormattedText([("fg:ansibrightblack", self._ph_text)])
348
+ return self._session.prompt(placeholder=ph).strip()
349
+ # Fallback: plain prompt with simple rules, also fit to the words.
350
+ self._ph_text = random.choice(PLACEHOLDERS)
351
+ rule = _rule("❯ " + self._ph_text)
352
+ print(rule)
353
+ try:
354
+ line = input("❯ ").strip()
355
+ finally:
356
+ print(rule)
357
+ return line
xcode/mcp.py ADDED
@@ -0,0 +1,157 @@
1
+ """Minimal MCP (Model Context Protocol) client.
2
+
3
+ Launches MCP servers over stdio (newline-delimited JSON-RPC 2.0), lists their
4
+ tools, and lets the agent call them. Servers are declared in
5
+ .xcode/settings.json:
6
+
7
+ "mcpServers": {
8
+ "fs": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."]}
9
+ }
10
+
11
+ Each server tool is exposed to the model as mcp__<server>__<tool>.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import queue
18
+ import subprocess
19
+ import threading
20
+ from typing import Optional
21
+
22
+ PROTOCOL = "2024-11-05"
23
+
24
+
25
+ class McpClient:
26
+ def __init__(self, name: str, command: str, args: list[str],
27
+ env: Optional[dict] = None):
28
+ self.name = name
29
+ self.tools: list[dict] = []
30
+ self._id = 0
31
+ self._inbox: "queue.Queue[dict]" = queue.Queue()
32
+ self.proc = subprocess.Popen(
33
+ [command, *args], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
34
+ stderr=subprocess.DEVNULL, text=True, bufsize=1, env=_merged_env(env))
35
+ threading.Thread(target=self._reader, daemon=True).start()
36
+
37
+ # ---- lifecycle ---------------------------------------------------------
38
+ def initialize(self) -> None:
39
+ self._request("initialize", {
40
+ "protocolVersion": PROTOCOL,
41
+ "capabilities": {},
42
+ "clientInfo": {"name": "xcode", "version": "0.1"},
43
+ })
44
+ self._notify("notifications/initialized", {})
45
+ res = self._request("tools/list", {})
46
+ self.tools = (res or {}).get("tools", [])
47
+
48
+ def call(self, tool: str, arguments: dict) -> str:
49
+ res = self._request("tools/call", {"name": tool, "arguments": arguments})
50
+ if res is None:
51
+ return f"ERROR: no response from MCP server '{self.name}'"
52
+ parts = []
53
+ for block in res.get("content", []):
54
+ if block.get("type") == "text":
55
+ parts.append(block.get("text", ""))
56
+ else:
57
+ parts.append(json.dumps(block))
58
+ out = "\n".join(parts) if parts else json.dumps(res)
59
+ return ("ERROR: " + out) if res.get("isError") else out
60
+
61
+ def close(self) -> None:
62
+ try:
63
+ self.proc.terminate()
64
+ except Exception:
65
+ pass
66
+
67
+ # ---- transport ---------------------------------------------------------
68
+ def _reader(self) -> None:
69
+ for line in self.proc.stdout: # type: ignore[union-attr]
70
+ line = line.strip()
71
+ if not line:
72
+ continue
73
+ try:
74
+ self._inbox.put(json.loads(line))
75
+ except json.JSONDecodeError:
76
+ continue
77
+
78
+ def _send(self, obj: dict) -> None:
79
+ self.proc.stdin.write(json.dumps(obj) + "\n") # type: ignore[union-attr]
80
+ self.proc.stdin.flush() # type: ignore[union-attr]
81
+
82
+ def _notify(self, method: str, params: dict) -> None:
83
+ self._send({"jsonrpc": "2.0", "method": method, "params": params})
84
+
85
+ def _request(self, method: str, params: dict, timeout: float = 30) -> Optional[dict]:
86
+ self._id += 1
87
+ rid = self._id
88
+ self._send({"jsonrpc": "2.0", "id": rid, "method": method, "params": params})
89
+ # Drain until we see the matching response (skip notifications/logs).
90
+ while True:
91
+ try:
92
+ msg = self._inbox.get(timeout=timeout)
93
+ except queue.Empty:
94
+ return None
95
+ if msg.get("id") == rid:
96
+ if "error" in msg:
97
+ return {"isError": True,
98
+ "content": [{"type": "text",
99
+ "text": json.dumps(msg["error"])}]}
100
+ return msg.get("result", {})
101
+
102
+
103
+ class McpManager:
104
+ """Owns all connected servers and exposes their tools to the agent."""
105
+
106
+ def __init__(self):
107
+ self.clients: dict[str, McpClient] = {}
108
+
109
+ def connect_all(self, servers: dict, on_status=lambda s: None) -> None:
110
+ for name, cfg in (servers or {}).items():
111
+ try:
112
+ c = McpClient(name, cfg["command"], cfg.get("args", []),
113
+ cfg.get("env"))
114
+ c.initialize()
115
+ self.clients[name] = c
116
+ on_status(f"MCP '{name}': {len(c.tools)} tools")
117
+ except Exception as e:
118
+ on_status(f"MCP '{name}' failed: {e}")
119
+
120
+ def schemas(self) -> list[dict]:
121
+ """OpenAI-format tool schemas for every MCP tool, namespaced."""
122
+ out = []
123
+ for name, client in self.clients.items():
124
+ for t in client.tools:
125
+ out.append({"type": "function", "function": {
126
+ "name": f"mcp__{name}__{t['name']}",
127
+ "description": f"[{name}] {t.get('description', '')}",
128
+ "parameters": t.get("inputSchema",
129
+ {"type": "object", "properties": {}}),
130
+ }})
131
+ return out
132
+
133
+ def handles(self, tool_name: str) -> bool:
134
+ return tool_name.startswith("mcp__")
135
+
136
+ def call(self, tool_name: str, args: dict) -> str:
137
+ try:
138
+ _, server, tool = tool_name.split("__", 2)
139
+ except ValueError:
140
+ return f"ERROR: malformed MCP tool name '{tool_name}'"
141
+ client = self.clients.get(server)
142
+ if not client:
143
+ return f"ERROR: no MCP server '{server}'"
144
+ return client.call(tool, args)
145
+
146
+ def close(self) -> None:
147
+ for c in self.clients.values():
148
+ c.close()
149
+
150
+
151
+ def _merged_env(env: Optional[dict]) -> Optional[dict]:
152
+ if not env:
153
+ return None
154
+ import os
155
+ merged = dict(os.environ)
156
+ merged.update({k: str(v) for k, v in env.items()})
157
+ return merged
xcode/memory.py ADDED
@@ -0,0 +1,34 @@
1
+ """Project memory: an XCODE.md file at the repo root that's auto-loaded into the
2
+ system prompt, so the agent remembers project conventions across sessions.
3
+
4
+ Mirrors how Claude Code uses CLAUDE.md.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ CANDIDATES = ["XCODE.md", "CLAUDE.md", ".xcode/XCODE.md"]
12
+
13
+ INIT_INSTRUCTION = (
14
+ "Explore this project and write an XCODE.md file at the repo root. "
15
+ "Use list_dir, glob_files, grep and read_file to understand it. The file "
16
+ "should be concise and cover: what the project is, how to build/run/test it, "
17
+ "the main directories and entry points, and any conventions a new contributor "
18
+ "should follow. Keep it under ~60 lines. When done, write the file with "
19
+ "write_file and give a one-line confirmation."
20
+ )
21
+
22
+
23
+ def load() -> str:
24
+ """Return the contents of the first project-memory file found, or ''."""
25
+ for name in CANDIDATES:
26
+ p = Path(name)
27
+ if p.is_file():
28
+ try:
29
+ text = p.read_text(encoding="utf-8", errors="replace").strip()
30
+ if text:
31
+ return f"# Project memory ({name})\n{text}"
32
+ except Exception:
33
+ pass
34
+ return ""