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/permissions.py ADDED
@@ -0,0 +1,80 @@
1
+ """Persistent permission rules, stored per-project in .xcode/permissions.json.
2
+
3
+ The model proposes an action (write a file, run a command); the CLI asks the
4
+ user. If the user picks "always", we remember it here so we stop asking.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ STORE = Path(".xcode") / "permissions.json"
13
+
14
+
15
+ class Permissions:
16
+ def __init__(self, store: Path = STORE):
17
+ self.store = store
18
+ self.tools: set[str] = set() # tool kinds always allowed wholesale
19
+ self.cmd_prefixes: set[str] = set() # command first-words always allowed
20
+ self._load()
21
+
22
+ # ---- queries -----------------------------------------------------------
23
+ def is_allowed(self, kind: str, target: str) -> bool:
24
+ if kind in self.tools:
25
+ return True
26
+ if kind == "run_command":
27
+ head = _first_word(target)
28
+ return head in self.cmd_prefixes
29
+ return False
30
+
31
+ # ---- mutations ---------------------------------------------------------
32
+ def allow_kind(self, kind: str) -> None:
33
+ self.tools.add(kind)
34
+ self._save()
35
+
36
+ def allow_command(self, target: str) -> str:
37
+ head = _first_word(target)
38
+ self.cmd_prefixes.add(head)
39
+ self._save()
40
+ return head
41
+
42
+ def reset(self) -> None:
43
+ self.tools.clear()
44
+ self.cmd_prefixes.clear()
45
+ if self.store.exists():
46
+ self.store.unlink()
47
+
48
+ def summary(self) -> str:
49
+ parts = []
50
+ if self.tools:
51
+ parts.append("tools: " + ", ".join(sorted(self.tools)))
52
+ if self.cmd_prefixes:
53
+ parts.append("commands: " + ", ".join(sorted(self.cmd_prefixes)))
54
+ return " · ".join(parts) or "(none yet)"
55
+
56
+ # ---- persistence -------------------------------------------------------
57
+ def _load(self) -> None:
58
+ if not self.store.exists():
59
+ return
60
+ try:
61
+ data = json.loads(self.store.read_text(encoding="utf-8"))
62
+ self.tools = set(data.get("tools", []))
63
+ self.cmd_prefixes = set(data.get("cmd_prefixes", []))
64
+ except Exception:
65
+ pass # corrupt store: start clean, don't crash
66
+
67
+ def _save(self) -> None:
68
+ try:
69
+ self.store.parent.mkdir(parents=True, exist_ok=True)
70
+ self.store.write_text(json.dumps({
71
+ "tools": sorted(self.tools),
72
+ "cmd_prefixes": sorted(self.cmd_prefixes),
73
+ }, indent=2), encoding="utf-8")
74
+ except Exception:
75
+ pass
76
+
77
+
78
+ def _first_word(command: str) -> str:
79
+ toks = command.strip().split()
80
+ return toks[0] if toks else ""
xcode/session.py ADDED
@@ -0,0 +1,67 @@
1
+ """Save / load / list conversation sessions under .xcode/sessions/.
2
+
3
+ A session is just the message list plus a little metadata, so you can quit and
4
+ pick up where you left off with `xcode --resume`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import time
11
+ from pathlib import Path
12
+
13
+ DIR = Path(".xcode") / "sessions"
14
+
15
+
16
+ def _slug() -> str:
17
+ return time.strftime("%Y%m%d-%H%M%S")
18
+
19
+
20
+ def save(messages: list[dict], model: str, session_id: str | None) -> str:
21
+ DIR.mkdir(parents=True, exist_ok=True)
22
+ sid = session_id or _slug()
23
+ path = DIR / f"{sid}.json"
24
+ payload = {
25
+ "id": sid,
26
+ "model": model,
27
+ "updated": time.time(),
28
+ "messages": messages,
29
+ }
30
+ path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
31
+ return sid
32
+
33
+
34
+ def latest() -> dict | None:
35
+ sessions = _all_paths()
36
+ return _read(sessions[0]) if sessions else None
37
+
38
+
39
+ def load(session_id: str) -> dict | None:
40
+ path = DIR / f"{session_id}.json"
41
+ return _read(path) if path.exists() else None
42
+
43
+
44
+ def listing() -> list[dict]:
45
+ out = []
46
+ for p in _all_paths():
47
+ d = _read(p)
48
+ if not d:
49
+ continue
50
+ msgs = [m for m in d.get("messages", []) if m.get("role") == "user"]
51
+ first = msgs[0]["content"][:60] if msgs else "(empty)"
52
+ out.append({"id": d.get("id", p.stem), "model": d.get("model", "?"),
53
+ "turns": len(msgs), "first": first})
54
+ return out
55
+
56
+
57
+ def _all_paths() -> list[Path]:
58
+ if not DIR.exists():
59
+ return []
60
+ return sorted(DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
61
+
62
+
63
+ def _read(path: Path) -> dict | None:
64
+ try:
65
+ return json.loads(path.read_text(encoding="utf-8"))
66
+ except Exception:
67
+ return None
xcode/tools.py ADDED
@@ -0,0 +1,451 @@
1
+ """The tools the agent can call, their JSON schemas, and a dispatcher.
2
+
3
+ Each tool returns a string (what the model sees as the result). Tools that
4
+ mutate the system (write_file, edit_file, run_command) go through a confirm()
5
+ callback: confirm(kind, target, detail) -> bool, where the CLI may consult
6
+ persistent permission rules and/or ask the user.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import difflib
12
+ import html
13
+ import re
14
+ import subprocess
15
+ from pathlib import Path
16
+ from typing import Callable
17
+ from urllib.parse import parse_qs, unquote, urlparse
18
+
19
+ import httpx
20
+
21
+ # confirm(kind, target, detail) -> allow?
22
+ # kind = stable id of the action ("write_file", "run_command", ...)
23
+ # target = the thing acted on (a path, or the command string)
24
+ # detail = human-readable preview shown to the user
25
+ Confirm = Callable[[str, str, str], bool]
26
+
27
+ MAX_OUTPUT = 20_000 # clip giant tool results so we don't blow context
28
+
29
+ # Side channel: the last write/edit stashes its diff here so the CLI can render
30
+ # a Claude-Code-style numbered diff even in auto mode (where there's no confirm
31
+ # preview). Reset at the start of every dispatch().
32
+ RENDER: dict = {}
33
+ SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv",
34
+ ".xcode", "dist", "build", ".mypy_cache", ".pytest_cache"}
35
+
36
+ # Long-running commands started with background=True keep running here so the
37
+ # agent can fire off a server/build and carry on. background_count() prunes the
38
+ # finished ones and reports how many are still alive (shown in the spinner).
39
+ _BG: list = []
40
+
41
+
42
+ def background_count() -> int:
43
+ """How many background shells are still running (finished ones pruned)."""
44
+ _BG[:] = [p for p in _BG if p.poll() is None]
45
+ return len(_BG)
46
+
47
+
48
+ def _clip(text: str) -> str:
49
+ if len(text) > MAX_OUTPUT:
50
+ return text[:MAX_OUTPUT] + f"\n... [clipped {len(text) - MAX_OUTPUT} chars]"
51
+ return text
52
+
53
+
54
+ def _skipped(p: Path) -> bool:
55
+ return any(part in SKIP_DIRS for part in p.parts)
56
+
57
+
58
+ def _diff(old: str, new: str, path: str) -> str:
59
+ """A compact unified diff for confirmation previews."""
60
+ lines = list(difflib.unified_diff(
61
+ old.splitlines(), new.splitlines(),
62
+ fromfile=path, tofile=path, lineterm="", n=2))
63
+ if not lines:
64
+ return "(no textual changes)"
65
+ if len(lines) > 80:
66
+ lines = lines[:80] + [f"... [{len(lines) - 80} more diff lines]"]
67
+ return "\n".join(lines)
68
+
69
+
70
+ # ---------------------------------------------------------------- tool impls
71
+
72
+ def read_file(path: str) -> str:
73
+ p = Path(path)
74
+ if not p.exists():
75
+ return f"ERROR: no such file: {path}"
76
+ if p.is_dir():
77
+ return f"ERROR: {path} is a directory (use list_dir)"
78
+ try:
79
+ text = p.read_text(encoding="utf-8", errors="replace")
80
+ numbered = "\n".join(f"{i:>5} {ln}"
81
+ for i, ln in enumerate(text.splitlines(), 1))
82
+ return _clip(numbered)
83
+ except Exception as e:
84
+ return f"ERROR reading {path}: {e}"
85
+
86
+
87
+ def write_file(path: str, content: str, confirm: Confirm) -> str:
88
+ p = Path(path)
89
+ old = p.read_text(encoding="utf-8", errors="replace") if p.exists() else ""
90
+ diff = _diff(old, content, path)
91
+ RENDER["diff"] = diff
92
+ detail = f"{'Overwrite' if p.exists() else 'Create'} {path}\n\n{diff}"
93
+ if not confirm("write_file", path, detail):
94
+ return "DENIED: user declined the write."
95
+ try:
96
+ p.parent.mkdir(parents=True, exist_ok=True)
97
+ p.write_text(content, encoding="utf-8")
98
+ return f"OK: wrote {len(content)} chars to {path}"
99
+ except Exception as e:
100
+ return f"ERROR writing {path}: {e}"
101
+
102
+
103
+ def edit_file(path: str, old_string: str, new_string: str, confirm: Confirm) -> str:
104
+ p = Path(path)
105
+ if not p.exists():
106
+ return f"ERROR: no such file: {path}"
107
+ try:
108
+ text = p.read_text(encoding="utf-8", errors="replace")
109
+ except Exception as e:
110
+ return f"ERROR reading {path}: {e}"
111
+
112
+ count = text.count(old_string)
113
+ if count == 0:
114
+ return ("ERROR: old_string not found. It must match the file exactly "
115
+ "(including whitespace). Read the file first.")
116
+ if count > 1:
117
+ return (f"ERROR: old_string appears {count} times; add surrounding "
118
+ "context so it matches exactly once.")
119
+
120
+ new_text = text.replace(old_string, new_string, 1)
121
+ diff = _diff(text, new_text, path)
122
+ RENDER["diff"] = diff
123
+ detail = f"Edit {path}\n\n{diff}"
124
+ if not confirm("edit_file", path, detail):
125
+ return "DENIED: user declined the edit."
126
+ try:
127
+ p.write_text(new_text, encoding="utf-8")
128
+ return f"OK: edited {path}"
129
+ except Exception as e:
130
+ return f"ERROR writing {path}: {e}"
131
+
132
+
133
+ def list_dir(path: str = ".") -> str:
134
+ p = Path(path)
135
+ if not p.exists():
136
+ return f"ERROR: no such path: {path}"
137
+ if p.is_file():
138
+ return f"{path} (file, {p.stat().st_size} bytes)"
139
+ try:
140
+ entries = sorted(p.iterdir(), key=lambda e: (e.is_file(), e.name.lower()))
141
+ lines = [f"{'DIR ' if e.is_dir() else 'FILE'} {e.name}" for e in entries]
142
+ return "\n".join(lines) or "(empty directory)"
143
+ except Exception as e:
144
+ return f"ERROR listing {path}: {e}"
145
+
146
+
147
+ def glob_files(pattern: str, path: str = ".") -> str:
148
+ base = Path(path)
149
+ if not base.exists():
150
+ return f"ERROR: no such path: {path}"
151
+ try:
152
+ matches = [str(m) for m in sorted(base.glob(pattern))
153
+ if m.is_file() and not _skipped(m)]
154
+ except Exception as e:
155
+ return f"ERROR in glob '{pattern}': {e}"
156
+ if not matches:
157
+ return f"(no files match {pattern})"
158
+ extra = ""
159
+ if len(matches) > 200:
160
+ extra = f"\n... [{len(matches) - 200} more]"
161
+ matches = matches[:200]
162
+ return "\n".join(matches) + extra
163
+
164
+
165
+ def grep(pattern: str, path: str = ".", glob: str = "**/*") -> str:
166
+ try:
167
+ rx = re.compile(pattern)
168
+ except re.error as e:
169
+ return f"ERROR: bad regex: {e}"
170
+ base = Path(path)
171
+ if not base.exists():
172
+ return f"ERROR: no such path: {path}"
173
+
174
+ hits: list[str] = []
175
+ for f in base.glob(glob):
176
+ if not f.is_file() or _skipped(f):
177
+ continue
178
+ try:
179
+ for i, line in enumerate(f.read_text(encoding="utf-8",
180
+ errors="ignore").splitlines(), 1):
181
+ if rx.search(line):
182
+ hits.append(f"{f}:{i}: {line.strip()[:200]}")
183
+ if len(hits) >= 200:
184
+ return "\n".join(hits) + "\n... [stopped at 200 matches]"
185
+ except Exception:
186
+ continue
187
+ return "\n".join(hits) if hits else f"(no matches for /{pattern}/)"
188
+
189
+
190
+ def run_command(command: str, confirm: Confirm, background: bool = False,
191
+ on_output: Callable[[str], None] | None = None) -> str:
192
+ """Run a shell command with real-time streaming output.
193
+
194
+ Args:
195
+ command: Shell command to execute
196
+ confirm: Permission callback
197
+ background: Run detached without waiting
198
+ on_output: Optional callback for streaming output line-by-line
199
+ """
200
+ if not confirm("run_command", command, command):
201
+ return "DENIED: user declined the command."
202
+
203
+ if background:
204
+ try:
205
+ proc = subprocess.Popen(command, shell=True,
206
+ stdout=subprocess.DEVNULL,
207
+ stderr=subprocess.DEVNULL)
208
+ _BG.append(proc)
209
+ return (f"OK: started in background (pid {proc.pid}); "
210
+ f"{background_count()} background shell(s) running.")
211
+ except Exception as e:
212
+ return f"ERROR starting background command: {e}"
213
+
214
+ # Stream output in real-time if callback provided
215
+ if on_output:
216
+ try:
217
+ # Use unbuffered output for real-time streaming
218
+ proc = subprocess.Popen(
219
+ command,
220
+ shell=True,
221
+ stdout=subprocess.PIPE,
222
+ stderr=subprocess.STDOUT, # Merge stderr into stdout
223
+ text=True,
224
+ bufsize=0, # Unbuffered
225
+ universal_newlines=True
226
+ )
227
+
228
+ output_lines = []
229
+
230
+ # Read output character by character for true real-time streaming
231
+ while True:
232
+ char = proc.stdout.read(1)
233
+ if not char:
234
+ # Process finished
235
+ if proc.poll() is not None:
236
+ break
237
+ continue
238
+
239
+ # Stream each character immediately
240
+ on_output(char)
241
+
242
+ # Also collect for final result
243
+ if char == '\n':
244
+ output_lines.append('')
245
+ elif output_lines or char.strip():
246
+ if not output_lines:
247
+ output_lines.append('')
248
+ output_lines[-1] = output_lines[-1] + char
249
+
250
+ proc.wait(timeout=120)
251
+
252
+ # Build result
253
+ body = '\n'.join(output_lines).strip()
254
+ body += f"\n[exit {proc.returncode}]"
255
+
256
+ return _clip(body)
257
+
258
+ except subprocess.TimeoutExpired:
259
+ proc.kill()
260
+ return "ERROR: command timed out after 120s"
261
+ except Exception as e:
262
+ return f"ERROR running command: {e}"
263
+
264
+ # Fallback to non-streaming mode
265
+ try:
266
+ proc = subprocess.run(command, shell=True, capture_output=True,
267
+ text=True, timeout=120)
268
+ body = proc.stdout or ""
269
+ if proc.stderr:
270
+ body += "\n[stderr]\n" + proc.stderr
271
+ body += f"\n[exit {proc.returncode}]"
272
+ return _clip(body.strip())
273
+ except subprocess.TimeoutExpired:
274
+ return "ERROR: command timed out after 120s"
275
+ except Exception as e:
276
+ return f"ERROR running command: {e}"
277
+
278
+
279
+ def _trim(s: str, n: int = 300) -> str:
280
+ s = s.replace("\n", "\\n")
281
+ return s if len(s) <= n else s[:n] + "…"
282
+
283
+
284
+ # ------------------------------------------------------------------- web
285
+
286
+ _TAG = re.compile(r"<[^>]+>")
287
+ _WS = re.compile(r"[ \t]*\n[ \t\n]*")
288
+
289
+
290
+ def _html_to_text(raw: str) -> str:
291
+ raw = re.sub(r"(?is)<(script|style)[^>]*>.*?</\1>", " ", raw)
292
+ raw = re.sub(r"(?i)<br\s*/?>", "\n", raw)
293
+ raw = re.sub(r"(?i)</(p|div|li|h[1-6]|tr)>", "\n", raw)
294
+ text = html.unescape(_TAG.sub("", raw))
295
+ return _WS.sub("\n", text).strip()
296
+
297
+
298
+ def web_fetch(url: str) -> str:
299
+ if not url.startswith(("http://", "https://")):
300
+ url = "https://" + url
301
+ try:
302
+ r = httpx.get(url, timeout=15, follow_redirects=True,
303
+ headers={"User-Agent": "Mozilla/5.0 (xcode)"})
304
+ r.raise_for_status()
305
+ except Exception as e:
306
+ return f"ERROR fetching {url}: {e}"
307
+ ctype = r.headers.get("content-type", "")
308
+ body = _html_to_text(r.text) if "html" in ctype else r.text
309
+ return _clip(f"# {url}\n\n{body}")
310
+
311
+
312
+ def _ddg_unwrap(href: str) -> str:
313
+ if "duckduckgo.com/l/" in href:
314
+ q = parse_qs(urlparse(href).query)
315
+ if "uddg" in q:
316
+ return unquote(q["uddg"][0])
317
+ return href
318
+
319
+
320
+ def web_search(query: str) -> str:
321
+ try:
322
+ r = httpx.post("https://html.duckduckgo.com/html/",
323
+ data={"q": query}, timeout=15, follow_redirects=True,
324
+ headers={"User-Agent": "Mozilla/5.0 (xcode)"})
325
+ r.raise_for_status()
326
+ except Exception as e:
327
+ return f"ERROR searching: {e}"
328
+
329
+ results = []
330
+ pat = re.compile(
331
+ r'result__a"[^>]*href="(?P<url>[^"]+)"[^>]*>(?P<title>.*?)</a>'
332
+ r'(?:.*?result__snippet[^>]*>(?P<snip>.*?)</a>)?',
333
+ re.IGNORECASE | re.DOTALL)
334
+ for m in pat.finditer(r.text):
335
+ url = _ddg_unwrap(html.unescape(m.group("url")))
336
+ title = _html_to_text(m.group("title"))
337
+ snip = _html_to_text(m.group("snip") or "")
338
+ results.append(f"- {title}\n {url}\n {snip}".rstrip())
339
+ if len(results) >= 6:
340
+ break
341
+ if not results:
342
+ return f"(no results for '{query}')"
343
+ return f"Results for '{query}':\n\n" + "\n\n".join(results)
344
+
345
+
346
+ # ------------------------------------------------------------------ schemas
347
+
348
+ def _fn(name, desc, props, required=()):
349
+ return {"type": "function", "function": {
350
+ "name": name, "description": desc,
351
+ "parameters": {"type": "object", "properties": props,
352
+ "required": list(required)}}}
353
+
354
+
355
+ TOOL_SCHEMAS = [
356
+ _fn("read_file", "Read a file's contents (returned with line numbers).",
357
+ {"path": {"type": "string"}}, ["path"]),
358
+ _fn("write_file", "Create or overwrite a file with the given full content.",
359
+ {"path": {"type": "string"}, "content": {"type": "string"}},
360
+ ["path", "content"]),
361
+ _fn("edit_file",
362
+ "Replace an exact substring in a file. old_string must match exactly "
363
+ "once. Prefer this over write_file for small changes.",
364
+ {"path": {"type": "string"},
365
+ "old_string": {"type": "string", "description": "exact text to replace"},
366
+ "new_string": {"type": "string", "description": "replacement text"}},
367
+ ["path", "old_string", "new_string"]),
368
+ _fn("list_dir", "List files and folders at a path (default current dir).",
369
+ {"path": {"type": "string"}}),
370
+ _fn("glob_files", "Find files by glob pattern, e.g. '**/*.py'.",
371
+ {"pattern": {"type": "string"},
372
+ "path": {"type": "string", "description": "root to search, default '.'"}},
373
+ ["pattern"]),
374
+ _fn("grep",
375
+ "Search file contents by regex. Returns path:line: text matches.",
376
+ {"pattern": {"type": "string", "description": "regular expression"},
377
+ "path": {"type": "string", "description": "root, default '.'"},
378
+ "glob": {"type": "string", "description": "file filter, default '**/*'"}},
379
+ ["pattern"]),
380
+ _fn("run_command",
381
+ "Run a shell command; returns stdout/stderr/exit code. Set "
382
+ "background=true for long-running things (servers, watchers) to start "
383
+ "them detached and keep working.",
384
+ {"command": {"type": "string"},
385
+ "background": {"type": "boolean",
386
+ "description": "run detached, don't wait for it"}},
387
+ ["command"]),
388
+ _fn("web_search", "Search the web (DuckDuckGo). Returns titles/urls/snippets.",
389
+ {"query": {"type": "string"}}, ["query"]),
390
+ _fn("web_fetch", "Fetch a URL and return its text content.",
391
+ {"url": {"type": "string"}}, ["url"]),
392
+ _fn("spawn_agent",
393
+ "Delegate a self-contained subtask to a fresh sub-agent with its own "
394
+ "context. Use for big searches or isolated chunks of work. Returns the "
395
+ "sub-agent's final report.",
396
+ {"task": {"type": "string", "description": "what the sub-agent should do"}},
397
+ ["task"]),
398
+ _fn("ask_user",
399
+ "Ask the user a clarifying question and let them pick from a short list "
400
+ "(rendered as an arrow-key menu). Use this PROACTIVELY whenever the "
401
+ "request is open-ended or ambiguous — call it repeatedly, one question "
402
+ "at a time, to nail down scope, stack, hosting, naming, features, etc. "
403
+ "before building. Returns the chosen option.",
404
+ {"question": {"type": "string",
405
+ "description": "one focused question"},
406
+ "options": {"type": "array", "items": {"type": "string"},
407
+ "description": "2-5 concise, distinct choices; optionally "
408
+ "include a 'you decide' style option"}},
409
+ ["question", "options"]),
410
+ _fn("update_todos",
411
+ "Record/update the task plan for a multi-step request.",
412
+ {"todos": {"type": "array", "items": {"type": "object", "properties": {
413
+ "content": {"type": "string"},
414
+ "status": {"type": "string",
415
+ "enum": ["pending", "in_progress", "completed"]}},
416
+ "required": ["content", "status"]}}},
417
+ ["todos"]),
418
+ ]
419
+
420
+
421
+ def dispatch(name: str, args: dict, confirm: Confirm, on_output: Callable[[str], None] | None = None) -> str:
422
+ """Route a tool call from the model to the right implementation."""
423
+ RENDER.clear()
424
+ try:
425
+ if name == "read_file":
426
+ return read_file(args["path"])
427
+ if name == "write_file":
428
+ return write_file(args["path"], args["content"], confirm)
429
+ if name == "edit_file":
430
+ return edit_file(args["path"], args["old_string"],
431
+ args["new_string"], confirm)
432
+ if name == "list_dir":
433
+ return list_dir(args.get("path", "."))
434
+ if name == "glob_files":
435
+ return glob_files(args["pattern"], args.get("path", "."))
436
+ if name == "grep":
437
+ return grep(args["pattern"], args.get("path", "."),
438
+ args.get("glob", "**/*"))
439
+ if name == "run_command":
440
+ return run_command(args["command"], confirm,
441
+ background=bool(args.get("background", False)),
442
+ on_output=on_output)
443
+ if name == "web_search":
444
+ return web_search(args["query"])
445
+ if name == "web_fetch":
446
+ return web_fetch(args["url"])
447
+ return f"ERROR: unknown tool '{name}'"
448
+ except KeyError as e:
449
+ return f"ERROR: tool '{name}' missing required argument {e}"
450
+ except Exception as e:
451
+ return f"ERROR in tool '{name}': {e}"