lcode-cli 0.16.0__py3-none-any.whl → 0.17.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.
- lcode/__init__.py +1 -1
- lcode/agent.py +11 -1
- lcode/cli.py +21 -0
- lcode/gitflow.py +640 -0
- lcode/repl.py +38 -3
- lcode/tools.py +6 -0
- {lcode_cli-0.16.0.dist-info → lcode_cli-0.17.0.dist-info}/METADATA +5 -2
- {lcode_cli-0.16.0.dist-info → lcode_cli-0.17.0.dist-info}/RECORD +11 -10
- {lcode_cli-0.16.0.dist-info → lcode_cli-0.17.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.16.0.dist-info → lcode_cli-0.17.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.16.0.dist-info → lcode_cli-0.17.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/agent.py
CHANGED
|
@@ -130,7 +130,14 @@ def git_info(cwd: Path) -> str:
|
|
|
130
130
|
return "not a git repository"
|
|
131
131
|
changed = git("status", "--short").stdout.strip().splitlines()
|
|
132
132
|
log = git("log", "--oneline", "-5").stdout.strip() or "(no commits)"
|
|
133
|
-
|
|
133
|
+
dirs = git("rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir").stdout.split()
|
|
134
|
+
worktree = (
|
|
135
|
+
"\nThis folder is a git worktree, separate from the repository's main checkout: read, change and "
|
|
136
|
+
"commit files only here, with paths inside this folder."
|
|
137
|
+
if len(dirs) == 2 and dirs[0] != dirs[1]
|
|
138
|
+
else ""
|
|
139
|
+
)
|
|
140
|
+
return f"branch {branch.stdout.strip()}, {len(changed)} changed file(s){worktree}\nRecent commits:\n{log}"
|
|
134
141
|
except (OSError, subprocess.TimeoutExpired):
|
|
135
142
|
return "unknown"
|
|
136
143
|
|
|
@@ -242,6 +249,7 @@ class Agent:
|
|
|
242
249
|
# Set on subagents (see lcode.subagents):
|
|
243
250
|
self.allowed_tools: set[str] | None = None # None: every tool
|
|
244
251
|
self.read_only = False # only read-only shell commands
|
|
252
|
+
self.no_changes = "" # set during a review: why nothing may change (read-only tools only)
|
|
245
253
|
self.cancel: threading.Event | None = None # set from another thread to stop
|
|
246
254
|
self.on_tool = None # called with (name, arguments) before each tool runs
|
|
247
255
|
self.response = None # the streaming response, so another thread can abort it
|
|
@@ -931,4 +939,6 @@ class Agent:
|
|
|
931
939
|
else:
|
|
932
940
|
note = ""
|
|
933
941
|
self.settings.context = tokens
|
|
942
|
+
if getattr(self.ollama, "kind", "ollama") != "ollama": # the server keeps its own window
|
|
943
|
+
return f"lcode now uses {format_tokens(tokens)} tokens of the context window{note}, and summarizes sooner."
|
|
934
944
|
return f"Context window set to {format_tokens(tokens)} tokens{note}. The model reloads on the next request."
|
lcode/cli.py
CHANGED
|
@@ -626,6 +626,17 @@ def cmd_chat(args) -> None:
|
|
|
626
626
|
cwd = Path(args.repo).expanduser().resolve()
|
|
627
627
|
if not cwd.is_dir():
|
|
628
628
|
fail(f"not a directory: {cwd}")
|
|
629
|
+
worktree = None
|
|
630
|
+
if args.worktree is not None:
|
|
631
|
+
from lcode import gitflow
|
|
632
|
+
|
|
633
|
+
try:
|
|
634
|
+
worktree = gitflow.start_worktree(cwd, args.worktree)
|
|
635
|
+
except gitflow.GitError as e:
|
|
636
|
+
fail(f"--worktree: {e}")
|
|
637
|
+
cwd = worktree.cwd
|
|
638
|
+
state = "a new worktree" if worktree.created_branch else "the worktree"
|
|
639
|
+
console.print(f"[dim]Working in {state} {worktree.path}, on branch {escape(worktree.branch)}.[/]")
|
|
629
640
|
ollama = connect(cfg)
|
|
630
641
|
hw = detect()
|
|
631
642
|
check_ollama(ollama, hw)
|
|
@@ -714,6 +725,8 @@ def cmd_chat(args) -> None:
|
|
|
714
725
|
agent.mcp.close()
|
|
715
726
|
if agent.sandbox:
|
|
716
727
|
agent.sandbox.stop()
|
|
728
|
+
if worktree is not None:
|
|
729
|
+
console.print(f"[dim]{escape(worktree.finish())}[/]")
|
|
717
730
|
|
|
718
731
|
|
|
719
732
|
def build_parser() -> argparse.ArgumentParser:
|
|
@@ -736,6 +749,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
736
749
|
metavar="SESSION",
|
|
737
750
|
help="resume a saved session: pick from a list, or give its number, name or id",
|
|
738
751
|
)
|
|
752
|
+
parser.add_argument(
|
|
753
|
+
"-w",
|
|
754
|
+
"--worktree",
|
|
755
|
+
nargs="?",
|
|
756
|
+
const="",
|
|
757
|
+
metavar="NAME",
|
|
758
|
+
help="work in a git worktree of its own, on branch NAME (new, or continue one); removed if nothing changed",
|
|
759
|
+
)
|
|
739
760
|
parser.add_argument("--plan", action="store_true", help="start in plan mode: agree on a plan before any change")
|
|
740
761
|
parser.add_argument("--auto-edit", action="store_true", help="apply file edits without asking")
|
|
741
762
|
parser.add_argument("--yolo", action="store_true", help="never ask for permission (edits and commands)")
|
lcode/gitflow.py
ADDED
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
"""Git workflow: /commit, /review, /pr, and sessions in a worktree of their own (lcode --worktree).
|
|
2
|
+
|
|
3
|
+
/commit and /pr ask the model for a message (or a title and description) in one extra request
|
|
4
|
+
alongside the conversation, so it knows why the changes were made and Ollama reuses the cached
|
|
5
|
+
prompt; that request isn't kept in the conversation. Nothing is committed, pushed or opened
|
|
6
|
+
without the user's yes, also in yolo mode. /review runs as a normal request in which the model can
|
|
7
|
+
read but not change anything.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import fnmatch
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import secrets
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import tempfile
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
from rich.markup import escape
|
|
26
|
+
from rich.panel import Panel
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from lcode.agent import Agent
|
|
30
|
+
|
|
31
|
+
# Files that often hold secrets: /commit leaves them out unless the user stages them.
|
|
32
|
+
SENSITIVE = (".env", ".env.*", "*.pem", "*.key", "*.p12", "*.pfx", "id_rsa*", "id_ed25519*", "id_ecdsa*",
|
|
33
|
+
"*credentials*", "*secret*", "*.keystore", "*.jks", ".netrc", ".pgpass") # fmt: skip
|
|
34
|
+
MAX_UNTRACKED_LINES = 200 # of a new file, in the diff the model sees
|
|
35
|
+
MIN_DIFF_CHARS = 6000
|
|
36
|
+
MAX_DIFF_CHARS = 120_000
|
|
37
|
+
|
|
38
|
+
COMMIT_SCHEMA = {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"properties": {"files": {"type": "array", "items": {"type": "string"}}, "message": {"type": "string"}},
|
|
41
|
+
"required": ["files", "message"],
|
|
42
|
+
}
|
|
43
|
+
PR_SCHEMA = {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"properties": {"branch": {"type": "string"}, "title": {"type": "string"}, "body": {"type": "string"}},
|
|
46
|
+
"required": ["branch", "title", "body"],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
COMMIT_PROMPT = """Write the git commit message for the changes below.{hint}
|
|
50
|
+
|
|
51
|
+
The repository's recent commit messages; match their style (length, tense, capitals, prefixes such as "fix:", trailers):
|
|
52
|
+
{style}
|
|
53
|
+
|
|
54
|
+
Changed files{which}:
|
|
55
|
+
{files}
|
|
56
|
+
|
|
57
|
+
{diff}
|
|
58
|
+
|
|
59
|
+
Reply with JSON. "message" is the whole commit message: a subject line of at most 72 characters and, when the change needs explaining, a blank line and a body wrapped at 72 characters that says why, not what the diff already shows. {files_rule} Use what you know from this conversation about why the changes were made. Don't mention yourself or lcode."""
|
|
60
|
+
|
|
61
|
+
FILES_CHOSEN = """"files" lists the files that belong in this commit, from the list above: leave out files unrelated to the work (scratch files, logs, local experiments). When in doubt, include a file."""
|
|
62
|
+
FILES_STAGED = """The user staged exactly what to commit, so "files" is an empty list."""
|
|
63
|
+
|
|
64
|
+
PR_PROMPT = """Write a pull request for the branch's changes below, into {base}.{hint}
|
|
65
|
+
|
|
66
|
+
Commits:
|
|
67
|
+
{commits}
|
|
68
|
+
|
|
69
|
+
{diff}
|
|
70
|
+
{template}
|
|
71
|
+
Reply with JSON. "title" is a short title (at most 70 characters) in the style of the commits. "body" is the description in Markdown: what changes and why, and how it was tested when you know. Keep it short and concrete; no headings for a small change. {branch_rule} Use what you know from this conversation. Don't mention yourself or lcode."""
|
|
72
|
+
|
|
73
|
+
REVIEW_PROMPT = """Review {what} before it's {next}.
|
|
74
|
+
|
|
75
|
+
{stat}
|
|
76
|
+
{diff}
|
|
77
|
+
|
|
78
|
+
Look for:
|
|
79
|
+
1. Bugs: wrong logic, missed edge cases, error handling, wrong assumptions about inputs, races.
|
|
80
|
+
2. Tests: changed behaviour without a test, tests that don't check what they claim.
|
|
81
|
+
3. Security: injection, secrets in the code, unchecked input, too-broad permissions.
|
|
82
|
+
4. Clarity: confusing names, dead code, duplication, comments that no longer match the code. Only what matters.
|
|
83
|
+
|
|
84
|
+
Read the surrounding code where you need it: a diff alone can mislead. Don't change any files: this is a review. If the project's instructions or a skill describe how to review here, follow them.
|
|
85
|
+
|
|
86
|
+
Report each finding as `path:line`, its kind (bug, test, security or clarity), what's wrong and how to fix it, most important first. If nothing needs changing, say so in a sentence; don't invent problems."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class GitError(Exception):
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def git(cwd: Path, *args: str, check: bool = True, stdin: str | None = None, timeout: float = 120) -> str:
|
|
94
|
+
try:
|
|
95
|
+
r = subprocess.run(
|
|
96
|
+
["git", *args], cwd=cwd, input=stdin, capture_output=True, text=True, timeout=timeout
|
|
97
|
+
) # fmt: skip
|
|
98
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
99
|
+
raise GitError(f"git {args[0]} failed: {e}") from e
|
|
100
|
+
if r.returncode != 0 and check:
|
|
101
|
+
raise GitError((r.stderr or r.stdout).strip() or f"git {args[0]} failed")
|
|
102
|
+
return r.stdout
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def repo_root(cwd: Path) -> Path:
|
|
106
|
+
try:
|
|
107
|
+
return Path(git(cwd, "rev-parse", "--show-toplevel").strip())
|
|
108
|
+
except GitError as e:
|
|
109
|
+
raise GitError("this isn't a git repository") from e
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def has_commits(root: Path) -> bool:
|
|
113
|
+
return bool(git(root, "rev-parse", "--verify", "-q", "HEAD", check=False).strip())
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ----------------------------------------------------------------------------- changes
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class Change:
|
|
121
|
+
path: str
|
|
122
|
+
code: str # git's two status letters, e.g. " M", "A ", "??"
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def staged(self) -> bool:
|
|
126
|
+
return self.code[0] not in " ?"
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def untracked(self) -> bool:
|
|
130
|
+
return self.code == "??"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def changes(root: Path) -> list[Change]:
|
|
134
|
+
out = git(root, "status", "--porcelain=v1", "-z", "--untracked-files=all")
|
|
135
|
+
found, parts, i = [], out.split("\0"), 0
|
|
136
|
+
while i < len(parts):
|
|
137
|
+
entry = parts[i]
|
|
138
|
+
i += 1
|
|
139
|
+
if len(entry) < 4:
|
|
140
|
+
continue
|
|
141
|
+
code, path = entry[:2], entry[3:]
|
|
142
|
+
if code[0] in "RC":
|
|
143
|
+
i += 1 # the old name follows
|
|
144
|
+
found.append(Change(path, code))
|
|
145
|
+
return found
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def sensitive(path: str) -> bool:
|
|
149
|
+
name = Path(path).name.lower()
|
|
150
|
+
return any(fnmatch.fnmatch(name, pattern) for pattern in SENSITIVE)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def diff_budget(agent: Agent) -> int:
|
|
154
|
+
"""How much diff (in characters) fits next to the conversation."""
|
|
155
|
+
room = int(agent.settings.context * 0.8) - agent.ctx_used - 3000
|
|
156
|
+
return max(MIN_DIFF_CHARS, min(MAX_DIFF_CHARS, room * 3))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def shorten(text: str, limit: int, what: str) -> str:
|
|
160
|
+
if len(text) <= limit:
|
|
161
|
+
return text
|
|
162
|
+
return text[:limit] + f"\n[… {len(text) - limit:,} more characters of the {what} not shown]"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def new_files(root: Path, paths: list[str]) -> str:
|
|
166
|
+
parts = []
|
|
167
|
+
for rel in paths:
|
|
168
|
+
path = root / rel
|
|
169
|
+
try:
|
|
170
|
+
if path.is_symlink() or not path.is_file() or path.stat().st_size > 1_000_000:
|
|
171
|
+
parts.append(f"new file: {rel} (not shown)")
|
|
172
|
+
continue
|
|
173
|
+
data = path.read_bytes()
|
|
174
|
+
except OSError:
|
|
175
|
+
continue
|
|
176
|
+
if b"\0" in data[:4096]:
|
|
177
|
+
parts.append(f"new file: {rel} (binary)")
|
|
178
|
+
continue
|
|
179
|
+
lines = data.decode(errors="replace").splitlines()
|
|
180
|
+
more = f"\n[… {len(lines) - MAX_UNTRACKED_LINES} more lines]" if len(lines) > MAX_UNTRACKED_LINES else ""
|
|
181
|
+
parts.append(f"new file: {rel}\n" + "\n".join(lines[:MAX_UNTRACKED_LINES]) + more)
|
|
182
|
+
return "\n\n".join(parts)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def changes_diff(root: Path, files: list[Change], staged_only: bool, limit: int) -> str:
|
|
186
|
+
"""The diff of these changes, new files included, cut to `limit` characters."""
|
|
187
|
+
tracked = [c.path for c in files if not c.untracked]
|
|
188
|
+
text = ""
|
|
189
|
+
if staged_only:
|
|
190
|
+
text = git(root, "diff", "--cached", "--no-color")
|
|
191
|
+
elif tracked:
|
|
192
|
+
against = ["HEAD"] if has_commits(root) else ["--cached"]
|
|
193
|
+
text = git(root, "diff", "--no-color", *against, "--", *tracked)
|
|
194
|
+
untracked = new_files(root, [c.path for c in files if c.untracked])
|
|
195
|
+
return shorten("\n\n".join(p for p in (text.strip(), untracked) if p), limit, "diff")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def style(root: Path) -> str:
|
|
199
|
+
if not has_commits(root):
|
|
200
|
+
return "(no commits yet)"
|
|
201
|
+
subjects = git(root, "log", "-n", "15", "--no-merges", "--format=%s").strip()
|
|
202
|
+
bodies = git(root, "log", "-n", "3", "--no-merges", "--format=--- %B").strip()
|
|
203
|
+
return f"{subjects}\n\nThe latest three in full:\n{bodies}"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ----------------------------------------------------------------------------- asking the model
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def parse_json(content: str) -> dict:
|
|
210
|
+
for candidate in (content, *re.findall(r"\{.*\}", content, re.S)):
|
|
211
|
+
try:
|
|
212
|
+
data = json.loads(candidate)
|
|
213
|
+
except (json.JSONDecodeError, TypeError):
|
|
214
|
+
continue
|
|
215
|
+
if isinstance(data, dict):
|
|
216
|
+
return data
|
|
217
|
+
return {}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def ask(agent: Agent, request: str, schema: dict) -> dict:
|
|
221
|
+
"""One structured answer, with the conversation as context; the request isn't kept in it."""
|
|
222
|
+
history = agent.messages
|
|
223
|
+
if agent.ctx_used + len(request) // 3 > agent.settings.context * 0.85:
|
|
224
|
+
history = agent.messages[:1] # too long: the instructions only
|
|
225
|
+
payload = {
|
|
226
|
+
"model": agent.settings.model,
|
|
227
|
+
"messages": [*history, {"role": "user", "content": request}],
|
|
228
|
+
"tools": agent.tool_schemas(), # the same as the session's, so the cached prompt is reused
|
|
229
|
+
"think": False,
|
|
230
|
+
"format": schema,
|
|
231
|
+
"keep_alive": agent.settings.keep_alive,
|
|
232
|
+
"options": agent.options(),
|
|
233
|
+
}
|
|
234
|
+
reply = agent.ollama.chat(payload)
|
|
235
|
+
return parse_json((reply.get("message") or {}).get("content") or "")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def clean_message(text: str) -> str:
|
|
239
|
+
text = re.sub(r"^```\w*\n|\n```$", "", text.strip()).strip()
|
|
240
|
+
return "\n".join(line.rstrip() for line in text.splitlines()).strip()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def confirm(agent: Agent, question: str, editable: str | None = None) -> str:
|
|
244
|
+
"""'y', 'e' (when there's something to edit) or 'n'."""
|
|
245
|
+
try:
|
|
246
|
+
answer = input(question).strip().lower()
|
|
247
|
+
except (EOFError, KeyboardInterrupt):
|
|
248
|
+
agent.console.print()
|
|
249
|
+
return "n"
|
|
250
|
+
if answer in ("y", "yes"):
|
|
251
|
+
return "y"
|
|
252
|
+
if answer in ("e", "edit") and editable is not None:
|
|
253
|
+
return "e"
|
|
254
|
+
return "n"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ----------------------------------------------------------------------------- /commit
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def commit_command(agent: Agent, hint: str = "") -> None:
|
|
261
|
+
c = agent.console
|
|
262
|
+
try:
|
|
263
|
+
root = repo_root(agent.cwd)
|
|
264
|
+
found = changes(root)
|
|
265
|
+
except GitError as e:
|
|
266
|
+
c.print(f"[red]{escape(str(e))}[/]")
|
|
267
|
+
return
|
|
268
|
+
if not found:
|
|
269
|
+
c.print("Nothing to commit: the working tree is clean.")
|
|
270
|
+
return
|
|
271
|
+
staged = [ch for ch in found if ch.staged]
|
|
272
|
+
risky: list[str] = []
|
|
273
|
+
if staged:
|
|
274
|
+
candidates = staged
|
|
275
|
+
unstaged = len([ch for ch in found if not ch.staged])
|
|
276
|
+
c.print(
|
|
277
|
+
f"[dim]Committing the {len(staged)} staged file(s)"
|
|
278
|
+
+ (f"; {unstaged} other changed file(s) stay out of it." if unstaged else ".")
|
|
279
|
+
+ "[/]"
|
|
280
|
+
)
|
|
281
|
+
else:
|
|
282
|
+
risky = [ch.path for ch in found if sensitive(ch.path)]
|
|
283
|
+
candidates = [ch for ch in found if not sensitive(ch.path)]
|
|
284
|
+
if not candidates:
|
|
285
|
+
c.print(
|
|
286
|
+
f"Only files that may hold secrets changed ({escape(', '.join(risky))}). "
|
|
287
|
+
"If they belong in a commit, stage them yourself (git add) and run /commit again."
|
|
288
|
+
)
|
|
289
|
+
return
|
|
290
|
+
paths = [ch.path for ch in candidates]
|
|
291
|
+
request = COMMIT_PROMPT.format(
|
|
292
|
+
hint=f" The user adds: {hint}" if hint else "",
|
|
293
|
+
style=style(root),
|
|
294
|
+
which=" (staged)" if staged else "",
|
|
295
|
+
files="\n".join(f"{ch.code.strip() or 'M'} {ch.path}" for ch in candidates),
|
|
296
|
+
diff=changes_diff(root, candidates, bool(staged), diff_budget(agent)),
|
|
297
|
+
files_rule=FILES_STAGED if staged else FILES_CHOSEN,
|
|
298
|
+
)
|
|
299
|
+
from lcode.ollama import OllamaError
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
with c.status("[cyan]Writing the commit message…[/]", spinner="dots"):
|
|
303
|
+
answer = ask(agent, request, COMMIT_SCHEMA)
|
|
304
|
+
except KeyboardInterrupt:
|
|
305
|
+
c.print("[dim]Cancelled.[/]")
|
|
306
|
+
return
|
|
307
|
+
except OllamaError as e:
|
|
308
|
+
c.print(f"[red]Couldn't write a commit message: {escape(str(e))}[/]")
|
|
309
|
+
return
|
|
310
|
+
message = clean_message(str(answer.get("message") or ""))
|
|
311
|
+
if not message:
|
|
312
|
+
c.print("[red]The model didn't write a commit message.[/] Try again, or commit yourself.")
|
|
313
|
+
return
|
|
314
|
+
files = paths
|
|
315
|
+
if not staged:
|
|
316
|
+
chosen = [f for f in answer.get("files") or [] if f in paths]
|
|
317
|
+
files = chosen or paths
|
|
318
|
+
left_out = [p for p in paths if p not in files]
|
|
319
|
+
while True:
|
|
320
|
+
c.print(Panel(escape(message), title="Commit message", title_align="left", border_style="cyan"))
|
|
321
|
+
c.print(f" [bold]Files[/] {escape(', '.join(files))}")
|
|
322
|
+
if left_out:
|
|
323
|
+
c.print(f" [dim]Left out (unrelated, the model thinks): {escape(', '.join(left_out))}[/]")
|
|
324
|
+
if risky:
|
|
325
|
+
c.print(f" [dim]Left out (may hold secrets; stage them yourself if needed): {escape(', '.join(risky))}[/]")
|
|
326
|
+
choice = confirm(agent, " [y] commit · [e] edit the message · [n] cancel: ", message)
|
|
327
|
+
if choice == "e":
|
|
328
|
+
from lcode.planning import edit_text
|
|
329
|
+
|
|
330
|
+
message = clean_message(edit_text(message)) or message
|
|
331
|
+
continue
|
|
332
|
+
break
|
|
333
|
+
if choice != "y":
|
|
334
|
+
c.print("Not committed.")
|
|
335
|
+
return
|
|
336
|
+
try:
|
|
337
|
+
if not staged:
|
|
338
|
+
git(root, "add", "-A", "--", *files)
|
|
339
|
+
git(root, "commit", "-q", "-F", "-", stdin=message + "\n", timeout=600) # commit hooks may run tests
|
|
340
|
+
summary = git(root, "log", "-1", "--format=%h %s").strip()
|
|
341
|
+
except GitError as e:
|
|
342
|
+
c.print(f"[red]git commit failed:[/] {escape(str(e))}")
|
|
343
|
+
return
|
|
344
|
+
c.print(f"[green]✓ Committed[/] {escape(summary)}")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# ----------------------------------------------------------------------------- /review
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def default_branch(root: Path) -> str:
|
|
351
|
+
head = git(root, "symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD", check=False).strip()
|
|
352
|
+
if head:
|
|
353
|
+
return head.removeprefix("origin/")
|
|
354
|
+
for name in ("main", "master", "trunk", "develop"):
|
|
355
|
+
if git(root, "rev-parse", "--verify", "-q", name, check=False).strip():
|
|
356
|
+
return name
|
|
357
|
+
return "main"
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def base_ref(root: Path, base: str, remote: str = "origin") -> str:
|
|
361
|
+
"""<remote>/<base> when there is one (the local branch may be behind), else <base>."""
|
|
362
|
+
for ref in (f"{remote}/{base}", base):
|
|
363
|
+
if git(root, "rev-parse", "--verify", "-q", ref, check=False).strip():
|
|
364
|
+
return ref
|
|
365
|
+
raise GitError(f"there's no branch called {base}")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def is_branch(root: Path, name: str, remote: str = "origin") -> bool:
|
|
369
|
+
refs = (f"refs/heads/{name}", f"refs/remotes/{remote}/{name}")
|
|
370
|
+
return any(git(root, "rev-parse", "--verify", "-q", ref, check=False).strip() for ref in refs)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def review_prompt(agent: Agent, base: str = "") -> str:
|
|
374
|
+
"""The request for a review of the uncommitted changes, or of the branch against `base`.
|
|
375
|
+
|
|
376
|
+
Uncommitted changes to tracked files are the work in progress, so they come first; otherwise
|
|
377
|
+
(or with a base) the branch's changes since it left the base. New files are shown either way.
|
|
378
|
+
"""
|
|
379
|
+
root = repo_root(agent.cwd)
|
|
380
|
+
limit = diff_budget(agent)
|
|
381
|
+
found = changes(root)
|
|
382
|
+
untracked = [ch.path for ch in found if ch.untracked]
|
|
383
|
+
work_in_progress = any(not ch.untracked for ch in found)
|
|
384
|
+
commits = fork = ""
|
|
385
|
+
explicit = bool(base)
|
|
386
|
+
if base or (has_commits(root) and not work_in_progress):
|
|
387
|
+
if not has_commits(root):
|
|
388
|
+
raise GitError("nothing to compare with yet: the repository has no commits")
|
|
389
|
+
base = base or default_branch(root)
|
|
390
|
+
if is_branch(root, base):
|
|
391
|
+
fork = git(root, "merge-base", base_ref(root, base), "HEAD").strip()
|
|
392
|
+
elif explicit and git(root, "rev-parse", "--verify", "-q", base, check=False).strip():
|
|
393
|
+
fork = git(root, "merge-base", base, "HEAD").strip()
|
|
394
|
+
elif explicit:
|
|
395
|
+
raise GitError(f"there's no branch or commit called {base}")
|
|
396
|
+
if fork:
|
|
397
|
+
commits = git(root, "log", "--format=- %s", f"{fork}..HEAD").strip()
|
|
398
|
+
if fork and (commits or explicit or work_in_progress):
|
|
399
|
+
branch = git(root, "branch", "--show-current").strip() or "HEAD"
|
|
400
|
+
tracked = git(root, "diff", "--no-color", fork) # the branch's commits and any uncommitted changes
|
|
401
|
+
what, next_step = f"the changes on {branch} compared with {base}", "merged"
|
|
402
|
+
stat = (
|
|
403
|
+
f"Commits:\n{commits or '(none: uncommitted changes only)'}\n\n{git(root, 'diff', '--stat', fork).strip()}"
|
|
404
|
+
)
|
|
405
|
+
elif found:
|
|
406
|
+
tracked = git(root, "diff", "--no-color", "HEAD") if has_commits(root) else ""
|
|
407
|
+
what, next_step = "the uncommitted changes", "committed"
|
|
408
|
+
stat = git(root, "diff", "--stat", "HEAD").strip() if has_commits(root) else ""
|
|
409
|
+
else:
|
|
410
|
+
where = f" and nothing on this branch that {base} doesn't have" if base else ""
|
|
411
|
+
raise GitError(f"nothing to review: no uncommitted changes{where}")
|
|
412
|
+
if untracked:
|
|
413
|
+
stat += ("\n" if stat else "") + "New files, not yet added to git: " + ", ".join(untracked)
|
|
414
|
+
diff = "\n\n".join(p for p in (tracked.strip(), new_files(root, untracked)) if p)
|
|
415
|
+
if not diff:
|
|
416
|
+
raise GitError(f"nothing to review: {what} are empty")
|
|
417
|
+
return REVIEW_PROMPT.format(
|
|
418
|
+
what=what, next=next_step, stat=stat, diff=f"```diff\n{shorten(diff, limit, 'diff')}\n```"
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# ----------------------------------------------------------------------------- /pr
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def pr_command(agent: Agent, arg: str = "") -> None:
|
|
426
|
+
c = agent.console
|
|
427
|
+
if not shutil.which("gh"):
|
|
428
|
+
c.print("[red]/pr needs the GitHub CLI:[/] install it from https://cli.github.com and run `gh auth login`.")
|
|
429
|
+
return
|
|
430
|
+
try:
|
|
431
|
+
root = repo_root(agent.cwd)
|
|
432
|
+
if not git(root, "remote").split():
|
|
433
|
+
raise GitError("this repository has no remote to push to")
|
|
434
|
+
branch = git(root, "branch", "--show-current").strip()
|
|
435
|
+
if not branch:
|
|
436
|
+
raise GitError("HEAD isn't on a branch (detached); switch to one first")
|
|
437
|
+
uncommitted = [ch for ch in changes(root) if not ch.untracked]
|
|
438
|
+
if uncommitted:
|
|
439
|
+
c.print(
|
|
440
|
+
f"There are uncommitted changes ({len(uncommitted)} file(s)). Commit them first with /commit, "
|
|
441
|
+
"then run /pr again."
|
|
442
|
+
)
|
|
443
|
+
return
|
|
444
|
+
remotes = git(root, "remote").split()
|
|
445
|
+
remote = "origin" if "origin" in remotes else remotes[0]
|
|
446
|
+
first, _, rest = arg.strip().partition(" ")
|
|
447
|
+
if first and is_branch(root, first, remote): # /pr develop, or /pr develop <what to say>
|
|
448
|
+
base, hint = first, rest.strip()
|
|
449
|
+
else:
|
|
450
|
+
base, hint = default_branch(root), arg.strip()
|
|
451
|
+
ref = base_ref(root, base, remote)
|
|
452
|
+
commits = git(root, "log", "--format=- %s%n%b", f"{ref}..HEAD").strip()
|
|
453
|
+
except GitError as e:
|
|
454
|
+
c.print(f"[red]{escape(str(e))}[/]")
|
|
455
|
+
return
|
|
456
|
+
if not commits:
|
|
457
|
+
c.print(f"No commits to open a pull request with: {escape(branch)} has nothing that {escape(ref)} doesn't.")
|
|
458
|
+
return
|
|
459
|
+
existing = subprocess.run(
|
|
460
|
+
["gh", "pr", "view", branch, "--json", "url,state", "-q", 'select(.state == "OPEN") | .url'],
|
|
461
|
+
cwd=root, capture_output=True, text=True, timeout=60,
|
|
462
|
+
).stdout.strip() if branch != base else "" # fmt: skip
|
|
463
|
+
if existing:
|
|
464
|
+
c.print(f"A pull request for {escape(branch)} is already open: {existing}")
|
|
465
|
+
if confirm(agent, f" Push the new commits to {remote}/{branch}? [y] push · [n] no: ") == "y":
|
|
466
|
+
push(agent, root, remote, branch)
|
|
467
|
+
return
|
|
468
|
+
new_branch = branch == base # the commits are on the base branch: they go to a new branch
|
|
469
|
+
template = next(
|
|
470
|
+
(p for p in (root / ".github" / "pull_request_template.md", root / "docs" / "pull_request_template.md",
|
|
471
|
+
root / "PULL_REQUEST_TEMPLATE.md") if p.is_file()),
|
|
472
|
+
None,
|
|
473
|
+
) # fmt: skip
|
|
474
|
+
request = PR_PROMPT.format(
|
|
475
|
+
base=base,
|
|
476
|
+
hint=f" The user adds: {hint}" if hint else "",
|
|
477
|
+
commits=commits,
|
|
478
|
+
diff=f"```diff\n{shorten(git(root, 'diff', '--no-color', f'{ref}...HEAD'), diff_budget(agent), 'diff')}\n```",
|
|
479
|
+
template=f"\nFill in the repository's pull request template:\n{template.read_text()[:4000]}\n"
|
|
480
|
+
if template
|
|
481
|
+
else "",
|
|
482
|
+
branch_rule=(
|
|
483
|
+
'"branch" is a short name for a new branch for these commits, such as fix-login-timeout.'
|
|
484
|
+
if new_branch
|
|
485
|
+
else '"branch" is an empty string.'
|
|
486
|
+
),
|
|
487
|
+
)
|
|
488
|
+
from lcode.ollama import OllamaError
|
|
489
|
+
|
|
490
|
+
try:
|
|
491
|
+
with c.status("[cyan]Writing the pull request…[/]", spinner="dots"):
|
|
492
|
+
answer = ask(agent, request, PR_SCHEMA)
|
|
493
|
+
except KeyboardInterrupt:
|
|
494
|
+
c.print("[dim]Cancelled.[/]")
|
|
495
|
+
return
|
|
496
|
+
except OllamaError as e:
|
|
497
|
+
c.print(f"[red]Couldn't write the pull request: {escape(str(e))}[/]")
|
|
498
|
+
return
|
|
499
|
+
title = " ".join(str(answer.get("title") or "").split())
|
|
500
|
+
body = clean_message(str(answer.get("body") or ""))
|
|
501
|
+
if not title:
|
|
502
|
+
c.print("[red]The model didn't write a title.[/] Try again, or use gh pr create yourself.")
|
|
503
|
+
return
|
|
504
|
+
head = branch
|
|
505
|
+
if new_branch:
|
|
506
|
+
head = branch_name(str(answer.get("branch") or "")) or f"lcode-{time.strftime('%m%d')}-{secrets.token_hex(2)}"
|
|
507
|
+
while True:
|
|
508
|
+
c.print(
|
|
509
|
+
Panel(escape(f"{title}\n\n{body}".strip()), title="Pull request", title_align="left", border_style="cyan")
|
|
510
|
+
)
|
|
511
|
+
if new_branch:
|
|
512
|
+
c.print(f" [dim]The commits are on {escape(base)}, so they go to a new branch: {escape(head)}[/]")
|
|
513
|
+
choice = confirm(
|
|
514
|
+
agent,
|
|
515
|
+
f" Push {head} to {remote} and open the pull request into {base}? [y] yes · [e] edit · [n] no: ",
|
|
516
|
+
body,
|
|
517
|
+
)
|
|
518
|
+
if choice == "e":
|
|
519
|
+
from lcode.planning import edit_text
|
|
520
|
+
|
|
521
|
+
edited = edit_text(f"{title}\n\n{body}").strip()
|
|
522
|
+
if edited:
|
|
523
|
+
title, _, body = edited.partition("\n")
|
|
524
|
+
title, body = title.strip(), body.strip()
|
|
525
|
+
continue
|
|
526
|
+
break
|
|
527
|
+
if choice != "y":
|
|
528
|
+
c.print("No pull request opened.")
|
|
529
|
+
return
|
|
530
|
+
try:
|
|
531
|
+
if new_branch:
|
|
532
|
+
git(root, "switch", "-q", "-c", head)
|
|
533
|
+
c.print(f"[dim]Switched to the new branch {escape(head)}; {escape(base)} still has the commits locally.[/]")
|
|
534
|
+
if not push(agent, root, remote, head):
|
|
535
|
+
return
|
|
536
|
+
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as f:
|
|
537
|
+
f.write(body + "\n")
|
|
538
|
+
try:
|
|
539
|
+
r = subprocess.run(
|
|
540
|
+
["gh", "pr", "create", "--base", base, "--head", head, "--title", title, "--body-file", f.name],
|
|
541
|
+
cwd=root, capture_output=True, text=True, timeout=120,
|
|
542
|
+
) # fmt: skip
|
|
543
|
+
finally:
|
|
544
|
+
Path(f.name).unlink(missing_ok=True)
|
|
545
|
+
except (GitError, OSError, subprocess.SubprocessError) as e:
|
|
546
|
+
c.print(f"[red]{escape(str(e))}[/]")
|
|
547
|
+
return
|
|
548
|
+
if r.returncode != 0:
|
|
549
|
+
c.print(f"[red]gh pr create failed:[/] {escape((r.stderr or r.stdout).strip())}")
|
|
550
|
+
return
|
|
551
|
+
c.print(f"[green]✓ Opened[/] {r.stdout.strip().splitlines()[-1] if r.stdout.strip() else 'the pull request'}")
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def push(agent: Agent, root: Path, remote: str, branch: str) -> bool:
|
|
555
|
+
try:
|
|
556
|
+
with agent.console.status(f"Pushing {escape(branch)}…", spinner="dots"):
|
|
557
|
+
git(root, "push", "-q", "-u", remote, branch, timeout=300)
|
|
558
|
+
except GitError as e:
|
|
559
|
+
agent.console.print(f"[red]git push failed:[/] {escape(str(e))}")
|
|
560
|
+
return False
|
|
561
|
+
agent.console.print(f"[green]✓ Pushed[/] {escape(branch)}")
|
|
562
|
+
return True
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def branch_name(text: str) -> str:
|
|
566
|
+
name = re.sub(r"[^A-Za-z0-9._/-]+", "-", text.strip()).strip("-/.")
|
|
567
|
+
return re.sub(r"-{2,}", "-", name)[:60]
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
# ----------------------------------------------------------------------------- lcode --worktree
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
@dataclass
|
|
574
|
+
class WorktreeSession:
|
|
575
|
+
root: Path # the repository's main working tree
|
|
576
|
+
path: Path
|
|
577
|
+
branch: str
|
|
578
|
+
base: str # the commit the main working tree was on when the session started
|
|
579
|
+
created_branch: bool
|
|
580
|
+
cwd: Path
|
|
581
|
+
|
|
582
|
+
def finish(self) -> str:
|
|
583
|
+
"""Remove the worktree when nothing changed in it; otherwise say where the work is."""
|
|
584
|
+
try:
|
|
585
|
+
dirty = git(self.path, "status", "--porcelain").strip()
|
|
586
|
+
ahead = int(git(self.root, "rev-list", "--count", f"{self.base}..{self.branch}").strip() or 0)
|
|
587
|
+
except (GitError, ValueError):
|
|
588
|
+
return f"The worktree stays at {self.path}."
|
|
589
|
+
if not dirty and not ahead:
|
|
590
|
+
git(self.root, "worktree", "remove", str(self.path), check=False)
|
|
591
|
+
if self.created_branch:
|
|
592
|
+
git(self.root, "branch", "-D", self.branch, check=False)
|
|
593
|
+
return f"Nothing changed in the worktree, so it was removed{' with its branch' if self.created_branch else ''}."
|
|
594
|
+
parts = [f"{ahead} commit(s)" if ahead else "", "uncommitted changes" if dirty else ""]
|
|
595
|
+
return (
|
|
596
|
+
f"The worktree stays at {self.path} (branch {self.branch}: {' and '.join(p for p in parts if p)}). "
|
|
597
|
+
f"Continue there with `lcode --worktree {self.branch}`; merge with `git merge {self.branch}`; "
|
|
598
|
+
f"remove it with `git worktree remove {self.path}`."
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def main_checkout(cwd: Path) -> Path:
|
|
603
|
+
"""The repository's main working tree, also from inside one of its worktrees."""
|
|
604
|
+
root = repo_root(cwd)
|
|
605
|
+
common = Path(git(root, "rev-parse", "--path-format=absolute", "--git-common-dir").strip())
|
|
606
|
+
return common.parent if common.name == ".git" else root
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def worktree_path(root: Path, name: str) -> Path:
|
|
610
|
+
"""Outside the repository: inside it, the main checkout's path would lead models astray."""
|
|
611
|
+
from lcode import config
|
|
612
|
+
from lcode.memory import slugify
|
|
613
|
+
|
|
614
|
+
key = hashlib.sha256(str(root.resolve()).encode()).hexdigest()[:8]
|
|
615
|
+
return config.STATE_DIR / "worktrees" / f"{slugify(root.name, 40) or 'repo'}-{key}" / name.replace("/", "-")
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def start_worktree(cwd: Path, name: str) -> WorktreeSession:
|
|
619
|
+
"""A worktree on its own branch, for a session that works in parallel with others."""
|
|
620
|
+
root = main_checkout(cwd)
|
|
621
|
+
if not has_commits(root):
|
|
622
|
+
raise GitError("a worktree needs a repository with at least one commit")
|
|
623
|
+
name = name or f"lcode-{time.strftime('%m%d')}-{secrets.token_hex(2)}"
|
|
624
|
+
if git(root, "check-ref-format", "--branch", name, check=False).strip() != name:
|
|
625
|
+
raise GitError(f"{name!r} isn't a valid branch name")
|
|
626
|
+
path = worktree_path(root, name)
|
|
627
|
+
base = git(root, "rev-parse", "HEAD").strip()
|
|
628
|
+
created = False
|
|
629
|
+
if not (path / ".git").exists():
|
|
630
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
631
|
+
git(root, "worktree", "prune") # forget worktrees whose folders were deleted
|
|
632
|
+
if git(root, "rev-parse", "--verify", "-q", f"refs/heads/{name}", check=False).strip():
|
|
633
|
+
git(root, "worktree", "add", "-q", str(path), name)
|
|
634
|
+
else:
|
|
635
|
+
git(root, "worktree", "add", "-q", "-b", name, str(path), "HEAD")
|
|
636
|
+
created = True
|
|
637
|
+
here = cwd.resolve()
|
|
638
|
+
sub = here.relative_to(root.resolve()) if here.is_relative_to(root.resolve()) else Path()
|
|
639
|
+
inside = path / sub
|
|
640
|
+
return WorktreeSession(root, path, name, base, created, inside if inside.is_dir() else path)
|
lcode/repl.py
CHANGED
|
@@ -26,7 +26,7 @@ from rich.rule import Rule
|
|
|
26
26
|
from rich.table import Table
|
|
27
27
|
from rich.text import Text
|
|
28
28
|
|
|
29
|
-
from lcode import __version__, backends, catalog, extensions, limits, sessions, web
|
|
29
|
+
from lcode import __version__, backends, catalog, extensions, gitflow, limits, sessions, web
|
|
30
30
|
from lcode import context as context_tools
|
|
31
31
|
from lcode import memory as memory_notes
|
|
32
32
|
from lcode.agent import AUTO_COMPACT_RATIO, INIT_PROMPT, Agent
|
|
@@ -62,8 +62,12 @@ COMMANDS = {
|
|
|
62
62
|
"/mode": "Permission mode: ask | plan | auto-edit | yolo (Shift+Tab cycles)",
|
|
63
63
|
"/cd": "Change the working directory",
|
|
64
64
|
"/todos": "Show the current todo list",
|
|
65
|
+
"/commit": "Commit the changes with a message in the repository's style, after you approve it",
|
|
66
|
+
"/review": "Review the uncommitted changes, or the branch against a base: /review [base]",
|
|
67
|
+
"/pr": "Push the branch and open a GitHub pull request, after you approve it: /pr [base] [notes]",
|
|
65
68
|
"/exit": "Quit",
|
|
66
69
|
}
|
|
70
|
+
REPLACEABLE = {"/commit", "/review", "/pr"} # a command of your own with the same name runs instead
|
|
67
71
|
|
|
68
72
|
|
|
69
73
|
class InputCompleter(Completer):
|
|
@@ -420,7 +424,11 @@ def custom_commands(agent: Agent) -> dict[str, str]:
|
|
|
420
424
|
ext = agent.extensions()
|
|
421
425
|
found = {f"/{name}": skill.description for name, skill in ext.skills.items()}
|
|
422
426
|
found.update({f"/{name}": command.description for name, command in ext.commands.items()})
|
|
423
|
-
return {
|
|
427
|
+
return {
|
|
428
|
+
name: description
|
|
429
|
+
for name, description in found.items()
|
|
430
|
+
if name not in COMMANDS or (name in REPLACEABLE and name[1:] in ext.commands)
|
|
431
|
+
}
|
|
424
432
|
|
|
425
433
|
|
|
426
434
|
def help_extensions(agent: Agent) -> None:
|
|
@@ -430,7 +438,11 @@ def help_extensions(agent: Agent) -> None:
|
|
|
430
438
|
for column in ("Command", "Does", "From"):
|
|
431
439
|
table.add_column(column)
|
|
432
440
|
for command in ext.commands.values():
|
|
433
|
-
hidden = "
|
|
441
|
+
hidden = ""
|
|
442
|
+
if f"/{command.name}" in REPLACEABLE:
|
|
443
|
+
hidden = " [dim](replaces the built-in command)[/]"
|
|
444
|
+
elif f"/{command.name}" in COMMANDS:
|
|
445
|
+
hidden = " [yellow](hidden by the built-in command)[/]"
|
|
434
446
|
usage = f"/{command.name}" + (f" {command.argument_hint}" if command.argument_hint else "")
|
|
435
447
|
table.add_row(escape(usage), escape(command.description) + hidden, escape(agent.tools.rel(command.path)))
|
|
436
448
|
c.print(table)
|
|
@@ -767,6 +779,27 @@ def web_status(agent: Agent) -> str:
|
|
|
767
779
|
LONG_REQUEST = 30 # seconds: a request this long sends a notification when it's done (notification hooks)
|
|
768
780
|
|
|
769
781
|
|
|
782
|
+
def git_command(agent: Agent, cmd: str, arg: str) -> None:
|
|
783
|
+
"""/commit, /review and /pr (a command of your own with the same name replaces them)."""
|
|
784
|
+
c = agent.console
|
|
785
|
+
if cmd == "/commit":
|
|
786
|
+
gitflow.commit_command(agent, arg)
|
|
787
|
+
elif cmd == "/pr":
|
|
788
|
+
gitflow.pr_command(agent, arg)
|
|
789
|
+
else:
|
|
790
|
+
try:
|
|
791
|
+
with c.status("Collecting the changes…", spinner="dots"):
|
|
792
|
+
prompt = gitflow.review_prompt(agent, arg)
|
|
793
|
+
except gitflow.GitError as e:
|
|
794
|
+
c.print(f"[yellow]{escape(str(e))}[/]")
|
|
795
|
+
return
|
|
796
|
+
agent.no_changes = "This is a review, so nothing can be changed: report what should change instead."
|
|
797
|
+
try:
|
|
798
|
+
run_safely(agent, prompt)
|
|
799
|
+
finally:
|
|
800
|
+
agent.no_changes = ""
|
|
801
|
+
|
|
802
|
+
|
|
770
803
|
def run_safely(agent: Agent, text: str) -> None:
|
|
771
804
|
started = time.monotonic()
|
|
772
805
|
try:
|
|
@@ -900,6 +933,8 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
|
|
|
900
933
|
agent.tools.show_todos()
|
|
901
934
|
elif cmd == "/init":
|
|
902
935
|
run_safely(agent, INIT_PROMPT)
|
|
936
|
+
elif cmd in REPLACEABLE and cmd[1:] not in agent.extensions().commands:
|
|
937
|
+
git_command(agent, cmd, arg)
|
|
903
938
|
elif cmd[1:] in agent.extensions().commands:
|
|
904
939
|
command = agent.extensions().commands[cmd[1:]]
|
|
905
940
|
c.print(f"[dim]/{escape(command.name)} from {escape(agent.tools.rel(command.path))}[/]")
|
lcode/tools.py
CHANGED
|
@@ -440,6 +440,8 @@ class Toolbox:
|
|
|
440
440
|
return f"Error: {name} isn't available to you. Available: {', '.join(sorted(allowed))}"
|
|
441
441
|
if self.agent.planning() and name not in PLAN_MODE_TOOLS:
|
|
442
442
|
return f"Error: {BLOCKED}"
|
|
443
|
+
if self.agent.no_changes and name not in PLAN_MODE_TOOLS:
|
|
444
|
+
return f"Error: {self.agent.no_changes}"
|
|
443
445
|
if name == "present_plan" and not self.agent.planning():
|
|
444
446
|
return "Error: plan mode is off, so there's nothing to present: carry on with the work."
|
|
445
447
|
if self.agent.mcp and self.agent.mcp.owns(name):
|
|
@@ -484,6 +486,8 @@ class Toolbox:
|
|
|
484
486
|
kind = self.agent.agent_types().get(type)
|
|
485
487
|
if self.agent.planning() and kind and not kind.read_only:
|
|
486
488
|
raise ToolError(f"plan mode is on: only read-only agents can run now ({BLOCKED})")
|
|
489
|
+
if self.agent.no_changes and kind and not kind.read_only:
|
|
490
|
+
raise ToolError(f"only read-only agents can run now: {self.agent.no_changes}")
|
|
487
491
|
return subagents.run_one(self.agent, {"type": type, "task": task, "description": description})
|
|
488
492
|
|
|
489
493
|
# -- plan mode
|
|
@@ -777,6 +781,8 @@ class Toolbox:
|
|
|
777
781
|
sandbox = self.agent.sandbox
|
|
778
782
|
if self.agent.planning() and not is_read_only(command):
|
|
779
783
|
raise ToolError(f"{BLOCKED} Until then, only read-only commands run (ls, cat, grep, git log, …).")
|
|
784
|
+
if self.agent.no_changes and not is_read_only(command):
|
|
785
|
+
raise ToolError(f"{self.agent.no_changes} Only read-only commands run (ls, cat, grep, git diff, …).")
|
|
780
786
|
if self.agent.read_only and not is_read_only(command):
|
|
781
787
|
raise ToolError(
|
|
782
788
|
"you can only run read-only commands (such as ls, cat, grep, find, git log, git diff), one at a "
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.17.0
|
|
4
4
|
Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
|
|
5
5
|
Project-URL: Homepage, https://nasser1941.github.io/lcode/
|
|
6
6
|
Project-URL: Documentation, https://nasser1941.github.io/lcode/
|
|
@@ -101,6 +101,9 @@ through [Ollama](https://ollama.com), and when it needs current information it c
|
|
|
101
101
|
- **Remembers what matters.** Corrections, decisions and where things live carry over to the next
|
|
102
102
|
session as short notes, saved by the model, by you (`/remember`) or by a quick check at the end
|
|
103
103
|
of a session. Not whole conversations, and never secrets.
|
|
104
|
+
- **Git workflow.** `/commit` writes messages in your repository's style, `/review` checks the
|
|
105
|
+
changes with `file:line` findings, `/pr` opens the pull request, each after you approve it, and
|
|
106
|
+
`lcode --worktree` runs parallel sessions on one repository.
|
|
104
107
|
- **Bring your own model.** Eight curated open-weight models, all tested end to end, or any Ollama model
|
|
105
108
|
with tool calling. Or use LM Studio, llama.cpp, vLLM or MLX instead of Ollama.
|
|
106
109
|
- **MCP servers, ready to go.** Connect Jira and Confluence, GitHub, AWS, Google Drive, Grafana,
|
|
@@ -208,8 +211,8 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
|
|
|
208
211
|
|
|
209
212
|
See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
|
|
210
213
|
|
|
211
|
-
- [Git workflow: /commit, /review, /pr and parallel worktrees](https://github.com/nasser1941/lcode/issues/50)
|
|
212
214
|
- [Automation: JSON output, a Python API and a GitHub Action for self-hosted runners](https://github.com/nasser1941/lcode/issues/48)
|
|
215
|
+
- [Editor integration through the Agent Client Protocol](https://github.com/nasser1941/lcode/issues/49)
|
|
213
216
|
- More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
|
|
214
217
|
|
|
215
218
|
## Contributing
|
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
lcode/__init__.py,sha256=
|
|
1
|
+
lcode/__init__.py,sha256=m94TcHxJbJ3MaEvHqOhWfWdGVWdgYlJoeSId7er_5-Q,118
|
|
2
2
|
lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
|
|
3
|
-
lcode/agent.py,sha256=
|
|
3
|
+
lcode/agent.py,sha256=sFt7pfwvP0DMxIySznhWDyOLT7fujQa6jBMl9tQ2j8M,46979
|
|
4
4
|
lcode/backends.py,sha256=dtJHrC6ys73bRDg64z0tQz1PavmeAkiODZu1fOO9ExQ,20635
|
|
5
5
|
lcode/bench.py,sha256=XVdk0hDxmRcjgROOCgwwOsExcUhIxgh0B2vnxtGnA3o,40742
|
|
6
6
|
lcode/bench_backend.py,sha256=X2fo-zRX92Xp3KMxSsFlWioc2vRupBTtowj5kLME8mw,14287
|
|
7
7
|
lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
|
|
8
8
|
lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
|
|
9
|
-
lcode/cli.py,sha256=
|
|
9
|
+
lcode/cli.py,sha256=KjUezZD83VzSdLZEXMzSJO0I-mvNQSwSQdZgFpyGc-4,41090
|
|
10
10
|
lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
|
|
11
11
|
lcode/config.py,sha256=nFljzjHTFaCTTbagycagbh2X_PpLtsUs-yRODiy4QcU,10065
|
|
12
12
|
lcode/context.py,sha256=qJymLE3eYeaF6qVOzlUwDURqlky__8cskl_6vXeU3AA,10701
|
|
13
13
|
lcode/extensions.py,sha256=W0HFDl0BvROzr7i_qtabHdIbacBgP5o5qIJLzoM2oNE,16806
|
|
14
14
|
lcode/frontmatter.py,sha256=Bx0n4vLPhp7ZCWx-g7xqzqNy5VoKBxyeeikh5UFB_gA,2441
|
|
15
|
+
lcode/gitflow.py,sha256=y78dqqh80is-MiDTnSHnK9vjx0vFovor_SZ8hW1NJaU,27167
|
|
15
16
|
lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
|
|
16
17
|
lcode/hooks.py,sha256=K0itOiAuH4rxndJ9gne_uCrqby6oWAzFPRrfB3tCq-E,9324
|
|
17
18
|
lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
|
|
@@ -22,12 +23,12 @@ lcode/ollama.py,sha256=u5tnvL28cfaFS4GhmiEsHhTQe6w3Ng_Tej05UUzLQTQ,7715
|
|
|
22
23
|
lcode/permissions.py,sha256=bSDpkSzq1tozC3LgzAzXmJX_DGRxoY9a3Kdx-AFQ3nI,5426
|
|
23
24
|
lcode/planning.py,sha256=hZjVka7i9TswJrXLbjbZJ0PBc6zHpmO64JqMiaFYnMQ,6110
|
|
24
25
|
lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
|
|
25
|
-
lcode/repl.py,sha256=
|
|
26
|
+
lcode/repl.py,sha256=zJ2hj1c-tx35Sms8V-1W4XsyfFZ9omu9bZgGpEgBYi8,47655
|
|
26
27
|
lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
|
|
27
28
|
lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
|
|
28
29
|
lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
|
|
29
30
|
lcode/subagents.py,sha256=kfDICxAYd4i9LtBFxTO9tdvbAB5JzF_iQXUUK9i6yc4,28813
|
|
30
|
-
lcode/tools.py,sha256=
|
|
31
|
+
lcode/tools.py,sha256=zpFFY3dK8-g9So_MmUoj7qU8Stmx5b6PQrpcNba-s6E,42360
|
|
31
32
|
lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
|
|
32
33
|
lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
|
|
33
34
|
lcode/mcp/__init__.py,sha256=XujtAmRoXW9S0uq1XPf_CAeCkHh_UvA8FCmoAKP9j1Y,171
|
|
@@ -45,8 +46,8 @@ lcode/mcp/stdio.py,sha256=kC6KHwzMMDjBr-yCZLmyVKS7PZXU299WpZdYRoX5eRQ,6292
|
|
|
45
46
|
lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
|
|
46
47
|
lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
|
|
47
48
|
lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
|
|
48
|
-
lcode_cli-0.
|
|
49
|
-
lcode_cli-0.
|
|
50
|
-
lcode_cli-0.
|
|
51
|
-
lcode_cli-0.
|
|
52
|
-
lcode_cli-0.
|
|
49
|
+
lcode_cli-0.17.0.dist-info/METADATA,sha256=dDApqW2hYQQr89fUFigiyVph4Ib-DyYO4Ho1_zggtS4,12376
|
|
50
|
+
lcode_cli-0.17.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
51
|
+
lcode_cli-0.17.0.dist-info/entry_points.txt,sha256=pMz2Byj2QRyt-ZZQYZvmWUtmKPCeE7f8iCvu-sjZPIo,133
|
|
52
|
+
lcode_cli-0.17.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
|
|
53
|
+
lcode_cli-0.17.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|