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/__init__.py +3 -0
- xcode/__main__.py +4 -0
- xcode/agent.py +341 -0
- xcode/backends.py +141 -0
- xcode/cli.py +600 -0
- xcode/config.py +72 -0
- xcode/hooks.py +74 -0
- xcode/input_bar.py +357 -0
- xcode/mcp.py +157 -0
- xcode/memory.py +34 -0
- xcode/permissions.py +80 -0
- xcode/session.py +67 -0
- xcode/tools.py +451 -0
- xcode/ui.py +349 -0
- xcoding-0.1.0.dist-info/METADATA +190 -0
- xcoding-0.1.0.dist-info/RECORD +20 -0
- xcoding-0.1.0.dist-info/WHEEL +5 -0
- xcoding-0.1.0.dist-info/entry_points.txt +2 -0
- xcoding-0.1.0.dist-info/licenses/LICENSE +21 -0
- xcoding-0.1.0.dist-info/top_level.txt +1 -0
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 ""
|