@oh-my-pi/pi-coding-agent 5.5.0 → 5.6.70

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.
Files changed (98) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/docs/python-repl.md +77 -0
  3. package/examples/hooks/snake.ts +7 -7
  4. package/package.json +5 -5
  5. package/src/bun-imports.d.ts +6 -0
  6. package/src/cli/args.ts +7 -0
  7. package/src/cli/setup-cli.ts +231 -0
  8. package/src/cli.ts +2 -0
  9. package/src/core/agent-session.ts +118 -15
  10. package/src/core/bash-executor.ts +3 -84
  11. package/src/core/compaction/compaction.ts +10 -5
  12. package/src/core/extensions/index.ts +2 -0
  13. package/src/core/extensions/loader.ts +13 -1
  14. package/src/core/extensions/runner.ts +50 -2
  15. package/src/core/extensions/types.ts +67 -2
  16. package/src/core/keybindings.ts +51 -1
  17. package/src/core/prompt-templates.ts +15 -0
  18. package/src/core/python-executor-display.test.ts +42 -0
  19. package/src/core/python-executor-lifecycle.test.ts +99 -0
  20. package/src/core/python-executor-mapping.test.ts +41 -0
  21. package/src/core/python-executor-per-call.test.ts +49 -0
  22. package/src/core/python-executor-session.test.ts +103 -0
  23. package/src/core/python-executor-streaming.test.ts +77 -0
  24. package/src/core/python-executor-timeout.test.ts +35 -0
  25. package/src/core/python-executor.lifecycle.test.ts +139 -0
  26. package/src/core/python-executor.result.test.ts +49 -0
  27. package/src/core/python-executor.test.ts +180 -0
  28. package/src/core/python-executor.ts +313 -0
  29. package/src/core/python-gateway-coordinator.ts +832 -0
  30. package/src/core/python-kernel-display.test.ts +54 -0
  31. package/src/core/python-kernel-env.test.ts +138 -0
  32. package/src/core/python-kernel-session.test.ts +87 -0
  33. package/src/core/python-kernel-ws.test.ts +104 -0
  34. package/src/core/python-kernel.lifecycle.test.ts +249 -0
  35. package/src/core/python-kernel.test.ts +461 -0
  36. package/src/core/python-kernel.ts +1182 -0
  37. package/src/core/python-modules.test.ts +102 -0
  38. package/src/core/python-modules.ts +110 -0
  39. package/src/core/python-prelude.py +889 -0
  40. package/src/core/python-prelude.test.ts +140 -0
  41. package/src/core/python-prelude.ts +3 -0
  42. package/src/core/sdk.ts +24 -6
  43. package/src/core/session-manager.ts +174 -82
  44. package/src/core/settings-manager-python.test.ts +23 -0
  45. package/src/core/settings-manager.ts +202 -0
  46. package/src/core/streaming-output.test.ts +26 -0
  47. package/src/core/streaming-output.ts +100 -0
  48. package/src/core/system-prompt.python.test.ts +17 -0
  49. package/src/core/system-prompt.ts +3 -1
  50. package/src/core/timings.ts +1 -1
  51. package/src/core/tools/bash.ts +13 -2
  52. package/src/core/tools/edit-diff.ts +9 -1
  53. package/src/core/tools/index.test.ts +50 -23
  54. package/src/core/tools/index.ts +83 -1
  55. package/src/core/tools/python-execution.test.ts +68 -0
  56. package/src/core/tools/python-fallback.test.ts +72 -0
  57. package/src/core/tools/python-renderer.test.ts +36 -0
  58. package/src/core/tools/python-tool-mode.test.ts +43 -0
  59. package/src/core/tools/python.test.ts +121 -0
  60. package/src/core/tools/python.ts +760 -0
  61. package/src/core/tools/renderers.ts +2 -0
  62. package/src/core/tools/schema-validation.test.ts +1 -0
  63. package/src/core/tools/task/executor.ts +146 -3
  64. package/src/core/tools/task/worker-protocol.ts +32 -2
  65. package/src/core/tools/task/worker.ts +182 -15
  66. package/src/index.ts +6 -0
  67. package/src/main.ts +136 -40
  68. package/src/modes/interactive/components/custom-editor.ts +16 -31
  69. package/src/modes/interactive/components/extensions/extension-dashboard.ts +5 -16
  70. package/src/modes/interactive/components/extensions/extension-list.ts +5 -13
  71. package/src/modes/interactive/components/history-search.ts +5 -8
  72. package/src/modes/interactive/components/hook-editor.ts +3 -4
  73. package/src/modes/interactive/components/hook-input.ts +3 -3
  74. package/src/modes/interactive/components/hook-selector.ts +5 -15
  75. package/src/modes/interactive/components/index.ts +1 -0
  76. package/src/modes/interactive/components/keybinding-hints.ts +66 -0
  77. package/src/modes/interactive/components/model-selector.ts +53 -66
  78. package/src/modes/interactive/components/oauth-selector.ts +5 -5
  79. package/src/modes/interactive/components/session-selector.ts +29 -23
  80. package/src/modes/interactive/components/settings-defs.ts +404 -196
  81. package/src/modes/interactive/components/settings-selector.ts +14 -10
  82. package/src/modes/interactive/components/status-line-segment-editor.ts +7 -7
  83. package/src/modes/interactive/components/tool-execution.ts +8 -0
  84. package/src/modes/interactive/components/tree-selector.ts +29 -23
  85. package/src/modes/interactive/components/user-message-selector.ts +6 -17
  86. package/src/modes/interactive/controllers/command-controller.ts +86 -37
  87. package/src/modes/interactive/controllers/event-controller.ts +8 -0
  88. package/src/modes/interactive/controllers/extension-ui-controller.ts +51 -0
  89. package/src/modes/interactive/controllers/input-controller.ts +42 -6
  90. package/src/modes/interactive/interactive-mode.ts +56 -30
  91. package/src/modes/interactive/theme/theme-schema.json +2 -2
  92. package/src/modes/interactive/types.ts +6 -1
  93. package/src/modes/interactive/utils/ui-helpers.ts +2 -1
  94. package/src/modes/print-mode.ts +23 -0
  95. package/src/modes/rpc/rpc-mode.ts +21 -0
  96. package/src/prompts/agents/reviewer.md +1 -1
  97. package/src/prompts/system/system-prompt.md +32 -1
  98. package/src/prompts/tools/python.md +91 -0
