@oh-my-pi/pi-coding-agent 5.5.0 → 5.6.7
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.
- package/CHANGELOG.md +98 -0
- package/docs/python-repl.md +77 -0
- package/examples/hooks/snake.ts +7 -7
- package/package.json +5 -5
- package/src/bun-imports.d.ts +6 -0
- package/src/cli/args.ts +7 -0
- package/src/cli/setup-cli.ts +231 -0
- package/src/cli.ts +2 -0
- package/src/core/agent-session.ts +118 -15
- package/src/core/bash-executor.ts +3 -84
- package/src/core/compaction/compaction.ts +10 -5
- package/src/core/extensions/index.ts +2 -0
- package/src/core/extensions/loader.ts +13 -1
- package/src/core/extensions/runner.ts +50 -2
- package/src/core/extensions/types.ts +67 -2
- package/src/core/keybindings.ts +51 -1
- package/src/core/prompt-templates.ts +15 -0
- package/src/core/python-executor-display.test.ts +42 -0
- package/src/core/python-executor-lifecycle.test.ts +99 -0
- package/src/core/python-executor-mapping.test.ts +41 -0
- package/src/core/python-executor-per-call.test.ts +49 -0
- package/src/core/python-executor-session.test.ts +103 -0
- package/src/core/python-executor-streaming.test.ts +77 -0
- package/src/core/python-executor-timeout.test.ts +35 -0
- package/src/core/python-executor.lifecycle.test.ts +139 -0
- package/src/core/python-executor.result.test.ts +49 -0
- package/src/core/python-executor.test.ts +180 -0
- package/src/core/python-executor.ts +313 -0
- package/src/core/python-gateway-coordinator.ts +832 -0
- package/src/core/python-kernel-display.test.ts +54 -0
- package/src/core/python-kernel-env.test.ts +138 -0
- package/src/core/python-kernel-session.test.ts +87 -0
- package/src/core/python-kernel-ws.test.ts +104 -0
- package/src/core/python-kernel.lifecycle.test.ts +249 -0
- package/src/core/python-kernel.test.ts +549 -0
- package/src/core/python-kernel.ts +1178 -0
- package/src/core/python-prelude.py +889 -0
- package/src/core/python-prelude.test.ts +140 -0
- package/src/core/python-prelude.ts +3 -0
- package/src/core/sdk.ts +24 -6
- package/src/core/session-manager.ts +174 -82
- package/src/core/settings-manager-python.test.ts +23 -0
- package/src/core/settings-manager.ts +202 -0
- package/src/core/streaming-output.test.ts +26 -0
- package/src/core/streaming-output.ts +100 -0
- package/src/core/system-prompt.python.test.ts +17 -0
- package/src/core/system-prompt.ts +3 -1
- package/src/core/timings.ts +1 -1
- package/src/core/tools/bash.ts +13 -2
- package/src/core/tools/edit-diff.ts +9 -1
- package/src/core/tools/index.test.ts +50 -23
- package/src/core/tools/index.ts +83 -1
- package/src/core/tools/python-execution.test.ts +68 -0
- package/src/core/tools/python-fallback.test.ts +72 -0
- package/src/core/tools/python-renderer.test.ts +36 -0
- package/src/core/tools/python-tool-mode.test.ts +43 -0
- package/src/core/tools/python.test.ts +121 -0
- package/src/core/tools/python.ts +760 -0
- package/src/core/tools/renderers.ts +2 -0
- package/src/core/tools/schema-validation.test.ts +1 -0
- package/src/core/tools/task/executor.ts +146 -3
- package/src/core/tools/task/worker-protocol.ts +32 -2
- package/src/core/tools/task/worker.ts +182 -15
- package/src/index.ts +6 -0
- package/src/main.ts +136 -40
- package/src/modes/interactive/components/custom-editor.ts +16 -31
- package/src/modes/interactive/components/extensions/extension-dashboard.ts +5 -16
- package/src/modes/interactive/components/extensions/extension-list.ts +5 -13
- package/src/modes/interactive/components/history-search.ts +5 -8
- package/src/modes/interactive/components/hook-editor.ts +3 -4
- package/src/modes/interactive/components/hook-input.ts +3 -3
- package/src/modes/interactive/components/hook-selector.ts +5 -15
- package/src/modes/interactive/components/index.ts +1 -0
- package/src/modes/interactive/components/keybinding-hints.ts +66 -0
- package/src/modes/interactive/components/model-selector.ts +53 -66
- package/src/modes/interactive/components/oauth-selector.ts +5 -5
- package/src/modes/interactive/components/session-selector.ts +29 -23
- package/src/modes/interactive/components/settings-defs.ts +404 -196
- package/src/modes/interactive/components/settings-selector.ts +14 -10
- package/src/modes/interactive/components/status-line-segment-editor.ts +7 -7
- package/src/modes/interactive/components/tool-execution.ts +8 -0
- package/src/modes/interactive/components/tree-selector.ts +29 -23
- package/src/modes/interactive/components/user-message-selector.ts +6 -17
- package/src/modes/interactive/controllers/command-controller.ts +86 -37
- package/src/modes/interactive/controllers/event-controller.ts +8 -0
- package/src/modes/interactive/controllers/extension-ui-controller.ts +51 -0
- package/src/modes/interactive/controllers/input-controller.ts +42 -6
- package/src/modes/interactive/interactive-mode.ts +56 -30
- package/src/modes/interactive/theme/theme-schema.json +2 -2
- package/src/modes/interactive/types.ts +6 -1
- package/src/modes/interactive/utils/ui-helpers.ts +2 -1
- package/src/modes/print-mode.ts +23 -0
- package/src/modes/rpc/rpc-mode.ts +21 -0
- package/src/prompts/agents/reviewer.md +1 -1
- package/src/prompts/system/system-prompt.md +32 -1
- 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"]))
|