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/cli.py
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
"""The xcode REPL + headless runner.
|
|
2
|
+
|
|
3
|
+
Themed UI: ghost+trees welcome box, streaming output, persistent permissions,
|
|
4
|
+
diff-colored confirmations, auto mode, model picker, todos, @file mentions,
|
|
5
|
+
session save/resume, and a context meter.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from rich.console import Console, Group
|
|
16
|
+
from rich.live import Live
|
|
17
|
+
from rich.markup import escape
|
|
18
|
+
from rich.panel import Panel
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
|
|
21
|
+
from .agent import Agent
|
|
22
|
+
from .backends import detect_backend, list_models
|
|
23
|
+
from .config import CONTEXT_TOKENS
|
|
24
|
+
from . import (memory, session, ui, hooks as hooks_mod, mcp as mcp_mod,
|
|
25
|
+
input_bar, tools as tools_mod)
|
|
26
|
+
from .permissions import Permissions
|
|
27
|
+
|
|
28
|
+
console = Console()
|
|
29
|
+
|
|
30
|
+
# (command, description) — drives both /help and the slash-completion dropdown.
|
|
31
|
+
COMMANDS = [
|
|
32
|
+
("/help", "Show available commands"),
|
|
33
|
+
("/model", "Switch the active model"),
|
|
34
|
+
("/models", "List models on reachable backends"),
|
|
35
|
+
("/auto", "Toggle auto mode (run & write without asking)"),
|
|
36
|
+
("/theme", "Switch theme: ghost, matrix, dracula, ember, mono"),
|
|
37
|
+
("/mcp", "List connected MCP servers and their tools"),
|
|
38
|
+
("/init", "Explore the project and write an XCODE.md"),
|
|
39
|
+
("/memory", "Show the loaded project memory (XCODE.md)"),
|
|
40
|
+
("/todos", "Show the current task list"),
|
|
41
|
+
("/perms", "Show or clear saved permissions (/perms reset)"),
|
|
42
|
+
("/compact", "Summarize the conversation to free up context"),
|
|
43
|
+
("/sessions", "List saved sessions"),
|
|
44
|
+
("/resume", "Resume the latest (or a given) session"),
|
|
45
|
+
("/reset", "Clear the conversation"),
|
|
46
|
+
("/exit", "Quit xcode"),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
HELP = "Commands:\n" + "\n".join(
|
|
50
|
+
f" {name:<10} {desc}" for name, desc in COMMANDS
|
|
51
|
+
) + "\n\nType a request to work. Use @path to attach files."
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ----------------------------------------------------------------- UI glue
|
|
55
|
+
|
|
56
|
+
class UI:
|
|
57
|
+
def __init__(self, backend, perms: Permissions, theme: dict,
|
|
58
|
+
mode: str = "normal", quiet: bool = False,
|
|
59
|
+
auto_allow: bool = False):
|
|
60
|
+
self.backend = backend
|
|
61
|
+
self.perms = perms
|
|
62
|
+
self.theme = theme
|
|
63
|
+
self.mode = mode # "normal" | "auto"
|
|
64
|
+
self.quiet = quiet # headless: suppress chatter
|
|
65
|
+
self.auto_allow = auto_allow # headless --yes
|
|
66
|
+
self.last_file = None # shown on the right of the input bar
|
|
67
|
+
self._streaming = False
|
|
68
|
+
# live "✻ Envisioning…" spinner state
|
|
69
|
+
self._think = None # ui.ThinkingStatus
|
|
70
|
+
self._live = None # rich.live.Live
|
|
71
|
+
self._partial = "" # current unfinished line of the reply
|
|
72
|
+
|
|
73
|
+
# ---- the Claude-Code-style thinking spinner ---------------------------
|
|
74
|
+
def _live_ok(self) -> bool:
|
|
75
|
+
return not self.quiet and console.is_terminal
|
|
76
|
+
|
|
77
|
+
def _live_render(self) -> Group:
|
|
78
|
+
parts = []
|
|
79
|
+
if self._partial:
|
|
80
|
+
parts.append(Text(self._partial, style="white"))
|
|
81
|
+
parts.append(self._think)
|
|
82
|
+
return Group(*parts)
|
|
83
|
+
|
|
84
|
+
def on_wait_start(self) -> None:
|
|
85
|
+
self._partial = ""
|
|
86
|
+
if not self._live_ok():
|
|
87
|
+
return
|
|
88
|
+
self._think = ui.ThinkingStatus(self.theme,
|
|
89
|
+
get_shells=tools_mod.background_count)
|
|
90
|
+
self._live = Live(self._live_render(), console=console,
|
|
91
|
+
refresh_per_second=12, transient=True)
|
|
92
|
+
self._live.start()
|
|
93
|
+
|
|
94
|
+
def on_wait_end(self) -> None:
|
|
95
|
+
if self._live is not None:
|
|
96
|
+
if self._partial: # flush the last, unfinished line
|
|
97
|
+
self._live.console.print(self._partial, style="white",
|
|
98
|
+
highlight=False)
|
|
99
|
+
self._partial = ""
|
|
100
|
+
self._live.stop()
|
|
101
|
+
self._live = None
|
|
102
|
+
self._think = None
|
|
103
|
+
self._streaming = False
|
|
104
|
+
|
|
105
|
+
def on_token(self, text: str) -> None:
|
|
106
|
+
# Live mode: stream finished lines above the pinned spinner.
|
|
107
|
+
if self._live is not None:
|
|
108
|
+
if self._think is not None:
|
|
109
|
+
self._think.tokens += max(1, len(text) // 4)
|
|
110
|
+
data = self._partial + text
|
|
111
|
+
*done, self._partial = data.split("\n")
|
|
112
|
+
for line in done:
|
|
113
|
+
self._live.console.print(line, style="white", highlight=False)
|
|
114
|
+
self._live.update(self._live_render())
|
|
115
|
+
return
|
|
116
|
+
# Fallback (headless / no TTY): plain inline streaming.
|
|
117
|
+
# Also used for command output streaming
|
|
118
|
+
self._streaming = True
|
|
119
|
+
console.print(text, end="", style="white", highlight=False)
|
|
120
|
+
console.file.flush()
|
|
121
|
+
|
|
122
|
+
def on_turn_end(self) -> None:
|
|
123
|
+
if self._streaming:
|
|
124
|
+
console.print()
|
|
125
|
+
self._streaming = False
|
|
126
|
+
|
|
127
|
+
def on_tool(self, name: str, args: dict) -> None:
|
|
128
|
+
if args.get("path"):
|
|
129
|
+
self.last_file = Path(args["path"]).name
|
|
130
|
+
if self.quiet or name in ("update_todos", "ask_user"):
|
|
131
|
+
return
|
|
132
|
+
verb, target = _friendly(name, args)
|
|
133
|
+
console.print(f"[{self.theme['accent']}]●[/] [bold]{escape(verb)}[/]"
|
|
134
|
+
f"([{self.theme['user']}]{escape(target)}[/])")
|
|
135
|
+
# For run_command, add a visual separator before streaming output
|
|
136
|
+
if name == "run_command":
|
|
137
|
+
console.print(f" [{self.theme['tool']}]┌─ output ─[/]")
|
|
138
|
+
|
|
139
|
+
def on_tool_result(self, name: str, args: dict, result: str) -> None:
|
|
140
|
+
if self.quiet or name == "update_todos":
|
|
141
|
+
return
|
|
142
|
+
if name == "ask_user": # the menu already showed the Q&A
|
|
143
|
+
return
|
|
144
|
+
# For run_command, output was already streamed, just show summary
|
|
145
|
+
if name == "run_command":
|
|
146
|
+
summary, ok = _summary(name, result)
|
|
147
|
+
glyph = "[green]⎿[/]" if ok else "[red]⎿[/]"
|
|
148
|
+
console.print(f" [{self.theme['tool']}]└─[/]")
|
|
149
|
+
console.print(f" {glyph} [dim]{escape(summary)}[/]")
|
|
150
|
+
return
|
|
151
|
+
summary, ok = _summary(name, result)
|
|
152
|
+
glyph = "[green]⎿[/]" if ok else "[red]⎿[/]"
|
|
153
|
+
console.print(f" {glyph} [dim]{escape(summary)}[/]")
|
|
154
|
+
if name in ("edit_file", "write_file") and tools_mod.RENDER.get("diff"):
|
|
155
|
+
_render_numbered_diff(tools_mod.RENDER["diff"])
|
|
156
|
+
|
|
157
|
+
def on_todos(self, todos: list) -> None:
|
|
158
|
+
if not self.quiet:
|
|
159
|
+
_render_todos(todos, self.theme)
|
|
160
|
+
|
|
161
|
+
def on_notice(self, msg: str) -> None:
|
|
162
|
+
if not self.quiet:
|
|
163
|
+
console.print(f"[dim italic]… {msg}[/]")
|
|
164
|
+
|
|
165
|
+
def on_ask(self, question: str, options: list) -> str:
|
|
166
|
+
options = [str(o) for o in options if str(o).strip()]
|
|
167
|
+
if self.quiet or not options: # headless: take the first option
|
|
168
|
+
return options[0] if options else ""
|
|
169
|
+
choice = input_bar.select_menu(question, options)
|
|
170
|
+
# Record the Q&A in scrollback (the menu itself erases on exit).
|
|
171
|
+
console.print(f"[{self.theme['accent']}]●[/] {escape(question)}")
|
|
172
|
+
console.print(f" [{self.theme['user']}]❯ {escape(choice)}[/]")
|
|
173
|
+
return choice
|
|
174
|
+
|
|
175
|
+
def confirm(self, kind: str, target: str, detail: str) -> bool:
|
|
176
|
+
if self.auto_allow or self.mode == "auto" or self.perms.is_allowed(kind, target):
|
|
177
|
+
return True # silent — the ● header + mode bar already show intent
|
|
178
|
+
|
|
179
|
+
# Plan mode: explore but make no changes.
|
|
180
|
+
if self.mode == "plan":
|
|
181
|
+
console.print(f"[dim yellow]· plan mode — skipping {kind}[/]")
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
# Headless/non-interactive: never block on a prompt — deny mutations.
|
|
185
|
+
if self.quiet:
|
|
186
|
+
console.print(f"[dim yellow]· denied ({kind}); pass --yes to allow[/]")
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
console.print(Panel(_colorize_diff(detail), title=f"[yellow]{kind}?[/]",
|
|
190
|
+
border_style="yellow", expand=False))
|
|
191
|
+
|
|
192
|
+
ALWAYS = "Yes, and don't ask again"
|
|
193
|
+
if kind == "run_command":
|
|
194
|
+
always_desc = f"trust `{target.split()[0] if target.split() else target}` from now on"
|
|
195
|
+
else:
|
|
196
|
+
always_desc = f"trust all {kind} this session"
|
|
197
|
+
try:
|
|
198
|
+
choice = input_bar.select_menu(
|
|
199
|
+
f"Allow this {kind}?",
|
|
200
|
+
[("Yes", "run it once"),
|
|
201
|
+
(ALWAYS, always_desc),
|
|
202
|
+
("No", "skip it and tell the model")],
|
|
203
|
+
)
|
|
204
|
+
except (EOFError, KeyboardInterrupt):
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
if choice == "No":
|
|
208
|
+
return False
|
|
209
|
+
if choice == ALWAYS:
|
|
210
|
+
if kind == "run_command":
|
|
211
|
+
head = self.perms.allow_command(target)
|
|
212
|
+
console.print(f"[dim green]· will always allow `{head} …`[/]")
|
|
213
|
+
else:
|
|
214
|
+
self.perms.allow_kind(kind)
|
|
215
|
+
console.print(f"[dim green]· will always allow {kind}[/]")
|
|
216
|
+
return True
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _short(v, n: int = 60) -> str:
|
|
220
|
+
s = str(v).replace("\n", " ")
|
|
221
|
+
return s if len(s) <= n else s[:n] + "…"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ---- Claude-Code-style tool rendering ----------------------------------
|
|
225
|
+
|
|
226
|
+
def _friendly(name: str, args: dict) -> tuple[str, str]:
|
|
227
|
+
"""Map a tool call to a (Verb, target) pair for the ● header line."""
|
|
228
|
+
if name.startswith("mcp__"):
|
|
229
|
+
try:
|
|
230
|
+
_, server, tool = name.split("__", 2)
|
|
231
|
+
except ValueError:
|
|
232
|
+
server, tool = "mcp", name
|
|
233
|
+
return f"{server}.{tool}", _short(next(iter(args.values()), ""), 50)
|
|
234
|
+
table = {
|
|
235
|
+
"read_file": ("Read", args.get("path", "")),
|
|
236
|
+
"write_file": ("Write", args.get("path", "")),
|
|
237
|
+
"edit_file": ("Update", args.get("path", "")),
|
|
238
|
+
"list_dir": ("List", args.get("path", ".")),
|
|
239
|
+
"glob_files": ("Search", args.get("pattern", "")),
|
|
240
|
+
"grep": ("Search", args.get("pattern", "")),
|
|
241
|
+
"run_command": ("Bash", _short(args.get("command", ""), 70)),
|
|
242
|
+
"web_search": ("Web Search", args.get("query", "")),
|
|
243
|
+
"web_fetch": ("Fetch", args.get("url", "")),
|
|
244
|
+
"spawn_agent": ("Task", _short(args.get("task", ""), 50)),
|
|
245
|
+
}
|
|
246
|
+
return table.get(name, (name, _short(next(iter(args.values()), ""), 50)))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _count_lines(text: str) -> int:
|
|
250
|
+
return len(text.splitlines())
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _summary(name: str, result: str) -> tuple[str, bool]:
|
|
254
|
+
"""A one-line ⎿ summary plus whether it succeeded."""
|
|
255
|
+
if result.startswith(("ERROR", "DENIED")):
|
|
256
|
+
return (result.splitlines()[0][:80], False)
|
|
257
|
+
if name in ("edit_file", "write_file"):
|
|
258
|
+
diff = tools_mod.RENDER.get("diff", "")
|
|
259
|
+
added = sum(1 for l in diff.splitlines()
|
|
260
|
+
if l.startswith("+") and not l.startswith("+++"))
|
|
261
|
+
removed = sum(1 for l in diff.splitlines()
|
|
262
|
+
if l.startswith("-") and not l.startswith("---"))
|
|
263
|
+
return (f"Added {added} line{'s'*(added!=1)}, "
|
|
264
|
+
f"removed {removed} line{'s'*(removed!=1)}", True)
|
|
265
|
+
if name == "read_file":
|
|
266
|
+
return (f"Read {_count_lines(result)} lines", True)
|
|
267
|
+
if name == "list_dir":
|
|
268
|
+
return (f"{_count_lines(result)} items", True)
|
|
269
|
+
if name == "glob_files":
|
|
270
|
+
n = 0 if result.startswith("(no files") else _count_lines(result)
|
|
271
|
+
return (f"Found {n} file{'s'*(n!=1)}", True)
|
|
272
|
+
if name == "grep":
|
|
273
|
+
n = 0 if result.startswith("(no matches") else _count_lines(result)
|
|
274
|
+
return (f"{n} match{'es'*(n!=1)}", True)
|
|
275
|
+
if name == "run_command":
|
|
276
|
+
ok = "[exit 0]" in result or not result
|
|
277
|
+
body = [l for l in result.splitlines()
|
|
278
|
+
if l.strip() and not l.startswith("[exit")]
|
|
279
|
+
first = body[0] if body else "(no output)"
|
|
280
|
+
return (_short(first, 80), ok)
|
|
281
|
+
if name == "web_search":
|
|
282
|
+
n = sum(1 for l in result.splitlines() if l.startswith("- "))
|
|
283
|
+
return (f"{n} result{'s'*(n!=1)}", True)
|
|
284
|
+
if name == "web_fetch":
|
|
285
|
+
return (f"Fetched {len(result)} chars", True)
|
|
286
|
+
if name == "spawn_agent":
|
|
287
|
+
return (f"Sub-agent finished ({len(result)} chars)", True)
|
|
288
|
+
return (_short(result.splitlines()[0] if result else "ok", 80), True)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _render_numbered_diff(diff: str, max_lines: int = 12) -> None:
|
|
292
|
+
"""Render a unified diff like Claude Code: line numbers + green/red."""
|
|
293
|
+
new_no = 0
|
|
294
|
+
shown = 0
|
|
295
|
+
for line in diff.splitlines():
|
|
296
|
+
if line.startswith(("---", "+++")):
|
|
297
|
+
continue
|
|
298
|
+
if line.startswith("@@"):
|
|
299
|
+
m = re.search(r"\+(\d+)", line)
|
|
300
|
+
new_no = int(m.group(1)) if m else new_no
|
|
301
|
+
continue
|
|
302
|
+
if shown >= max_lines:
|
|
303
|
+
console.print(" [dim]…[/]")
|
|
304
|
+
break
|
|
305
|
+
content = escape(line[1:]) if line else ""
|
|
306
|
+
if line.startswith("+"):
|
|
307
|
+
console.print(f" [dim]{new_no:>4}[/] [green]+ {content}[/]")
|
|
308
|
+
new_no += 1; shown += 1
|
|
309
|
+
elif line.startswith("-"):
|
|
310
|
+
console.print(f" [dim] [/][red]- {content}[/]")
|
|
311
|
+
shown += 1
|
|
312
|
+
else:
|
|
313
|
+
console.print(f" [dim]{new_no:>4} {content}[/]")
|
|
314
|
+
new_no += 1; shown += 1
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _colorize_diff(text: str) -> Text:
|
|
318
|
+
t = Text()
|
|
319
|
+
for line in text.splitlines(keepends=True):
|
|
320
|
+
s = line.rstrip("\n")
|
|
321
|
+
if s.startswith(("+++", "---")):
|
|
322
|
+
t.append(line, style="bold")
|
|
323
|
+
elif s.startswith("+"):
|
|
324
|
+
t.append(line, style="green")
|
|
325
|
+
elif s.startswith("-"):
|
|
326
|
+
t.append(line, style="red")
|
|
327
|
+
elif s.startswith("@@"):
|
|
328
|
+
t.append(line, style="cyan")
|
|
329
|
+
else:
|
|
330
|
+
t.append(line)
|
|
331
|
+
return t
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _render_todos(todos: list, theme: dict) -> None:
|
|
335
|
+
if not todos:
|
|
336
|
+
console.print("[dim](no todos)[/]")
|
|
337
|
+
return
|
|
338
|
+
marks = {"completed": "[green]✓[/]", "in_progress": f"[{theme['mode']}]▶[/]",
|
|
339
|
+
"pending": "[dim]○[/]"}
|
|
340
|
+
lines = []
|
|
341
|
+
for t in todos:
|
|
342
|
+
mark = marks.get(t.get("status", "pending"), "○")
|
|
343
|
+
text = t.get("content", "")
|
|
344
|
+
if t.get("status") == "completed":
|
|
345
|
+
lines.append(f" {mark} [dim strike]{text}[/]")
|
|
346
|
+
else:
|
|
347
|
+
lines.append(f" {mark} {text}")
|
|
348
|
+
console.print(Panel("\n".join(lines), title="todos",
|
|
349
|
+
border_style=theme["border"], expand=False, padding=(0, 1)))
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ------------------------------------------------------------ @file mentions
|
|
353
|
+
|
|
354
|
+
_MENTION = re.compile(r"@([^\s]+)")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _expand_mentions(text: str) -> str:
|
|
358
|
+
attached = []
|
|
359
|
+
for m in _MENTION.finditer(text):
|
|
360
|
+
p = Path(m.group(1))
|
|
361
|
+
if p.is_file():
|
|
362
|
+
try:
|
|
363
|
+
body = p.read_text(encoding="utf-8", errors="replace")[:8000]
|
|
364
|
+
attached.append(f"--- {p} ---\n{body}")
|
|
365
|
+
except Exception:
|
|
366
|
+
pass
|
|
367
|
+
if not attached:
|
|
368
|
+
return text
|
|
369
|
+
return text + "\n\n[Attached files]\n" + "\n\n".join(attached)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# ----------------------------------------------------------------- builders
|
|
373
|
+
|
|
374
|
+
def _make_agent(uic: UI, settings=None, mcp=None) -> Agent:
|
|
375
|
+
return Agent(uic.backend, confirm=uic.confirm, on_token=uic.on_token,
|
|
376
|
+
on_turn_end=uic.on_turn_end, on_tool=uic.on_tool,
|
|
377
|
+
on_tool_result=uic.on_tool_result, on_ask=uic.on_ask,
|
|
378
|
+
on_todos=uic.on_todos, on_notice=uic.on_notice,
|
|
379
|
+
on_wait_start=uic.on_wait_start, on_wait_end=uic.on_wait_end,
|
|
380
|
+
project_memory=memory.load(), settings=settings, mcp=mcp)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _pick_model(uic: UI) -> None:
|
|
384
|
+
options: list[tuple[str, str]] = []
|
|
385
|
+
for name, models in list_models().items():
|
|
386
|
+
for mdl in models:
|
|
387
|
+
label = f"{mdl} ✓" if mdl == uic.backend.model else mdl
|
|
388
|
+
options.append((label, f"on {name}"))
|
|
389
|
+
if not options:
|
|
390
|
+
console.print("[yellow]no models found[/]")
|
|
391
|
+
return
|
|
392
|
+
choice = input_bar.select_menu("Switch model", options)
|
|
393
|
+
if not choice:
|
|
394
|
+
return
|
|
395
|
+
# Strip the current-model checkmark we may have appended to the label.
|
|
396
|
+
picked = choice[:-2] if choice.endswith(" ✓") else choice
|
|
397
|
+
if picked == uic.backend.model:
|
|
398
|
+
return
|
|
399
|
+
uic.backend.model = picked
|
|
400
|
+
console.print(f"[green]switched to[/] {uic.backend.model}")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# ----------------------------------------------------------------- headless
|
|
404
|
+
|
|
405
|
+
def _run_headless(args) -> int:
|
|
406
|
+
try:
|
|
407
|
+
backend = detect_backend()
|
|
408
|
+
except RuntimeError as e:
|
|
409
|
+
console.print(f"[red]{e}[/]")
|
|
410
|
+
return 1
|
|
411
|
+
if args.model:
|
|
412
|
+
backend.model = args.model
|
|
413
|
+
prefs = ui.load_prefs()
|
|
414
|
+
perms = Permissions()
|
|
415
|
+
settings = hooks_mod.Settings()
|
|
416
|
+
settings.seed_permissions(perms)
|
|
417
|
+
uic = UI(backend, perms, ui.get_theme(prefs.get("theme", "ghost")),
|
|
418
|
+
quiet=True, auto_allow=args.yes)
|
|
419
|
+
mcp = mcp_mod.McpManager()
|
|
420
|
+
mcp.connect_all(settings.data.get("mcpServers", {}))
|
|
421
|
+
agent = _make_agent(uic, settings=settings, mcp=mcp)
|
|
422
|
+
if args.resume:
|
|
423
|
+
data = session.latest()
|
|
424
|
+
if data:
|
|
425
|
+
agent.load_messages(data["messages"])
|
|
426
|
+
try:
|
|
427
|
+
agent.send(_expand_mentions(args.print))
|
|
428
|
+
except Exception as e:
|
|
429
|
+
console.print(f"[red]error: {e}[/]")
|
|
430
|
+
return 1
|
|
431
|
+
return 0
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# ----------------------------------------------------------------- REPL
|
|
435
|
+
|
|
436
|
+
def _run_repl(args) -> int:
|
|
437
|
+
try:
|
|
438
|
+
backend = detect_backend()
|
|
439
|
+
except RuntimeError as e:
|
|
440
|
+
console.print(f"[red]{e}[/]")
|
|
441
|
+
return 1
|
|
442
|
+
if args.model:
|
|
443
|
+
backend.model = args.model
|
|
444
|
+
|
|
445
|
+
prefs = ui.load_prefs()
|
|
446
|
+
theme = ui.get_theme(prefs.get("theme", "ghost"))
|
|
447
|
+
perms = Permissions()
|
|
448
|
+
settings = hooks_mod.Settings()
|
|
449
|
+
settings.seed_permissions(perms)
|
|
450
|
+
uic = UI(backend, perms, theme, mode=prefs.get("mode", "normal"))
|
|
451
|
+
|
|
452
|
+
mcp = mcp_mod.McpManager()
|
|
453
|
+
# Don't connect MCP on startup - lazy load when needed
|
|
454
|
+
# mcp.connect_all(settings.data.get("mcpServers", {}),
|
|
455
|
+
# on_status=lambda s: console.print(f"[dim]· {s}[/]"))
|
|
456
|
+
|
|
457
|
+
agent = _make_agent(uic, settings=settings, mcp=mcp)
|
|
458
|
+
session_id = None
|
|
459
|
+
|
|
460
|
+
notes = []
|
|
461
|
+
if memory.load():
|
|
462
|
+
notes.append("XCODE.md")
|
|
463
|
+
if settings.loaded:
|
|
464
|
+
notes.append("settings.json")
|
|
465
|
+
mem_note = (" " + " · ".join(notes)) if notes else ""
|
|
466
|
+
console.print(ui.welcome(theme, backend.model, str(Path.cwd()), mem_note))
|
|
467
|
+
console.print()
|
|
468
|
+
|
|
469
|
+
def _save_mode(m):
|
|
470
|
+
prefs["mode"] = m
|
|
471
|
+
ui.save_prefs(prefs)
|
|
472
|
+
bar = input_bar.InputBar(uic, on_mode_change=_save_mode, commands=COMMANDS)
|
|
473
|
+
|
|
474
|
+
if args.resume:
|
|
475
|
+
data = session.latest()
|
|
476
|
+
if data:
|
|
477
|
+
agent.load_messages(data["messages"])
|
|
478
|
+
session_id = data["id"]
|
|
479
|
+
console.print(f"[green]resumed[/] session {session_id} "
|
|
480
|
+
f"({len(data['messages'])} msgs)\n")
|
|
481
|
+
|
|
482
|
+
while True:
|
|
483
|
+
if not input_bar.AVAILABLE: # plain fallback shows a status line
|
|
484
|
+
console.print(ui.status_line(theme, uic.mode, agent.context_tokens(),
|
|
485
|
+
CONTEXT_TOKENS, backend.model))
|
|
486
|
+
console.rule(style=theme["border"])
|
|
487
|
+
try:
|
|
488
|
+
raw = bar.ask(backend.model, agent.conversation_tokens, CONTEXT_TOKENS).strip()
|
|
489
|
+
except (EOFError, KeyboardInterrupt):
|
|
490
|
+
console.print("\n[dim]bye 👻[/]")
|
|
491
|
+
break
|
|
492
|
+
if not raw:
|
|
493
|
+
continue
|
|
494
|
+
|
|
495
|
+
# ---- slash commands ----
|
|
496
|
+
if raw in ("/exit", "/quit"):
|
|
497
|
+
console.print("[dim]bye 👻[/]"); break
|
|
498
|
+
if raw == "/help":
|
|
499
|
+
console.print(HELP); continue
|
|
500
|
+
if raw == "/auto":
|
|
501
|
+
uic.mode = "auto" if uic.mode == "normal" else "normal"
|
|
502
|
+
prefs["mode"] = uic.mode; ui.save_prefs(prefs)
|
|
503
|
+
if uic.mode == "auto":
|
|
504
|
+
console.print(f"[{theme['mode']}]⏵⏵ auto mode ON[/] — "
|
|
505
|
+
"running & writing without asking")
|
|
506
|
+
else:
|
|
507
|
+
console.print("[dim]·· auto mode off — I'll ask before changes[/]")
|
|
508
|
+
continue
|
|
509
|
+
if raw.startswith("/theme"):
|
|
510
|
+
parts = raw.split()
|
|
511
|
+
if len(parts) > 1 and parts[1] in ui.THEMES:
|
|
512
|
+
theme = ui.get_theme(parts[1]); uic.theme = theme
|
|
513
|
+
prefs["theme"] = parts[1]; ui.save_prefs(prefs)
|
|
514
|
+
console.print(ui.welcome(theme, backend.model, str(Path.cwd())))
|
|
515
|
+
else:
|
|
516
|
+
console.print(f"themes: {', '.join(ui.THEMES)} "
|
|
517
|
+
f"(usage: /theme matrix)")
|
|
518
|
+
continue
|
|
519
|
+
if raw == "/models":
|
|
520
|
+
for name, models in list_models().items():
|
|
521
|
+
console.print(f"[bold]{name}[/]: {', '.join(models) or '(none)'}")
|
|
522
|
+
continue
|
|
523
|
+
if raw == "/mcp":
|
|
524
|
+
if not mcp.clients:
|
|
525
|
+
console.print("[dim]no MCP servers connected "
|
|
526
|
+
"(add them in .xcode/settings.json)[/]")
|
|
527
|
+
for sname, client in mcp.clients.items():
|
|
528
|
+
tools = ", ".join(t["name"] for t in client.tools) or "(none)"
|
|
529
|
+
console.print(f"[bold]{sname}[/]: {tools}")
|
|
530
|
+
continue
|
|
531
|
+
if raw == "/memory":
|
|
532
|
+
mem = memory.load()
|
|
533
|
+
console.print(mem if mem else "[dim]no XCODE.md found (run /init)[/]")
|
|
534
|
+
continue
|
|
535
|
+
if raw == "/model":
|
|
536
|
+
_pick_model(uic); continue
|
|
537
|
+
if raw == "/todos":
|
|
538
|
+
_render_todos(agent.todos, theme); continue
|
|
539
|
+
if raw == "/perms":
|
|
540
|
+
console.print(f"[bold]saved permissions:[/] {uic.perms.summary()}")
|
|
541
|
+
continue
|
|
542
|
+
if raw == "/perms reset":
|
|
543
|
+
uic.perms.reset(); console.print("[dim]permissions cleared[/]"); continue
|
|
544
|
+
if raw == "/compact":
|
|
545
|
+
did = agent.compact(force=True)
|
|
546
|
+
console.print("[dim]compacted[/]" if did else "[dim]nothing to compact[/]")
|
|
547
|
+
continue
|
|
548
|
+
if raw == "/sessions":
|
|
549
|
+
rows = session.listing()
|
|
550
|
+
if not rows:
|
|
551
|
+
console.print("[dim](no saved sessions)[/]")
|
|
552
|
+
for r in rows:
|
|
553
|
+
console.print(f" [cyan]{r['id']}[/] · {r['turns']} turns · "
|
|
554
|
+
f"{r['model']} · [dim]{r['first']}[/]")
|
|
555
|
+
continue
|
|
556
|
+
if raw.startswith("/resume"):
|
|
557
|
+
parts = raw.split()
|
|
558
|
+
data = session.load(parts[1]) if len(parts) > 1 else session.latest()
|
|
559
|
+
if not data:
|
|
560
|
+
console.print("[yellow]no such session[/]")
|
|
561
|
+
else:
|
|
562
|
+
agent.load_messages(data["messages"]); session_id = data["id"]
|
|
563
|
+
console.print(f"[green]resumed[/] {session_id}")
|
|
564
|
+
continue
|
|
565
|
+
if raw == "/reset":
|
|
566
|
+
agent.reset(); session_id = None
|
|
567
|
+
console.print("[dim]conversation cleared[/]"); continue
|
|
568
|
+
if raw == "/init":
|
|
569
|
+
raw = memory.INIT_INSTRUCTION
|
|
570
|
+
|
|
571
|
+
# ---- a real request ----
|
|
572
|
+
try:
|
|
573
|
+
agent.send(_expand_mentions(raw))
|
|
574
|
+
session_id = session.save(agent.messages, backend.model, session_id)
|
|
575
|
+
except KeyboardInterrupt:
|
|
576
|
+
uic.on_turn_end(); console.print("\n[yellow]interrupted[/]")
|
|
577
|
+
except Exception as e:
|
|
578
|
+
uic.on_turn_end(); console.print(f"[red]error: {e}[/]")
|
|
579
|
+
console.print()
|
|
580
|
+
return 0
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def main() -> None:
|
|
584
|
+
parser = argparse.ArgumentParser(prog="xcode",
|
|
585
|
+
description="Local-model coding agent.")
|
|
586
|
+
parser.add_argument("-p", "--print", metavar="PROMPT",
|
|
587
|
+
help="headless: run one prompt, print result, exit")
|
|
588
|
+
parser.add_argument("-m", "--model", help="force a model name")
|
|
589
|
+
parser.add_argument("--resume", action="store_true",
|
|
590
|
+
help="resume the most recent session")
|
|
591
|
+
parser.add_argument("--yes", action="store_true",
|
|
592
|
+
help="headless: auto-approve writes/commands")
|
|
593
|
+
args = parser.parse_args()
|
|
594
|
+
|
|
595
|
+
rc = _run_headless(args) if args.print else _run_repl(args)
|
|
596
|
+
sys.exit(rc)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
if __name__ == "__main__":
|
|
600
|
+
main()
|
xcode/config.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""System prompt and assorted knobs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
MAX_AGENT_STEPS = int(os.getenv("XCODE_MAX_STEPS", "25"))
|
|
8
|
+
|
|
9
|
+
# Context budget (in estimated tokens) before we auto-summarize old turns.
|
|
10
|
+
# Local models often have small windows, so default conservatively.
|
|
11
|
+
CONTEXT_TOKENS = int(os.getenv("XCODE_CONTEXT_TOKENS", "8000"))
|
|
12
|
+
COMPACT_AT = float(os.getenv("XCODE_COMPACT_AT", "0.75")) # fraction of budget
|
|
13
|
+
KEEP_RECENT = int(os.getenv("XCODE_KEEP_RECENT", "8")) # msgs kept verbatim
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def estimate_tokens(messages: list[dict]) -> int:
|
|
17
|
+
"""Rough, backend-agnostic token estimate (~4 chars/token)."""
|
|
18
|
+
chars = 0
|
|
19
|
+
for m in messages:
|
|
20
|
+
chars += len(m.get("content") or "")
|
|
21
|
+
for tc in m.get("tool_calls", []) or []:
|
|
22
|
+
chars += len(tc.get("function", {}).get("arguments", ""))
|
|
23
|
+
return chars // 4
|
|
24
|
+
|
|
25
|
+
SYSTEM_PROMPT = """\
|
|
26
|
+
You are xcode, a CLI coding agent running on the user's machine. You help with \
|
|
27
|
+
software engineering tasks by reading and writing files and running shell commands.
|
|
28
|
+
|
|
29
|
+
If the user asks who made you, who created you, who built you, who's behind the \
|
|
30
|
+
platform, or anything like that, answer that you were made by @c7s89r. Don't \
|
|
31
|
+
mention any other company or model provider as your creator.
|
|
32
|
+
|
|
33
|
+
You have these tools:
|
|
34
|
+
- read_file(path) read a file (shown with line numbers)
|
|
35
|
+
- write_file(path, content) create/overwrite a file (needs approval)
|
|
36
|
+
- edit_file(path, old_string, new_str) replace exact text once (needs approval)
|
|
37
|
+
- list_dir(path) list a directory
|
|
38
|
+
- glob_files(pattern, path) find files, e.g. '**/*.py'
|
|
39
|
+
- grep(pattern, path, glob) search file contents by regex
|
|
40
|
+
- run_command(command) run a shell command (needs approval)
|
|
41
|
+
- ask_user(question, options) ask the user to pick from a short list
|
|
42
|
+
- update_todos(todos) track a multi-step plan (status: \
|
|
43
|
+
pending|in_progress|completed)
|
|
44
|
+
|
|
45
|
+
ASKING QUESTIONS — this matters a lot. Before building anything non-trivial, \
|
|
46
|
+
make sure you actually know what the user wants. If the request is open-ended or \
|
|
47
|
+
under-specified ("make me a server", "build a bot", "set this up"), do NOT just \
|
|
48
|
+
start guessing and writing code. First gather the requirements by calling \
|
|
49
|
+
ask_user, one question at a time, with 2-5 concise options each. Ask several \
|
|
50
|
+
questions in a row if needed — scope, tech/stack choices, where it runs/hosts, \
|
|
51
|
+
naming, styling, which features to include, defaults vs custom — until you have \
|
|
52
|
+
enough to build the RIGHT thing. Each option should be a real, distinct choice; \
|
|
53
|
+
add a short option so the user can say "you pick" when they don't care. Treat it \
|
|
54
|
+
like a quick interview: a few good questions up front beats building the wrong \
|
|
55
|
+
thing. Only skip questions when the task is unambiguous or you can reasonably \
|
|
56
|
+
decide yourself — don't interrogate the user over trivia.
|
|
57
|
+
|
|
58
|
+
For any task with multiple steps, call update_todos early to lay out the plan, \
|
|
59
|
+
then keep it current: mark a step in_progress before you start it and completed \
|
|
60
|
+
when it's done. Keep exactly one step in_progress at a time. Skip todos for \
|
|
61
|
+
trivial single-step tasks.
|
|
62
|
+
|
|
63
|
+
Guidelines:
|
|
64
|
+
- Work step by step. Explore with glob_files / grep / read_file before editing.
|
|
65
|
+
- Make the smallest change that solves the task. Match existing style.
|
|
66
|
+
- Prefer edit_file for small changes; use write_file for new/rewritten files.
|
|
67
|
+
- For edit_file, old_string must match exactly once — include enough context.
|
|
68
|
+
- After making changes, when sensible, run a command to verify (tests, build, run).
|
|
69
|
+
- Be concise in your prose. Don't narrate every token; explain what matters.
|
|
70
|
+
- When the task is done, give a short summary and stop calling tools.
|
|
71
|
+
- You're on the user's real filesystem — be careful with destructive commands.
|
|
72
|
+
"""
|