@@ -0,0 +1,889 @@
1
+ # OMP IPython prelude helpers
2
+ if "__omp_prelude_loaded__" not in globals():
3
+ __omp_prelude_loaded__ = True
4
+ from pathlib import Path
5
+ import os, sys, re, json, shutil, subprocess, glob, textwrap, inspect
6
+ from datetime import datetime
7
+ from IPython.display import display
8
+
9
+ def _emit_status(op: str, **data):
10
+ """Emit structured status event for TUI rendering."""
11
+ display({"application/x-omp-status": {"op": op, **data}}, raw=True)
12
+
13
+ def _category(cat: str):
14
+ """Decorator to tag a prelude function with its category."""
15
+ def decorator(fn):
16
+ fn._omp_category = cat
17
+ return fn
18
+ return decorator
19
+
20
+ @_category("Navigation")
21
+ def pwd() -> Path:
22
+ """Return current working directory."""
23
+ p = Path.cwd()
24
+ _emit_status("pwd", path=str(p))
25
+ return p
26
+
27
+ @_category("Navigation")
28
+ def cd(path: str | Path) -> Path:
29
+ """Change directory."""
30
+ p = Path(path).expanduser().resolve()
31
+ os.chdir(p)
32
+ _emit_status("cd", path=str(p))
33
+ return p
34
+
35
+ @_category("Shell")
36
+ def env(key: str | None = None, value: str | None = None):
37
+ """Get/set environment variables."""
38
+ if key is None:
39
+ items = dict(sorted(os.environ.items()))
40
+ _emit_status("env", count=len(items), keys=list(items.keys())[:20])
41
+ return items
42
+ if value is not None:
43
+ os.environ[key] = value
44
+ _emit_status("env", key=key, value=value, action="set")
45
+ return value
46
+ val = os.environ.get(key)
47
+ _emit_status("env", key=key, value=val, action="get")
48
+ return val
49
+
50
+ @_category("File I/O")
51
+ def read(path: str | Path, *, offset: int = 1, limit: int | None = None) -> str:
52
+ """Read file contents. offset/limit are 1-indexed line numbers."""
53
+ p = Path(path)
54
+ data = p.read_text(encoding="utf-8")
55
+ lines = data.splitlines(keepends=True)
56
+ if offset > 1 or limit is not None:
57
+ start = max(0, offset - 1)
58
+ end = start + limit if limit else len(lines)
59
+ lines = lines[start:end]
60
+ data = "".join(lines)
61
+ preview = data[:500]
62
+ _emit_status("read", path=str(p), chars=len(data), preview=preview)
63
+ return data
64
+
65
+ @_category("File I/O")
66
+ def write(path: str | Path, content: str) -> Path:
67
+ """Write file contents (create parents)."""
68
+ p = Path(path)
69
+ p.parent.mkdir(parents=True, exist_ok=True)
70
+ p.write_text(content, encoding="utf-8")
71
+ _emit_status("write", path=str(p), chars=len(content))
72
+ return p
73
+
74
+ @_category("File I/O")
75
+ def append(path: str | Path, content: str) -> Path:
76
+ """Append to file."""
77
+ p = Path(path)
78
+ p.parent.mkdir(parents=True, exist_ok=True)
79
+ with p.open("a", encoding="utf-8") as f:
80
+ f.write(content)
81
+ _emit_status("append", path=str(p), chars=len(content))
82
+ return p
83
+
84
+ @_category("File ops")
85
+ def mkdir(path: str | Path) -> Path:
86
+ """Create directory (parents=True)."""
87
+ p = Path(path)
88
+ p.mkdir(parents=True, exist_ok=True)
89
+ _emit_status("mkdir", path=str(p))
90
+ return p
91
+
92
+ @_category("File ops")
93
+ def rm(path: str | Path, *, recursive: bool = False) -> None:
94
+ """Delete file or directory (recursive optional)."""
95
+ p = Path(path)
96
+ if p.is_dir():
97
+ if recursive:
98
+ shutil.rmtree(p)
99
+ _emit_status("rm", path=str(p), recursive=True)
100
+ return
101
+ _emit_status("rm", path=str(p), error="directory, use recursive=True")
102
+ return
103
+ if p.exists():
104
+ p.unlink()
105
+ _emit_status("rm", path=str(p))
106
+ else:
107
+ _emit_status("rm", path=str(p), error="missing")
108
+
109
+ @_category("File ops")
110
+ def mv(src: str | Path, dst: str | Path) -> Path:
111
+ """Move or rename a file/directory."""
112
+ src_p = Path(src)
113
+ dst_p = Path(dst)
114
+ dst_p.parent.mkdir(parents=True, exist_ok=True)
115
+ shutil.move(str(src_p), str(dst_p))
116
+ _emit_status("mv", src=str(src_p), dst=str(dst_p))
117
+ return dst_p
118
+
119
+ @_category("File ops")
120
+ def cp(src: str | Path, dst: str | Path) -> Path:
121
+ """Copy a file or directory."""
122
+ src_p = Path(src)
123
+ dst_p = Path(dst)
124
+ dst_p.parent.mkdir(parents=True, exist_ok=True)
125
+ if src_p.is_dir():
126
+ shutil.copytree(src_p, dst_p, dirs_exist_ok=True)
127
+ else:
128
+ shutil.copy2(src_p, dst_p)
129
+ _emit_status("cp", src=str(src_p), dst=str(dst_p))
130
+ return dst_p
131
+
132
+ @_category("Navigation")
133
+ def ls(path: str | Path = ".") -> list[Path]:
134
+ """List directory contents."""
135
+ p = Path(path)
136
+ items = sorted(p.iterdir())
137
+ _emit_status("ls", path=str(p), count=len(items), items=[i.name + ("/" if i.is_dir() else "") for i in items[:20]])
138
+ return items
139
+
140
+ def _load_gitignore_patterns(base: Path) -> list[str]:
141
+ """Load .gitignore patterns from base directory and parents."""
142
+ patterns: list[str] = []
143
+ # Always exclude these
144
+ patterns.extend(["**/.git", "**/.git/**", "**/node_modules", "**/node_modules/**"])
145
+ # Walk up to find .gitignore files
146
+ current = base.resolve()
147
+ for _ in range(20): # Limit depth
148
+ gitignore = current / ".gitignore"
149
+ if gitignore.exists():
150
+ try:
151
+ for line in gitignore.read_text().splitlines():
152
+ line = line.strip()
153
+ if line and not line.startswith("#"):
154
+ # Normalize pattern for fnmatch
155
+ if line.startswith("/"):
156
+ patterns.append(str(current / line[1:]))
157
+ else:
158
+ patterns.append(f"**/{line}")
159
+ except Exception:
160
+ pass
161
+ parent = current.parent
162
+ if parent == current:
163
+ break
164
+ current = parent
165
+ return patterns
166
+
167
+ def _match_gitignore(path: Path, patterns: list[str], base: Path) -> bool:
168
+ """Check if path matches any gitignore pattern."""
169
+ import fnmatch
170
+ rel = str(path.relative_to(base)) if path.is_relative_to(base) else str(path)
171
+ abs_path = str(path.resolve())
172
+ for pat in patterns:
173
+ if pat.startswith("**/"):
174
+ # Match against any part of the path
175
+ if fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(rel, pat[3:]):
176
+ return True
177
+ # Also check each path component
178
+ for part in path.parts:
179
+ if fnmatch.fnmatch(part, pat[3:]):
180
+ return True
181
+ elif fnmatch.fnmatch(abs_path, pat) or fnmatch.fnmatch(rel, pat):
182
+ return True
183
+ return False
184
+
185
+ @_category("Search")
186
+ def find(
187
+ pattern: str,
188
+ path: str | Path = ".",
189
+ *,
190
+ type: str = "file",
191
+ limit: int = 1000,
192
+ hidden: bool = False,
193
+ sort_by_mtime: bool = False,
194
+ ) -> list[Path]:
195
+ """Recursive glob find. Respects .gitignore."""
196
+ p = Path(path)
197
+ ignore_patterns = _load_gitignore_patterns(p)
198
+ matches: list[Path] = []
199
+ for m in p.rglob(pattern):
200
+ if len(matches) >= limit:
201
+ break
202
+ # Skip hidden files unless requested
203
+ if not hidden and any(part.startswith(".") for part in m.parts):
204
+ continue
205
+ # Skip gitignored paths
206
+ if _match_gitignore(m, ignore_patterns, p):
207
+ continue
208
+ # Filter by type
209
+ if type == "file" and m.is_dir():
210
+ continue
211
+ if type == "dir" and not m.is_dir():
212
+ continue
213
+ matches.append(m)
214
+ if sort_by_mtime:
215
+ matches.sort(key=lambda x: x.stat().st_mtime, reverse=True)
216
+ else:
217
+ matches.sort()
218
+ _emit_status("find", pattern=pattern, path=str(p), count=len(matches), matches=[str(m) for m in matches[:20]])
219
+ return matches
220
+
221
+ @_category("Search")
222
+ def grep(
223
+ pattern: str,
224
+ path: str | Path,
225
+ *,
226
+ ignore_case: bool = False,
227
+ literal: bool = False,
228
+ context: int = 0,
229
+ ) -> list[tuple[int, str]]:
230
+ """Grep a single file. Returns (line_number, text) tuples."""
231
+ p = Path(path)
232
+ lines = p.read_text(encoding="utf-8").splitlines()
233
+ if literal:
234
+ if ignore_case:
235
+ match_fn = lambda line: pattern.lower() in line.lower()
236
+ else:
237
+ match_fn = lambda line: pattern in line
238
+ else:
239
+ flags = re.IGNORECASE if ignore_case else 0
240
+ rx = re.compile(pattern, flags)
241
+ match_fn = lambda line: rx.search(line) is not None
242
+
243
+ match_lines: set[int] = set()
244
+ for i, line in enumerate(lines, 1):
245
+ if match_fn(line):
246
+ match_lines.add(i)
247
+
248
+ # Expand with context
249
+ if context > 0:
250
+ expanded: set[int] = set()
251
+ for ln in match_lines:
252
+ for offset in range(-context, context + 1):
253
+ expanded.add(ln + offset)
254
+ output_lines = sorted(ln for ln in expanded if 1 <= ln <= len(lines))
255
+ else:
256
+ output_lines = sorted(match_lines)
257
+
258
+ hits = [(ln, lines[ln - 1]) for ln in output_lines]
259
+ _emit_status("grep", pattern=pattern, path=str(p), count=len(match_lines), hits=[{"line": h[0], "text": h[1][:100]} for h in hits[:10]])
260
+ return hits
261
+
262
+ @_category("Search")
263
+ def rgrep(
264
+ pattern: str,
265
+ path: str | Path = ".",
266
+ *,
267
+ glob_pattern: str = "*",
268
+ ignore_case: bool = False,
269
+ literal: bool = False,
270
+ limit: int = 100,
271
+ hidden: bool = False,
272
+ ) -> list[tuple[Path, int, str]]:
273
+ """Recursive grep across files matching glob_pattern. Respects .gitignore."""
274
+ if literal:
275
+ if ignore_case:
276
+ match_fn = lambda line: pattern.lower() in line.lower()
277
+ else:
278
+ match_fn = lambda line: pattern in line
279
+ else:
280
+ flags = re.IGNORECASE if ignore_case else 0
281
+ rx = re.compile(pattern, flags)
282
+ match_fn = lambda line: rx.search(line) is not None
283
+
284
+ base = Path(path)
285
+ ignore_patterns = _load_gitignore_patterns(base)
286
+ hits: list[tuple[Path, int, str]] = []
287
+ for file_path in base.rglob(glob_pattern):
288
+ if len(hits) >= limit:
289
+ break
290
+ if file_path.is_dir():
291
+ continue
292
+ # Skip hidden files unless requested
293
+ if not hidden and any(part.startswith(".") for part in file_path.parts):
294
+ continue
295
+ # Skip gitignored paths
296
+ if _match_gitignore(file_path, ignore_patterns, base):
297
+ continue
298
+ try:
299
+ lines = file_path.read_text(encoding="utf-8").splitlines()
300
+ except Exception:
301
+ continue
302
+ for i, line in enumerate(lines, 1):
303
+ if len(hits) >= limit:
304
+ break
305
+ if match_fn(line):
306
+ hits.append((file_path, i, line))
307
+ _emit_status("rgrep", pattern=pattern, path=str(base), count=len(hits), hits=[{"file": str(h[0]), "line": h[1], "text": h[2][:80]} for h in hits[:10]])
308
+ return hits
309
+
310
+ @_category("Text")
311
+ def head(text: str, n: int = 10) -> str:
312
+ """Return the first n lines of text."""
313
+ lines = text.splitlines()[:n]
314
+ out = "\n".join(lines)
315
+ _emit_status("head", lines=len(lines), preview=out[:500])
316
+ return out
317
+
318
+ @_category("Text")
319
+ def tail(text: str, n: int = 10) -> str:
320
+ """Return the last n lines of text."""
321
+ lines = text.splitlines()[-n:]
322
+ out = "\n".join(lines)
323
+ _emit_status("tail", lines=len(lines), preview=out[:500])
324
+ return out
325
+
326
+ @_category("Find/Replace")
327
+ def replace(path: str | Path, pattern: str, repl: str, *, regex: bool = False) -> int:
328
+ """Replace text in a file (regex optional)."""
329
+ p = Path(path)
330
+ data = p.read_text(encoding="utf-8")
331
+ if regex:
332
+ new, count = re.subn(pattern, repl, data)
333
+ else:
334
+ new = data.replace(pattern, repl)
335
+ count = data.count(pattern)
336
+ p.write_text(new, encoding="utf-8")
337
+ _emit_status("replace", path=str(p), count=count)
338
+ return count
339
+
340
+ class ShellResult:
341
+ """Result from shell command execution."""
342
+ __slots__ = ("stdout", "stderr", "code")
343
+ def __init__(self, stdout: str, stderr: str, code: int):
344
+ self.stdout = stdout
345
+ self.stderr = stderr
346
+ self.code = code
347
+ def __repr__(self):
348
+ if self.code == 0:
349
+ return ""
350
+ return f"exit code {self.code}"
351
+ def __bool__(self):
352
+ return self.code == 0
353
+
354
+ def _make_shell_result(proc: subprocess.CompletedProcess[str], cmd: str) -> ShellResult:
355
+ """Create ShellResult and emit status."""
356
+ output = proc.stdout + proc.stderr if proc.stderr else proc.stdout
357
+ _emit_status("sh", cmd=cmd[:80], code=proc.returncode, output=output[:500])
358
+ return ShellResult(proc.stdout, proc.stderr, proc.returncode)
359
+
360
+ import signal as _signal
361
+
362
+ def _run_with_interrupt(args: list[str], cwd: str | None, timeout: int | None, cmd: str) -> ShellResult:
363
+ """Run subprocess with proper interrupt handling."""
364
+ proc = subprocess.Popen(
365
+ args,
366
+ cwd=cwd,
367
+ stdout=subprocess.PIPE,
368
+ stderr=subprocess.PIPE,
369
+ text=True,
370
+ start_new_session=True,
371
+ )
372
+ try:
373
+ stdout, stderr = proc.communicate(timeout=timeout)
374
+ except KeyboardInterrupt:
375
+ os.killpg(proc.pid, _signal.SIGINT)
376
+ try:
377
+ stdout, stderr = proc.communicate(timeout=2)
378
+ except subprocess.TimeoutExpired:
379
+ os.killpg(proc.pid, _signal.SIGKILL)
380
+ stdout, stderr = proc.communicate()
381
+ result = subprocess.CompletedProcess(args, -_signal.SIGINT, stdout, stderr)
382
+ return _make_shell_result(result, cmd)
383
+ except subprocess.TimeoutExpired:
384
+ os.killpg(proc.pid, _signal.SIGKILL)
385
+ stdout, stderr = proc.communicate()
386
+ result = subprocess.CompletedProcess(args, -_signal.SIGKILL, stdout, stderr)
387
+ return _make_shell_result(result, cmd)
388
+ result = subprocess.CompletedProcess(args, proc.returncode, stdout, stderr)
389
+ return _make_shell_result(result, cmd)
390
+
391
+ @_category("Shell")
392
+ def run(cmd: str, *, cwd: str | Path | None = None, timeout: int | None = None) -> ShellResult:
393
+ """Run a shell command."""
394
+ shell_path = shutil.which("bash") or shutil.which("sh") or "/bin/sh"
395
+ args = [shell_path, "-c", cmd]
396
+ return _run_with_interrupt(args, str(cwd) if cwd else None, timeout, cmd)
397
+
398
+ @_category("Shell")
399
+ def sh(cmd: str, *, cwd: str | Path | None = None, timeout: int | None = None) -> ShellResult:
400
+ """Run a shell command via user's login shell with environment snapshot."""
401
+ snapshot = os.environ.get("OMP_SHELL_SNAPSHOT")
402
+ prefix = f"source '{snapshot}' 2>/dev/null && " if snapshot else ""
403
+ final = f"{prefix}{cmd}"
404
+
405
+ shell_path = os.environ.get("SHELL")
406
+ if not shell_path or not shutil.which(shell_path):
407
+ shell_path = shutil.which("bash") or shutil.which("zsh") or shutil.which("sh")
408
+
409
+ if not shell_path:
410
+ if sys.platform.startswith("win"):
411
+ proc = subprocess.run(
412
+ ["cmd", "/c", cmd],
413
+ cwd=str(cwd) if cwd else None,
414
+ capture_output=True,
415
+ text=True,
416
+ timeout=timeout,
417
+ )
418
+ return _make_shell_result(proc, cmd)
419
+ raise RuntimeError("No suitable shell found")
420
+
421
+ no_login = os.environ.get("OMP_BASH_NO_LOGIN") or os.environ.get("CLAUDE_BASH_NO_LOGIN")
422
+ args = [shell_path, "-c", final] if no_login else [shell_path, "-l", "-c", final]
423
+
424
+ return _run_with_interrupt(args, str(cwd) if cwd else None, timeout, cmd)
425
+
426
+ @_category("File I/O")
427
+ def cat(*paths: str | Path, separator: str = "\n") -> str:
428
+ """Concatenate multiple files. Like shell cat."""
429
+ parts = []
430
+ for p in paths:
431
+ parts.append(Path(p).read_text(encoding="utf-8"))
432
+ out = separator.join(parts)
433
+ _emit_status("cat", files=len(paths), chars=len(out), preview=out[:500])
434
+ return out
435
+
436
+ @_category("File I/O")
437
+ def touch(path: str | Path) -> Path:
438
+ """Create empty file or update mtime."""
439
+ p = Path(path)
440
+ p.parent.mkdir(parents=True, exist_ok=True)
441
+ p.touch()
442
+ _emit_status("touch", path=str(p))
443
+ return p
444
+
445
+ @_category("Text")
446
+ def wc(text: str) -> dict:
447
+ """Word/line/char count."""
448
+ lines = text.splitlines()
449
+ words = text.split()
450
+ result = {"lines": len(lines), "words": len(words), "chars": len(text)}
451
+ _emit_status("wc", lines=result["lines"], words=result["words"], chars=result["chars"])
452
+ return result
453
+
454
+ @_category("Text")
455
+ def sort_lines(text: str, *, reverse: bool = False, unique: bool = False) -> str:
456
+ """Sort lines of text."""
457
+ lines = text.splitlines()
458
+ if unique:
459
+ lines = list(dict.fromkeys(lines))
460
+ lines = sorted(lines, reverse=reverse)
461
+ out = "\n".join(lines)
462
+ _emit_status("sort_lines", lines=len(lines), unique=unique, reverse=reverse)
463
+ return out
464
+
465
+ @_category("Text")
466
+ def uniq(text: str, *, count: bool = False) -> str | list[tuple[int, str]]:
467
+ """Remove duplicate adjacent lines (like uniq)."""
468
+ lines = text.splitlines()
469
+ if not lines:
470
+ _emit_status("uniq", groups=0)
471
+ return [] if count else ""
472
+ groups: list[tuple[int, str]] = []
473
+ current = lines[0]
474
+ current_count = 1
475
+ for line in lines[1:]:
476
+ if line == current:
477
+ current_count += 1
478
+ continue
479
+ groups.append((current_count, current))
480
+ current = line
481
+ current_count = 1
482
+ groups.append((current_count, current))
483
+ _emit_status("uniq", groups=len(groups), count_mode=count)
484
+ if count:
485
+ return groups
486
+ return "\n".join(line for _, line in groups)
487
+
488
+ @_category("Text")
489
+ def cols(text: str, *indices: int, sep: str | None = None) -> str:
490
+ """Extract columns from text (0-indexed). Like cut."""
491
+ result_lines = []
492
+ for line in text.splitlines():
493
+ parts = line.split(sep) if sep else line.split()
494
+ selected = [parts[i] for i in indices if i < len(parts)]
495
+ result_lines.append(" ".join(selected))
496
+ out = "\n".join(result_lines)
497
+ _emit_status("cols", lines=len(result_lines), columns=list(indices))
498
+ return out
499
+
500
+ @_category("Navigation")
501
+ def tree(path: str | Path = ".", *, max_depth: int = 3, show_hidden: bool = False) -> str:
502
+ """Return directory tree."""
503
+ base = Path(path)
504
+ lines = []
505
+ def walk(p: Path, prefix: str, depth: int):
506
+ if depth > max_depth:
507
+ return
508
+ items = sorted(p.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
509
+ items = [i for i in items if show_hidden or not i.name.startswith(".")]
510
+ for i, item in enumerate(items):
511
+ is_last = i == len(items) - 1
512
+ connector = "└── " if is_last else "├── "
513
+ suffix = "/" if item.is_dir() else ""
514
+ lines.append(f"{prefix}{connector}{item.name}{suffix}")
515
+ if item.is_dir():
516
+ ext = " " if is_last else "│ "
517
+ walk(item, prefix + ext, depth + 1)
518
+ lines.append(str(base) + "/")
519
+ walk(base, "", 1)
520
+ out = "\n".join(lines)
521
+ _emit_status("tree", path=str(base), entries=len(lines) - 1, preview=out[:1000])
522
+ return out
523
+
524
+ @_category("Navigation")
525
+ def stat(path: str | Path) -> dict:
526
+ """Get file/directory info."""
527
+ p = Path(path)
528
+ s = p.stat()
529
+ info = {
530
+ "path": str(p),
531
+ "size": s.st_size,
532
+ "is_file": p.is_file(),
533
+ "is_dir": p.is_dir(),
534
+ "mtime": datetime.fromtimestamp(s.st_mtime).isoformat(),
535
+ "mode": oct(s.st_mode),
536
+ }
537
+ _emit_status("stat", path=str(p), size=s.st_size, is_dir=p.is_dir(), mtime=info["mtime"])
538
+ return info
539
+
540
+ @_category("Batch")
541
+ def diff(a: str | Path, b: str | Path) -> str:
542
+ """Compare two files, return unified diff."""
543
+ import difflib
544
+ path_a, path_b = Path(a), Path(b)
545
+ lines_a = path_a.read_text(encoding="utf-8").splitlines(keepends=True)
546
+ lines_b = path_b.read_text(encoding="utf-8").splitlines(keepends=True)
547
+ result = difflib.unified_diff(lines_a, lines_b, fromfile=str(path_a), tofile=str(path_b))
548
+ out = "".join(result)
549
+ _emit_status("diff", file_a=str(path_a), file_b=str(path_b), identical=not out, preview=out[:500])
550
+ return out
551
+
552
+ @_category("Search")
553
+ def glob_files(pattern: str, path: str | Path = ".", *, hidden: bool = False) -> list[Path]:
554
+ """Non-recursive glob (use find() for recursive). Respects .gitignore."""
555
+ p = Path(path)
556
+ ignore_patterns = _load_gitignore_patterns(p)
557
+ matches: list[Path] = []
558
+ for m in p.glob(pattern):
559
+ # Skip hidden files unless requested
560
+ if not hidden and m.name.startswith("."):
561
+ continue
562
+ # Skip gitignored paths
563
+ if _match_gitignore(m, ignore_patterns, p):
564
+ continue
565
+ matches.append(m)
566
+ matches = sorted(matches)
567
+ _emit_status("glob", pattern=pattern, path=str(p), count=len(matches), matches=[str(m) for m in matches[:20]])
568
+ return matches
569
+
570
+ @_category("Batch")
571
+ def batch(paths: list[str | Path], fn) -> list:
572
+ """Apply function to multiple files. Returns list of results."""
573
+ results = []
574
+ for p in paths:
575
+ result = fn(Path(p))
576
+ results.append(result)
577
+ _emit_status("batch", files=len(paths))
578
+ return results
579
+
580
+ @_category("Find/Replace")
581
+ def sed(path: str | Path, pattern: str, repl: str, *, flags: int = 0) -> int:
582
+ """Regex replace in file (like sed -i). Returns count."""
583
+ p = Path(path)
584
+ data = p.read_text(encoding="utf-8")
585
+ new, count = re.subn(pattern, repl, data, flags=flags)
586
+ p.write_text(new, encoding="utf-8")
587
+ _emit_status("sed", path=str(p), count=count)
588
+ return count
589
+
590
+ @_category("Find/Replace")
591
+ def rsed(
592
+ pattern: str,
593
+ repl: str,
594
+ path: str | Path = ".",
595
+ *,
596
+ glob_pattern: str = "*",
597
+ flags: int = 0,
598
+ hidden: bool = False,
599
+ ) -> int:
600
+ """Recursive sed across files matching glob_pattern. Respects .gitignore."""
601
+ base = Path(path)
602
+ ignore_patterns = _load_gitignore_patterns(base)
603
+ total = 0
604
+ files_changed = 0
605
+ changed_files = []
606
+ for file_path in base.rglob(glob_pattern):
607
+ if file_path.is_dir():
608
+ continue
609
+ # Skip hidden files unless requested
610
+ if not hidden and any(part.startswith(".") for part in file_path.parts):
611
+ continue
612
+ # Skip gitignored paths
613
+ if _match_gitignore(file_path, ignore_patterns, base):
614
+ continue
615
+ try:
616
+ data = file_path.read_text(encoding="utf-8")
617
+ new, count = re.subn(pattern, repl, data, flags=flags)
618
+ if count > 0:
619
+ file_path.write_text(new, encoding="utf-8")
620
+ total += count
621
+ files_changed += 1
622
+ if len(changed_files) < 10:
623
+ changed_files.append({"file": str(file_path), "count": count})
624
+ except Exception:
625
+ continue
626
+ _emit_status("rsed", path=str(base), count=total, files=files_changed, changed=changed_files)
627
+ return total
628
+
629
+ @_category("Line ops")
630
+ def lines(path: str | Path, start: int = 1, end: int | None = None) -> str:
631
+ """Extract line range from file (1-indexed, inclusive). Like sed -n 'N,Mp'."""
632
+ p = Path(path)
633
+ all_lines = p.read_text(encoding="utf-8").splitlines()
634
+ if end is None:
635
+ end = len(all_lines)
636
+ start = max(1, start)
637
+ end = min(len(all_lines), end)
638
+ selected = all_lines[start - 1 : end]
639
+ out = "\n".join(selected)
640
+ _emit_status("lines", path=str(p), start=start, end=end, count=len(selected), preview=out[:500])
641
+ return out
642
+
643
+ @_category("Line ops")
644
+ def delete_lines(path: str | Path, start: int, end: int | None = None) -> int:
645
+ """Delete line range from file (1-indexed, inclusive). Like sed -i 'N,Md'."""
646
+ p = Path(path)
647
+ all_lines = p.read_text(encoding="utf-8").splitlines()
648
+ if end is None:
649
+ end = start
650
+ start = max(1, start)
651
+ end = min(len(all_lines), end)
652
+ count = end - start + 1
653
+ new_lines = all_lines[: start - 1] + all_lines[end:]
654
+ p.write_text("\n".join(new_lines) + ("\n" if all_lines else ""), encoding="utf-8")
655
+ _emit_status("delete_lines", path=str(p), start=start, end=end, count=count)
656
+ return count
657
+
658
+ @_category("Line ops")
659
+ def delete_matching(path: str | Path, pattern: str, *, regex: bool = True) -> int:
660
+ """Delete lines matching pattern. Like sed -i '/pattern/d'."""
661
+ p = Path(path)
662
+ all_lines = p.read_text(encoding="utf-8").splitlines()
663
+ if regex:
664
+ rx = re.compile(pattern)
665
+ new_lines = [l for l in all_lines if not rx.search(l)]
666
+ else:
667
+ new_lines = [l for l in all_lines if pattern not in l]
668
+ count = len(all_lines) - len(new_lines)
669
+ p.write_text("\n".join(new_lines) + ("\n" if all_lines else ""), encoding="utf-8")
670
+ _emit_status("delete_matching", path=str(p), pattern=pattern, count=count)
671
+ return count
672
+
673
+ @_category("Line ops")
674
+ def insert_at(path: str | Path, line_num: int, text: str, *, after: bool = True) -> Path:
675
+ """Insert text at line. after=True (sed 'Na\\'), after=False (sed 'Ni\\')."""
676
+ p = Path(path)
677
+ all_lines = p.read_text(encoding="utf-8").splitlines()
678
+ new_lines = text.splitlines()
679
+ line_num = max(1, min(len(all_lines) + 1, line_num))
680
+ if after:
681
+ idx = min(line_num, len(all_lines))
682
+ all_lines = all_lines[:idx] + new_lines + all_lines[idx:]
683
+ pos = "after"
684
+ else:
685
+ idx = line_num - 1
686
+ all_lines = all_lines[:idx] + new_lines + all_lines[idx:]
687
+ pos = "before"
688
+ p.write_text("\n".join(all_lines) + "\n", encoding="utf-8")
689
+ _emit_status("insert_at", path=str(p), line=line_num, lines_inserted=len(new_lines), position=pos)
690
+ return p
691
+
692
+ def _git(*args: str, cwd: str | Path | None = None) -> tuple[int, str, str]:
693
+ """Run git command, return (returncode, stdout, stderr)."""
694
+ result = subprocess.run(
695
+ ["git", *args],
696
+ cwd=str(cwd) if cwd else None,
697
+ capture_output=True,
698
+ text=True,
699
+ )
700
+ return result.returncode, result.stdout, result.stderr
701
+
702
+ @_category("Git")
703
+ def git_status(*, cwd: str | Path | None = None) -> dict:
704
+ """Get structured git status: {branch, staged, modified, untracked, ahead, behind}."""
705
+ code, out, err = _git("status", "--porcelain=v2", "--branch", cwd=cwd)
706
+ if code != 0:
707
+ _emit_status("git_status", error=err.strip())
708
+ return {}
709
+
710
+ result: dict = {"branch": None, "staged": [], "modified": [], "untracked": [], "ahead": 0, "behind": 0}
711
+ for line in out.splitlines():
712
+ if line.startswith("# branch.head "):
713
+ result["branch"] = line.split(" ", 2)[2]
714
+ elif line.startswith("# branch.ab "):
715
+ parts = line.split()
716
+ for p in parts[2:]:
717
+ if p.startswith("+"):
718
+ result["ahead"] = int(p[1:])
719
+ elif p.startswith("-"):
720
+ result["behind"] = int(p[1:])
721
+ elif line.startswith("1 ") or line.startswith("2 "):
722
+ parts = line.split(" ", 8)
723
+ xy = parts[1]
724
+ path = parts[-1]
725
+ if xy[0] != ".":
726
+ result["staged"].append(path)
727
+ if xy[1] != ".":
728
+ result["modified"].append(path)
729
+ elif line.startswith("? "):
730
+ result["untracked"].append(line[2:])
731
+
732
+ clean = not any([result["staged"], result["modified"], result["untracked"]])
733
+ _emit_status("git_status", branch=result["branch"], staged=len(result["staged"]), modified=len(result["modified"]), untracked=len(result["untracked"]), clean=clean, files=result["staged"][:5] + result["modified"][:5])
734
+ return result
735
+
736
+ @_category("Git")
737
+ def git_diff(
738
+ *paths: str,
739
+ staged: bool = False,
740
+ ref: str | None = None,
741
+ stat: bool = False,
742
+ cwd: str | Path | None = None,
743
+ ) -> str:
744
+ """Show git diff. staged=True for --cached, ref for commit comparison."""
745
+ args = ["diff"]
746
+ if stat:
747
+ args.append("--stat")
748
+ if staged:
749
+ args.append("--cached")
750
+ if ref:
751
+ args.append(ref)
752
+ if paths:
753
+ args.append("--")
754
+ args.extend(paths)
755
+ code, out, err = _git(*args, cwd=cwd)
756
+ if code != 0:
757
+ _emit_status("git_diff", error=err.strip())
758
+ return ""
759
+ lines_count = len(out.splitlines()) if out else 0
760
+ _emit_status("git_diff", staged=staged, ref=ref, lines=lines_count, preview=out[:500])
761
+ return out
762
+
763
+ @_category("Git")
764
+ def git_log(
765
+ n: int = 10,
766
+ *,
767
+ oneline: bool = True,
768
+ ref_range: str | None = None,
769
+ paths: list[str] | None = None,
770
+ cwd: str | Path | None = None,
771
+ ) -> list[dict]:
772
+ """Get git log as list of {sha, subject, author, date}."""
773
+ fmt = "%H%x00%s%x00%an%x00%aI" if not oneline else "%h%x00%s%x00%an%x00%aI"
774
+ args = ["log", f"-{n}", f"--format={fmt}"]
775
+ if ref_range:
776
+ args.append(ref_range)
777
+ if paths:
778
+ args.append("--")
779
+ args.extend(paths)
780
+ code, out, err = _git(*args, cwd=cwd)
781
+ if code != 0:
782
+ _emit_status("git_log", error=err.strip())
783
+ return []
784
+
785
+ commits = []
786
+ for line in out.strip().splitlines():
787
+ parts = line.split("\x00")
788
+ if len(parts) >= 4:
789
+ commits.append({"sha": parts[0], "subject": parts[1], "author": parts[2], "date": parts[3]})
790
+
791
+ _emit_status("git_log", commits=len(commits), entries=[{"sha": c["sha"][:8], "subject": c["subject"][:50]} for c in commits[:5]])
792
+ return commits
793
+
794
+ @_category("Git")
795
+ def git_show(ref: str = "HEAD", *, stat: bool = True, cwd: str | Path | None = None) -> dict:
796
+ """Show commit details as {sha, subject, author, date, body, files}."""
797
+ args = ["show", ref, "--format=%H%x00%s%x00%an%x00%aI%x00%b", "--no-patch"]
798
+ code, out, err = _git(*args, cwd=cwd)
799
+ if code != 0:
800
+ _emit_status("git_show", ref=ref, error=err.strip())
801
+ return {}
802
+
803
+ parts = out.strip().split("\x00")
804
+ result = {
805
+ "sha": parts[0] if len(parts) > 0 else "",
806
+ "subject": parts[1] if len(parts) > 1 else "",
807
+ "author": parts[2] if len(parts) > 2 else "",
808
+ "date": parts[3] if len(parts) > 3 else "",
809
+ "body": parts[4].strip() if len(parts) > 4 else "",
810
+ "files": [],
811
+ }
812
+
813
+ if stat:
814
+ _, stat_out, _ = _git("show", ref, "--stat", "--format=", cwd=cwd)
815
+ result["files"] = [l.strip() for l in stat_out.strip().splitlines() if l.strip()]
816
+
817
+ _emit_status("git_show", ref=ref, sha=result["sha"][:12], subject=result["subject"][:60], files=len(result["files"]))
818
+ return result
819
+
820
+ @_category("Git")
821
+ def git_file_at(ref: str, path: str, *, lines: tuple[int, int] | None = None, cwd: str | Path | None = None) -> str:
822
+ """Get file content at ref. Optional lines=(start, end) for range (1-indexed)."""
823
+ code, out, err = _git("show", f"{ref}:{path}", cwd=cwd)
824
+ if code != 0:
825
+ _emit_status("git_file_at", ref=ref, path=path, error=err.strip())
826
+ return ""
827
+
828
+ if lines:
829
+ all_lines = out.splitlines()
830
+ start, end = lines
831
+ start = max(1, start)
832
+ end = min(len(all_lines), end)
833
+ selected = all_lines[start - 1 : end]
834
+ out = "\n".join(selected)
835
+ _emit_status("git_file_at", ref=ref, path=path, start=start, end=end, lines=len(selected))
836
+ return out
837
+
838
+ _emit_status("git_file_at", ref=ref, path=path, chars=len(out))
839
+ return out
840
+
841
+ @_category("Git")
842
+ def git_branch(*, cwd: str | Path | None = None) -> dict:
843
+ """Get branches: {current, local, remote}."""
844
+ code, out, _ = _git("branch", "-a", "--format=%(refname:short)%00%(HEAD)", cwd=cwd)
845
+ if code != 0:
846
+ _emit_status("git_branch", error="failed to list branches")
847
+ return {"current": None, "local": [], "remote": []}
848
+
849
+ result: dict = {"current": None, "local": [], "remote": []}
850
+ for line in out.strip().splitlines():
851
+ parts = line.split("\x00")
852
+ name = parts[0]
853
+ is_current = len(parts) > 1 and parts[1] == "*"
854
+ if is_current:
855
+ result["current"] = name
856
+ if name.startswith("remotes/") or "/" in name and not name.startswith("feature/"):
857
+ result["remote"].append(name)
858
+ else:
859
+ result["local"].append(name)
860
+ if is_current:
861
+ result["current"] = name
862
+
863
+ _emit_status("git_branch", current=result["current"], local=len(result["local"]), remote=len(result["remote"]), branches=result["local"][:10])
864
+ return result
865
+
866
+ @_category("Git")
867
+ def git_has_changes(*, cwd: str | Path | None = None) -> bool:
868
+ """Check if there are uncommitted changes (staged or unstaged)."""
869
+ code, out, _ = _git("status", "--porcelain", cwd=cwd)
870
+ has_changes = bool(out.strip())
871
+ _emit_status("git_has_changes", has_changes=has_changes)
872
+ return has_changes
873
+
874
+ def __omp_prelude_docs__() -> list[dict[str, str]]:
875
+ """Return prelude helper docs for templating. Discovers functions by _omp_category attribute."""
876
+ helpers: list[dict[str, str]] = []
877
+ for name, obj in globals().items():
878
+ if not callable(obj) or not hasattr(obj, "_omp_category"):
879
+ continue
880
+ signature = str(inspect.signature(obj))
881
+ doc = inspect.getdoc(obj) or ""
882
+ docline = doc.splitlines()[0] if doc else ""
883
+ helpers.append({
884
+ "name": name,
885
+ "signature": signature,
886
+ "docstring": docline,
887
+ "category": obj._omp_category,
888
+ })
889
+ return sorted(helpers, key=lambda h: (h["category"], h["name"]))