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/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
+ """