lcode-cli 0.24.0__py3-none-any.whl → 0.26.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/action.py +8 -1
- lcode/agent.py +8 -2
- lcode/api.py +1 -0
- lcode/bench.py +9 -2
- lcode/bench_runtime.py +156 -0
- lcode/config.py +1 -0
- lcode/gitflow.py +15 -0
- lcode/secretscan.py +315 -0
- lcode/tools.py +25 -2
- {lcode_cli-0.24.0.dist-info → lcode_cli-0.26.0.dist-info}/METADATA +1 -1
- {lcode_cli-0.24.0.dist-info → lcode_cli-0.26.0.dist-info}/RECORD +15 -13
- {lcode_cli-0.24.0.dist-info → lcode_cli-0.26.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.24.0.dist-info → lcode_cli-0.26.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.24.0.dist-info → lcode_cli-0.26.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/action.py
CHANGED
|
@@ -302,7 +302,14 @@ def answer(hub: GitHub, task: Task, inputs: Inputs, cwd: Path) -> None:
|
|
|
302
302
|
reply = result.text.strip() or "(no answer)"
|
|
303
303
|
changed = git(cwd, "status", "--porcelain").strip()
|
|
304
304
|
if changed:
|
|
305
|
-
from lcode import gitflow
|
|
305
|
+
from lcode import gitflow, secretscan
|
|
306
|
+
|
|
307
|
+
found = secretscan.in_files(cwd, ["."], staged=False)
|
|
308
|
+
if found:
|
|
309
|
+
raise ActionError(
|
|
310
|
+
secretscan.report("commit", found, excerpts=False) + "\n\nSo lcode didn't commit or push its "
|
|
311
|
+
"changes. Secrets belong in the repository's secrets or in configuration that git ignores."
|
|
312
|
+
)
|
|
306
313
|
|
|
307
314
|
data = gitflow.ask(
|
|
308
315
|
session.agent,
|
lcode/agent.py
CHANGED
|
@@ -81,14 +81,18 @@ SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent runni
|
|
|
81
81
|
- Use your tools to gather facts. Never guess file contents, APIs, or command output — read, search, or run them.
|
|
82
82
|
- To understand a repository: look at the layout, README/docs, entry points and config, then grep and read the relevant files. Cite code as `path:line`.
|
|
83
83
|
- Read a file before editing it. Use edit_file for targeted changes and write_file for new files or full rewrites. old_string must be copied exactly from the file (without the line-number prefixes).
|
|
84
|
-
- After writing or changing code, verify it: run the script, the tests, or at least a syntax check (e.g. `{python} -m py_compile file.py`). Fix what fails.
|
|
85
84
|
- Match the existing code style, naming and structure. Don't add unrequested features.
|
|
86
85
|
- For multi-step tasks, plan with todo_write and keep it updated.
|
|
87
86
|
- Work autonomously until the task is done. Only stop to ask the user when you are genuinely blocked or the decision is theirs.
|
|
88
87
|
- Don't run destructive or irreversible commands (rm -rf, git reset --hard, git push --force, dropping data) unless the user explicitly asked.
|
|
89
|
-
- Never claim something works without having run it. If something fails, say so and show the relevant error.
|
|
90
88
|
- If the user denies a tool call, don't retry the same thing; adjust or ask.
|
|
91
89
|
|
|
90
|
+
# Verifying your work
|
|
91
|
+
- A syntax check or an import only shows that code loads. Run what you changed the way it will be used (the script, the tests, the app) and fix what fails.
|
|
92
|
+
- To fix a bug, first reproduce each problem the user reports with a test, a command or a short script, then run the same checks to show they're gone.
|
|
93
|
+
- For what you can't see, like a GUI, a web page or a layout, check it without a screen where you can: `QT_QPA_PLATFORM=offscreen` for Qt, a headless browser, or a script that prints sizes and positions or renders to an image{look}. Measure instead of assuming.
|
|
94
|
+
- Never claim something works, mark a todo done, or call it tested unless you ran it. If you couldn't check something, say so and tell the user exactly what to look for. If something fails, say so and show the relevant error.
|
|
95
|
+
|
|
92
96
|
# Communication
|
|
93
97
|
- Be concise and direct; no filler. Use GitHub-flavored markdown.
|
|
94
98
|
- When you finish, give a short summary of what you found or changed and anything the user needs to do.
|
|
@@ -229,6 +233,7 @@ class Settings:
|
|
|
229
233
|
notify: bool = False # desktop notifications after long requests (lcode.notify)
|
|
230
234
|
notify_after: int = 30 # seconds: a request this long notifies when it's done or waits for an answer
|
|
231
235
|
max_parallel_agents: int = 1
|
|
236
|
+
secret_check: bool = True # scan what git commit and git push would add (lcode.secretscan)
|
|
232
237
|
repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
|
|
233
238
|
|
|
234
239
|
|
|
@@ -317,6 +322,7 @@ class Agent:
|
|
|
317
322
|
skills=extensions.skills_prompt(self.extensions().skills, self.settings.context),
|
|
318
323
|
code=self.code_prompt(),
|
|
319
324
|
map=self.map_prompt(),
|
|
325
|
+
look=" you look at with view_image" if self.vision_model() else "",
|
|
320
326
|
)
|
|
321
327
|
|
|
322
328
|
def repo_map(self) -> repomap.RepoMap | None:
|
lcode/api.py
CHANGED
|
@@ -194,6 +194,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
|
|
|
194
194
|
embed_model=cfg["embed_model"],
|
|
195
195
|
notify=cfg["notify"],
|
|
196
196
|
notify_after=cfg["notify_after"],
|
|
197
|
+
secret_check=cfg["secret_check"],
|
|
197
198
|
repeat_limit=cfg["repeat_limit"],
|
|
198
199
|
)
|
|
199
200
|
agent = Agent(ollama, settings, cwd, console=console)
|
lcode/bench.py
CHANGED
|
@@ -746,7 +746,7 @@ TASKS = [
|
|
|
746
746
|
|
|
747
747
|
def extra_tasks() -> list[Task]:
|
|
748
748
|
"""Tasks that only run when asked for with --tasks (they're slower, or test optional features)."""
|
|
749
|
-
from lcode import bench_backend
|
|
749
|
+
from lcode import bench_backend, bench_runtime
|
|
750
750
|
|
|
751
751
|
return [
|
|
752
752
|
Task(
|
|
@@ -755,7 +755,14 @@ def extra_tasks() -> list[Task]:
|
|
|
755
755
|
bench_backend.PROMPT,
|
|
756
756
|
bench_backend.FILES,
|
|
757
757
|
bench_backend.check,
|
|
758
|
-
)
|
|
758
|
+
),
|
|
759
|
+
Task(
|
|
760
|
+
"runtime-bug",
|
|
761
|
+
"Fix a bug that shows only when it runs",
|
|
762
|
+
bench_runtime.PROMPT,
|
|
763
|
+
bench_runtime.FILES,
|
|
764
|
+
bench_runtime.check,
|
|
765
|
+
),
|
|
759
766
|
]
|
|
760
767
|
|
|
761
768
|
|
lcode/bench_runtime.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""A bug that only shows when the code runs, for `lcode bench --tasks runtime-bug`.
|
|
2
|
+
|
|
3
|
+
A camera wall lays out video panels in a window. While video plays, the window grows taller frame after
|
|
4
|
+
frame, past the screen, and can't be made smaller again: each panel's minimum size is taken from the
|
|
5
|
+
frame it shows, and the frame is scaled to the whole cell, status bar included. Reading the code, the
|
|
6
|
+
obvious fixes are band-aids (ignore the minimum sizes, or only fix the scaling) that fail part of the
|
|
7
|
+
hidden test. Running it, the window sizes show the ratchet. It's the kind of bug a GUI has, without
|
|
8
|
+
needing one, and rewards reproducing a problem before fixing it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
WALL = '''\
|
|
17
|
+
"""A camera wall: a window with a grid of panels, each showing its camera's latest video frame."""
|
|
18
|
+
|
|
19
|
+
SPACING = 8 # pixels between and around the panels
|
|
20
|
+
STATUS_BAR = 20 # under its video, each panel shows the camera's name and status
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Panel:
|
|
24
|
+
def __init__(self, name):
|
|
25
|
+
self.name = name
|
|
26
|
+
self.min_width = self.min_height = 1 # the panel can't get smaller than this
|
|
27
|
+
self.video = (0, 0) # the size of the frame on screen
|
|
28
|
+
|
|
29
|
+
def show(self, frame_width, frame_height, cell_width, cell_height):
|
|
30
|
+
"""Show a frame scaled to fit the panel's cell, keeping its aspect ratio."""
|
|
31
|
+
scale = min(cell_width / frame_width, cell_height / frame_height)
|
|
32
|
+
self.video = (int(frame_width * scale), int(frame_height * scale))
|
|
33
|
+
# The video and the status bar under it must stay visible.
|
|
34
|
+
self.min_width, self.min_height = self.video[0], self.video[1] + STATUS_BAR
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Wall:
|
|
38
|
+
def __init__(self, names, rows, cols):
|
|
39
|
+
self.rows, self.cols = rows, cols
|
|
40
|
+
self.panels = [Panel(name) for name in names]
|
|
41
|
+
self.width = self.height = 0
|
|
42
|
+
|
|
43
|
+
def resize(self, width, height):
|
|
44
|
+
"""Ask for a window size. The window can't be smaller than its panels need."""
|
|
45
|
+
need_width = max(p.min_width for p in self.panels) * self.cols + SPACING * (self.cols + 1)
|
|
46
|
+
need_height = max(p.min_height for p in self.panels) * self.rows + SPACING * (self.rows + 1)
|
|
47
|
+
self.width, self.height = max(width, need_width), max(height, need_height)
|
|
48
|
+
|
|
49
|
+
def cell(self):
|
|
50
|
+
"""The size of one panel's cell."""
|
|
51
|
+
return (
|
|
52
|
+
(self.width - SPACING * (self.cols + 1)) // self.cols,
|
|
53
|
+
(self.height - SPACING * (self.rows + 1)) // self.rows,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def new_frame(self, frame_width, frame_height):
|
|
57
|
+
"""Every camera delivered a frame: show it in each panel, then let the layout settle."""
|
|
58
|
+
cell_width, cell_height = self.cell()
|
|
59
|
+
for panel in self.panels:
|
|
60
|
+
panel.show(frame_width, frame_height, cell_width, cell_height)
|
|
61
|
+
self.resize(self.width, self.height)
|
|
62
|
+
'''
|
|
63
|
+
|
|
64
|
+
SIMULATE = '''\
|
|
65
|
+
"""Play the camera wall without a screen: python simulate.py [WIDTH HEIGHT [ROWSxCOLS]]"""
|
|
66
|
+
|
|
67
|
+
import sys
|
|
68
|
+
|
|
69
|
+
from wall import Wall
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main():
|
|
73
|
+
width, height = (int(sys.argv[1]), int(sys.argv[2])) if len(sys.argv) > 2 else (2560, 1080)
|
|
74
|
+
rows, cols = map(int, sys.argv[3].split("x")) if len(sys.argv) > 3 else (3, 3)
|
|
75
|
+
wall = Wall([f"camera {n + 1}" for n in range(rows * cols)], rows, cols)
|
|
76
|
+
wall.resize(width * 9 // 10, height * 9 // 10)
|
|
77
|
+
print(f"{rows}x{cols} wall on a {width}x{height} screen, window {wall.width}x{wall.height}")
|
|
78
|
+
for n in range(1, 6):
|
|
79
|
+
wall.new_frame(1920, 1080)
|
|
80
|
+
print(f"after frame {n}: window {wall.width}x{wall.height}, video {wall.panels[0].video}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
main()
|
|
85
|
+
'''
|
|
86
|
+
|
|
87
|
+
FILES = {"wall.py": WALL, "simulate.py": SIMULATE}
|
|
88
|
+
|
|
89
|
+
PROMPT = (
|
|
90
|
+
"Users say the camera wall in wall.py keeps growing taller while video plays, until it's bigger than "
|
|
91
|
+
"their screen, and after that it can't be made smaller again. Fix it."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
HIDDEN_TEST = """\
|
|
95
|
+
import unittest
|
|
96
|
+
|
|
97
|
+
from wall import SPACING, STATUS_BAR, Wall
|
|
98
|
+
|
|
99
|
+
SCREENS = [(2560, 1080, 3, 3), (1920, 1080, 2, 3), (1366, 768, 3, 3), (3440, 1440, 2, 4), (1280, 1024, 2, 2)]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def play(width, height, rows, cols, frames=30, frame=(1920, 1080)):
|
|
103
|
+
wall = Wall([f"camera {n}" for n in range(rows * cols)], rows, cols)
|
|
104
|
+
wall.resize(width, height)
|
|
105
|
+
for _ in range(frames):
|
|
106
|
+
wall.new_frame(*frame)
|
|
107
|
+
return wall
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class HiddenTest(unittest.TestCase):
|
|
111
|
+
def test_keeps_its_size(self):
|
|
112
|
+
for width, height, rows, cols in SCREENS:
|
|
113
|
+
with self.subTest(screen=(width, height), grid=(rows, cols)):
|
|
114
|
+
wall = play(width * 9 // 10, height * 9 // 10, rows, cols)
|
|
115
|
+
self.assertEqual((wall.width, wall.height), (width * 9 // 10, height * 9 // 10))
|
|
116
|
+
|
|
117
|
+
def test_can_be_made_smaller(self):
|
|
118
|
+
wall = play(2304, 972, 3, 3)
|
|
119
|
+
wall.resize(1280, 720)
|
|
120
|
+
wall.new_frame(1920, 1080)
|
|
121
|
+
self.assertEqual((wall.width, wall.height), (1280, 720))
|
|
122
|
+
|
|
123
|
+
def test_video_fits_and_fills_its_cell(self):
|
|
124
|
+
for width, height, rows, cols in SCREENS:
|
|
125
|
+
for frame in [(1920, 1080), (1280, 960), (720, 1280)]:
|
|
126
|
+
with self.subTest(screen=(width, height), grid=(rows, cols), frame=frame):
|
|
127
|
+
wall = play(width * 9 // 10, height * 9 // 10, rows, cols, frames=3, frame=frame)
|
|
128
|
+
cell_width = (wall.width - SPACING * (cols + 1)) // cols
|
|
129
|
+
cell_height = (wall.height - SPACING * (rows + 1)) // rows
|
|
130
|
+
for panel in wall.panels:
|
|
131
|
+
video_width, video_height = panel.video
|
|
132
|
+
self.assertLessEqual(video_width, cell_width)
|
|
133
|
+
self.assertLessEqual(video_height + STATUS_BAR, cell_height)
|
|
134
|
+
fills = video_width >= cell_width - 2 or video_height + STATUS_BAR >= cell_height - 2
|
|
135
|
+
self.assertTrue(fills, "the video doesn't use the room it has")
|
|
136
|
+
self.assertAlmostEqual(video_width / video_height, frame[0] / frame[1], delta=0.02)
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
PROBLEMS = {
|
|
140
|
+
"test_keeps_its_size": "the window still grows",
|
|
141
|
+
"test_can_be_made_smaller": "the window can't be made smaller",
|
|
142
|
+
"test_video_fits_and_fills_its_cell": "the video doesn't fit its cell above the status bar (or doesn't fill it)",
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def check(folder: Path, answer: str):
|
|
147
|
+
from lcode.bench import Check, last_line, run_python
|
|
148
|
+
|
|
149
|
+
if not (folder / "wall.py").is_file():
|
|
150
|
+
return Check(False, "wall.py is missing")
|
|
151
|
+
(folder / "test_hidden_wall.py").write_text(HIDDEN_TEST)
|
|
152
|
+
r = run_python(folder, "-m", "unittest", "-q", "test_hidden_wall")
|
|
153
|
+
if r.returncode == 0:
|
|
154
|
+
return Check(True, "keeps its size, can be made smaller, and the video fits")
|
|
155
|
+
failed = [text for name, text in PROBLEMS.items() if re.search(rf"\b{name}\b", r.stderr)]
|
|
156
|
+
return Check(False, "; ".join(failed) or last_line(r.stderr) or last_line(r.stdout))
|
lcode/config.py
CHANGED
|
@@ -60,6 +60,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
|
60
60
|
"notify": (True, bool, "desktop notification when a long request is done or waits for you"),
|
|
61
61
|
"notify_after": (30, int, "seconds a request runs before it notifies (with notify on)"),
|
|
62
62
|
"repeat_limit": (3, int, "identical tool calls with identical results before lcode steps in (0: never)"),
|
|
63
|
+
"secret_check": (True, bool, "before git commit and git push, look for passwords, keys and tokens they'd add"),
|
|
63
64
|
"max_parallel_agents": (1, int, "subagents that may run at the same time (more needs OLLAMA_NUM_PARALLEL)"),
|
|
64
65
|
}
|
|
65
66
|
ENV_OVERRIDES = {
|
lcode/gitflow.py
CHANGED
|
@@ -24,6 +24,7 @@ from typing import TYPE_CHECKING
|
|
|
24
24
|
|
|
25
25
|
from rich.markup import escape
|
|
26
26
|
from rich.panel import Panel
|
|
27
|
+
from rich.text import Text
|
|
27
28
|
|
|
28
29
|
if TYPE_CHECKING:
|
|
29
30
|
from lcode.agent import Agent
|
|
@@ -316,6 +317,9 @@ def commit_command(agent: Agent, hint: str = "") -> None:
|
|
|
316
317
|
chosen = [f for f in answer.get("files") or [] if f in paths]
|
|
317
318
|
files = chosen or paths
|
|
318
319
|
left_out = [p for p in paths if p not in files]
|
|
320
|
+
from lcode import secretscan
|
|
321
|
+
|
|
322
|
+
secrets_found = secretscan.in_files(root, files, bool(staged)) if agent.settings.secret_check else []
|
|
319
323
|
while True:
|
|
320
324
|
c.print(Panel(escape(message), title="Commit message", title_align="left", border_style="cyan"))
|
|
321
325
|
c.print(f" [bold]Files[/] {escape(', '.join(files))}")
|
|
@@ -323,6 +327,9 @@ def commit_command(agent: Agent, hint: str = "") -> None:
|
|
|
323
327
|
c.print(f" [dim]Left out (unrelated, the model thinks): {escape(', '.join(left_out))}[/]")
|
|
324
328
|
if risky:
|
|
325
329
|
c.print(f" [dim]Left out (may hold secrets; stage them yourself if needed): {escape(', '.join(risky))}[/]")
|
|
330
|
+
if secrets_found:
|
|
331
|
+
c.print(Text(secretscan.report("commit", secrets_found), style="red"))
|
|
332
|
+
c.print(" [dim]Commit them only if they aren't secrets; otherwise move them out of the code first.[/]")
|
|
326
333
|
choice = confirm(agent, " [y] commit · [e] edit the message · [n] cancel: ", message)
|
|
327
334
|
if choice == "e":
|
|
328
335
|
from lcode.planning import edit_text
|
|
@@ -552,6 +559,14 @@ def pr_command(agent: Agent, arg: str = "") -> None:
|
|
|
552
559
|
|
|
553
560
|
|
|
554
561
|
def push(agent: Agent, root: Path, remote: str, branch: str) -> bool:
|
|
562
|
+
from lcode import secretscan
|
|
563
|
+
|
|
564
|
+
found = secretscan.outgoing(root, branch) if agent.settings.secret_check else []
|
|
565
|
+
if found:
|
|
566
|
+
agent.console.print(Text(secretscan.report("push", found), style="red"))
|
|
567
|
+
if confirm(agent, " Push anyway? [y]es / [n]o: ") != "y":
|
|
568
|
+
agent.console.print("Not pushed.")
|
|
569
|
+
return False
|
|
555
570
|
try:
|
|
556
571
|
with agent.console.status(f"Pushing {escape(branch)}…", spinner="dots"):
|
|
557
572
|
git(root, "push", "-q", "-u", remote, branch, timeout=300)
|
lcode/secretscan.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Stop secrets before they're committed or pushed.
|
|
2
|
+
|
|
3
|
+
Before the model runs `git commit` or `git push`, lcode scans what that would add to the repository's
|
|
4
|
+
history: the lines a commit adds (including files a `git add` in the same command stages), or every commit
|
|
5
|
+
a push would send. Passwords and tokens in code, private keys, credentials in URLs (`rtsp://user:pass@…`)
|
|
6
|
+
and `.env` files are reported with their file and line. In `ask` mode the user decides; in the other modes
|
|
7
|
+
the command doesn't run, and the model is told to move the secret out of the code.
|
|
8
|
+
|
|
9
|
+
A line that holds a value which only looks like a secret can carry `lcode: allow-secret` (or
|
|
10
|
+
`gitleaks:allow`) in a comment, and is skipped.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import shlex
|
|
17
|
+
import subprocess
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from lcode.memory import SECRET_PATTERNS
|
|
22
|
+
|
|
23
|
+
ALLOW = ("lcode: allow-secret", "lcode:allow-secret", "gitleaks:allow")
|
|
24
|
+
MAX_FINDINGS = 10
|
|
25
|
+
GIT_TIMEOUT = 20
|
|
26
|
+
MAX_FILE = 1_000_000 # bytes of an untracked file that are scanned
|
|
27
|
+
|
|
28
|
+
# Values that only stand in for a secret.
|
|
29
|
+
PLACEHOLDER = re.compile(
|
|
30
|
+
r"^(?:change[_-]?me\w*|your[_-]?\w*|x+|\*+|\.+|…+|<[^>]*>"
|
|
31
|
+
r"|(?:dummy|example|placeholder|redacted|secret|password|passwd|pass|pwd|token|test|changeit|none|null"
|
|
32
|
+
r"|true|false|required|optional|hidden|masked)(?:[_-]?here)?)$",
|
|
33
|
+
re.I,
|
|
34
|
+
)
|
|
35
|
+
# Words that only appear in made-up values: "change-me-in-production", "test-token", "example-key"…
|
|
36
|
+
FAKE_WORDS = (
|
|
37
|
+
"example",
|
|
38
|
+
"sample",
|
|
39
|
+
"dummy",
|
|
40
|
+
"fake",
|
|
41
|
+
"changeme",
|
|
42
|
+
"change-me",
|
|
43
|
+
"change_me",
|
|
44
|
+
"placeholder",
|
|
45
|
+
"redacted",
|
|
46
|
+
"test",
|
|
47
|
+
)
|
|
48
|
+
# A password, key or token written in code or configuration as a literal value.
|
|
49
|
+
ASSIGNED = re.compile(
|
|
50
|
+
r"""(?P<name>\b[\w.-]*(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key"""
|
|
51
|
+
r"""|client[_-]?secret)[\w.-]*)['"]?\s*(?::|=|:=|=>)\s*(?P<quote>['"]?)(?P<value>[^\s'"`,;)}\]]{6,})"""
|
|
52
|
+
r"""(?P=quote)""",
|
|
53
|
+
re.I,
|
|
54
|
+
)
|
|
55
|
+
# Names that hold something about a secret (where it is, how it looks), not the secret itself.
|
|
56
|
+
ABOUT = re.compile(
|
|
57
|
+
r"(?:url|uri|endpoint|file|path|dir|env|var|variable|name|field|header|type|label|prompt|hint|length|len|min|"
|
|
58
|
+
r"max|policy|pattern|regex|format|count|expires?|expiry|ttl|timeout|limit|required|enabled|hash|salt_rounds)$",
|
|
59
|
+
re.I,
|
|
60
|
+
)
|
|
61
|
+
EXTRA = [
|
|
62
|
+
("an xAI API key", re.compile(r"\bxai-[A-Za-z0-9]{20,}")),
|
|
63
|
+
("a Hugging Face token", re.compile(r"\bhf_[A-Za-z0-9]{30,}")),
|
|
64
|
+
("a Stripe key", re.compile(r"\b(?:sk|rk)_live_[A-Za-z0-9]{20,}")),
|
|
65
|
+
]
|
|
66
|
+
CONFIG_FILES = re.compile(r"(?:\.(?:env|ini|cfg|conf|config|properties|ya?ml|toml)|(?:^|/)\.env[\w.-]*)$", re.I)
|
|
67
|
+
URL_PASSWORD = re.compile(r"\b[a-z][a-z0-9+.\-]*://(?P<user>[^\s/:@'\"]+):(?P<value>[^\s/@'\"]+)@", re.I)
|
|
68
|
+
STRONG = [p for p in SECRET_PATTERNS if p[0] not in ("a password in a URL", "a password or key")] + EXTRA
|
|
69
|
+
ENV_FILE = re.compile(r"(?:^|/)\.env(?:\.[\w-]+)?$")
|
|
70
|
+
ENV_EXAMPLES = re.compile(r"\.(?:example|sample|template|dist|defaults?)$")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class Finding:
|
|
75
|
+
path: str
|
|
76
|
+
line: int # 0: the whole file
|
|
77
|
+
label: str # what it looks like, e.g. "a password"
|
|
78
|
+
excerpt: str # the line, with the secret masked
|
|
79
|
+
commit: str = "" # for a push: the commit that adds it
|
|
80
|
+
|
|
81
|
+
def describe(self, excerpt: bool = True) -> str:
|
|
82
|
+
where = f"{self.path}:{self.line}" if self.line else self.path
|
|
83
|
+
if self.commit:
|
|
84
|
+
where += f" (commit {self.commit})"
|
|
85
|
+
return f"{where}: {self.label}" + (f": {self.excerpt}" if self.excerpt and excerpt else "")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def mask(value: str) -> str:
|
|
89
|
+
return value[:2] + "…" if len(value) > 4 else "…"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def placeholder(value: str) -> bool:
|
|
93
|
+
"""A value that stands in for a secret: CHANGE_ME, <token>, ${PASSWORD}, a path, a web address…"""
|
|
94
|
+
if PLACEHOLDER.match(value) or any(c in value for c in "{}$%<>"):
|
|
95
|
+
return True
|
|
96
|
+
if any(word in value.lower() for word in FAKE_WORDS):
|
|
97
|
+
return True
|
|
98
|
+
return value.startswith(("/", "./", "~/", "http:", "https:", "os.", "process.env", "env."))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def scan_line(text: str, config: bool = False) -> tuple[str, str] | None:
|
|
102
|
+
"""What kind of secret the line holds, and the line with it masked. In configuration files (`config`) a
|
|
103
|
+
value counts without quotes too, as in `PASSWORD=hunter22` or `password: hunter22`."""
|
|
104
|
+
if any(marker in text for marker in ALLOW):
|
|
105
|
+
return None
|
|
106
|
+
shown = text.strip()[:160]
|
|
107
|
+
for label, pattern in STRONG:
|
|
108
|
+
found = pattern.search(text)
|
|
109
|
+
if found:
|
|
110
|
+
return label, shown.replace(found.group(0), mask(found.group(0)))
|
|
111
|
+
found = URL_PASSWORD.search(text)
|
|
112
|
+
if found and not placeholder(found.group("value")) and not placeholder(found.group("user")):
|
|
113
|
+
return "a password in a URL", shown.replace(found.group("value"), mask(found.group("value")))
|
|
114
|
+
for found in ASSIGNED.finditer(text):
|
|
115
|
+
value = found.group("value")
|
|
116
|
+
if placeholder(value) or ABOUT.search(found.group("name")):
|
|
117
|
+
continue
|
|
118
|
+
if not found.group("quote") and not (config and re.match(r"^\s*(?:export\s+)?[\w.-]+\s*[:=]", text)):
|
|
119
|
+
continue # in code, only a quoted literal is a value: `password = args.password` isn't one
|
|
120
|
+
return "a password or key", shown.replace(value, mask(value))
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def scan_file(path: str, lines: list[tuple[int, str]], commit: str = "") -> list[Finding]:
|
|
125
|
+
if ENV_FILE.search(path) and not ENV_EXAMPLES.search(path):
|
|
126
|
+
return [Finding(path, 0, "an environment file (.env), which usually holds secrets", "", commit)]
|
|
127
|
+
findings = []
|
|
128
|
+
config = bool(CONFIG_FILES.search(path))
|
|
129
|
+
for number, text in lines:
|
|
130
|
+
hit = scan_line(text, config)
|
|
131
|
+
if hit:
|
|
132
|
+
findings.append(Finding(path, number, hit[0], hit[1], commit))
|
|
133
|
+
return findings
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def scan_diff(diff: str) -> list[Finding]:
|
|
137
|
+
"""The secrets in the lines a unified diff adds (`git diff`, or `git log -p` with commit: lines)."""
|
|
138
|
+
findings: list[Finding] = []
|
|
139
|
+
path, number, commit = "", 0, ""
|
|
140
|
+
added: list[tuple[int, str]] = []
|
|
141
|
+
|
|
142
|
+
def flush() -> None:
|
|
143
|
+
if path and (added or ENV_FILE.search(path)):
|
|
144
|
+
findings.extend(scan_file(path, added, commit))
|
|
145
|
+
added.clear()
|
|
146
|
+
|
|
147
|
+
for line in diff.splitlines():
|
|
148
|
+
if line.startswith("commit:"):
|
|
149
|
+
flush()
|
|
150
|
+
commit, path = line.split(":", 1)[1].strip(), ""
|
|
151
|
+
elif line.startswith("+++ "):
|
|
152
|
+
flush()
|
|
153
|
+
target = line[4:].strip()
|
|
154
|
+
path = "" if target == "/dev/null" else target.removeprefix("b/")
|
|
155
|
+
elif line.startswith("@@"):
|
|
156
|
+
hunk = re.search(r"\+(\d+)", line)
|
|
157
|
+
number = int(hunk.group(1)) if hunk else 0
|
|
158
|
+
elif line.startswith("+") and path:
|
|
159
|
+
added.append((number, line[1:]))
|
|
160
|
+
number += 1
|
|
161
|
+
elif line.startswith(" "):
|
|
162
|
+
number += 1
|
|
163
|
+
flush()
|
|
164
|
+
return findings
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def git(cwd: Path, *args: str) -> str:
|
|
168
|
+
try:
|
|
169
|
+
done = subprocess.run(
|
|
170
|
+
["git", *args], cwd=cwd, capture_output=True, text=True, errors="replace", timeout=GIT_TIMEOUT
|
|
171
|
+
)
|
|
172
|
+
except (OSError, subprocess.SubprocessError):
|
|
173
|
+
return ""
|
|
174
|
+
return done.stdout if done.returncode == 0 else ""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
GIT_VERB = re.compile(r"\bgit\b(?:\s+-[-\w=.]+(?:\s+[^\s'\"-]\S*)?)*\s+(commit|push)\b")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass
|
|
181
|
+
class GitSteps:
|
|
182
|
+
adds: list[list[str]] # the arguments of each `git add`
|
|
183
|
+
commit: list[str] | None # the arguments of `git commit`
|
|
184
|
+
push: list[str] | None # the arguments of `git push`
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def git_steps(command: str) -> GitSteps | None:
|
|
188
|
+
"""The git add, commit and push a shell command runs, or None when it commits and pushes nothing."""
|
|
189
|
+
steps = GitSteps([], None, None)
|
|
190
|
+
for segment in re.split(r"&&|\|\||;|\n|\|", command):
|
|
191
|
+
try:
|
|
192
|
+
words = shlex.split(segment)
|
|
193
|
+
except ValueError:
|
|
194
|
+
words = segment.split()
|
|
195
|
+
while words and re.fullmatch(r"\w+=\S*", words[0]):
|
|
196
|
+
words = words[1:] # VAR=value in front
|
|
197
|
+
if len(words) < 2 or words[0] != "git":
|
|
198
|
+
continue
|
|
199
|
+
i = 1
|
|
200
|
+
while i < len(words) and words[i].startswith("-"):
|
|
201
|
+
i += 2 if words[i] in ("-C", "-c") else 1 # git -C dir / -c key=value
|
|
202
|
+
if i >= len(words):
|
|
203
|
+
continue
|
|
204
|
+
verb, rest = words[i].strip("'\""), words[i + 1 :]
|
|
205
|
+
if verb == "add":
|
|
206
|
+
steps.adds.append(rest)
|
|
207
|
+
elif verb == "commit":
|
|
208
|
+
steps.commit = rest
|
|
209
|
+
elif verb == "push":
|
|
210
|
+
steps.push = rest
|
|
211
|
+
# Inside quotes, as in bash -c "git add . && git commit …", sh -c or eval, what gets staged can't be told
|
|
212
|
+
# from the words: assume everything.
|
|
213
|
+
nested = [verb for m in re.finditer(r"(['\"])(.*?)\1", command, re.S) for verb in GIT_VERB.findall(m.group(2))]
|
|
214
|
+
if steps.commit is None and steps.push is None:
|
|
215
|
+
nested += GIT_VERB.findall(command)
|
|
216
|
+
if "commit" in nested:
|
|
217
|
+
steps.commit, steps.adds = ["--all"], [["."]]
|
|
218
|
+
if "push" in nested and steps.push is None:
|
|
219
|
+
steps.push = []
|
|
220
|
+
return steps if steps.commit is not None or steps.push is not None else None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def untracked(cwd: Path, paths: list[str]) -> list[Finding]:
|
|
224
|
+
findings = []
|
|
225
|
+
for name in git(cwd, "ls-files", "--others", "--exclude-standard", "--", *paths).splitlines():
|
|
226
|
+
file = cwd / name
|
|
227
|
+
try:
|
|
228
|
+
if file.stat().st_size > MAX_FILE:
|
|
229
|
+
continue
|
|
230
|
+
text = file.read_text(errors="strict")
|
|
231
|
+
except (OSError, UnicodeDecodeError):
|
|
232
|
+
continue # binary or unreadable
|
|
233
|
+
findings.extend(scan_file(name, list(enumerate(text.splitlines(), 1))))
|
|
234
|
+
return findings
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def to_commit(cwd: Path, steps: GitSteps) -> list[Finding]:
|
|
238
|
+
"""What `git add` and `git commit` in the command would commit: the staged changes, plus what it stages."""
|
|
239
|
+
args = steps.commit or []
|
|
240
|
+
everything = any(a in ("-a", "--all") or re.fullmatch(r"-[a-zA-Z]*a[a-zA-Z]*", a) for a in args if a != "--amend")
|
|
241
|
+
findings = scan_diff(git(cwd, "diff", "--cached", "--no-color", "--no-ext-diff", "-U0"))
|
|
242
|
+
if everything:
|
|
243
|
+
findings += scan_diff(git(cwd, "diff", "--no-color", "--no-ext-diff", "-U0"))
|
|
244
|
+
for add in steps.adds:
|
|
245
|
+
options = [a for a in add if a.startswith("-")]
|
|
246
|
+
paths = [a for a in add if not a.startswith("-")] or (["."] if {"-A", "--all"} & set(options) else [])
|
|
247
|
+
if not paths and "-u" not in options and "--update" not in options:
|
|
248
|
+
continue
|
|
249
|
+
paths = paths or ["."]
|
|
250
|
+
findings += scan_diff(git(cwd, "diff", "--no-color", "--no-ext-diff", "-U0", "--", *paths))
|
|
251
|
+
if "-u" not in options and "--update" not in options:
|
|
252
|
+
findings += untracked(cwd, paths)
|
|
253
|
+
return findings
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def to_push(cwd: Path, steps: GitSteps) -> list[Finding]:
|
|
257
|
+
"""The secrets in the commits a push would send that no remote has yet."""
|
|
258
|
+
refs = [a for a in (steps.push or []) if not a.startswith("-")]
|
|
259
|
+
branches = [r.split(":", 1)[0].lstrip("+") for r in refs[1:]] or ["HEAD"]
|
|
260
|
+
findings: list[Finding] = []
|
|
261
|
+
for branch in branches:
|
|
262
|
+
if not branch or not git(cwd, "rev-parse", "--verify", "--quiet", f"{branch}^{{commit}}").strip():
|
|
263
|
+
continue
|
|
264
|
+
log = git(
|
|
265
|
+
cwd, "log", "-p", "--no-color", "--no-ext-diff", "-U0", "--format=commit:%h", branch, "--not", "--remotes"
|
|
266
|
+
)
|
|
267
|
+
findings += scan_diff(log)
|
|
268
|
+
return findings
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def check(cwd: Path, command: str) -> tuple[str, list[Finding]]:
|
|
272
|
+
"""('commit' or 'push', the secrets it would add to the history) for a shell command."""
|
|
273
|
+
steps = git_steps(command)
|
|
274
|
+
if steps is None or not git(cwd, "rev-parse", "--git-dir").strip():
|
|
275
|
+
return "", []
|
|
276
|
+
findings: list[Finding] = []
|
|
277
|
+
if steps.commit is not None:
|
|
278
|
+
findings += to_commit(cwd, steps)
|
|
279
|
+
if steps.push is not None:
|
|
280
|
+
findings += to_push(cwd, steps)
|
|
281
|
+
unique = list({(f.path, f.line, f.label): f for f in findings}.values())
|
|
282
|
+
return ("push" if steps.push is not None and steps.commit is None else "commit"), unique
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def in_files(cwd: Path, paths: list[str], staged: bool) -> list[Finding]:
|
|
286
|
+
"""For lcode's /commit: the secrets in the staged changes, or in the changes to these files."""
|
|
287
|
+
if staged:
|
|
288
|
+
return scan_diff(git(cwd, "diff", "--cached", "--no-color", "--no-ext-diff", "-U0"))
|
|
289
|
+
if not paths:
|
|
290
|
+
return []
|
|
291
|
+
diffs = [
|
|
292
|
+
git(cwd, "diff", *staged_flag, "--no-color", "--no-ext-diff", "-U0", "--", *paths)
|
|
293
|
+
for staged_flag in ([], ["--cached"])
|
|
294
|
+
]
|
|
295
|
+
return scan_diff("\n".join(diffs)) + untracked(cwd, paths)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def outgoing(cwd: Path, branch: str = "HEAD") -> list[Finding]:
|
|
299
|
+
"""For lcode's /pr: the secrets in the commits of `branch` that no remote has yet."""
|
|
300
|
+
return to_push(cwd, GitSteps([], None, ["origin", branch]))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def report(action: str, findings: list[Finding], excerpts: bool = True) -> str:
|
|
304
|
+
"""The findings, one per line; without `excerpts` for places others read, like a public comment."""
|
|
305
|
+
lines = [f"- {f.describe(excerpts)}" for f in findings[:MAX_FINDINGS]]
|
|
306
|
+
if len(findings) > MAX_FINDINGS:
|
|
307
|
+
lines.append(f"- … and {len(findings) - MAX_FINDINGS} more")
|
|
308
|
+
return f"This {action} would add what looks like a secret to the repository's history:\n" + "\n".join(lines)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
ADVICE = (
|
|
312
|
+
"lcode didn't run the command. Don't commit secrets: read them from an environment variable or from a "
|
|
313
|
+
"configuration file that git ignores (and commit an example with placeholders instead). If one of these "
|
|
314
|
+
"isn't a secret, tell the user: they decide."
|
|
315
|
+
)
|
lcode/tools.py
CHANGED
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import difflib
|
|
6
6
|
import fnmatch
|
|
7
|
+
import hashlib
|
|
7
8
|
import inspect
|
|
8
9
|
import json
|
|
9
10
|
import os
|
|
@@ -19,11 +20,12 @@ from collections.abc import Callable
|
|
|
19
20
|
from pathlib import Path
|
|
20
21
|
from typing import TYPE_CHECKING
|
|
21
22
|
|
|
23
|
+
from rich.console import Group
|
|
22
24
|
from rich.panel import Panel
|
|
23
25
|
from rich.syntax import Syntax
|
|
24
26
|
from rich.text import Text
|
|
25
27
|
|
|
26
|
-
from lcode import web
|
|
28
|
+
from lcode import secretscan, web
|
|
27
29
|
from lcode.context import shorten
|
|
28
30
|
from lcode.jobs import JobError, describe, stop_leftovers
|
|
29
31
|
from lcode.permissions import bash_key, is_read_only
|
|
@@ -823,7 +825,28 @@ class Toolbox:
|
|
|
823
825
|
verdict = self.agent.perms.rule("bash", command)
|
|
824
826
|
if verdict is not None and not verdict[0]: # deny rules apply to read-only commands too
|
|
825
827
|
return verdict[1]
|
|
826
|
-
|
|
828
|
+
approved = False
|
|
829
|
+
if self.agent.settings.secret_check and not is_read_only(command):
|
|
830
|
+
action, found = secretscan.check(self.agent.cwd, command)
|
|
831
|
+
if found:
|
|
832
|
+
text = secretscan.report(action, found)
|
|
833
|
+
if self.agent.perms.mode != "ask": # nobody looks at each command: it doesn't run
|
|
834
|
+
self.console.print(Text(f" ⎿ {text}", style="red"))
|
|
835
|
+
return f"Error: lcode's secret check stopped this command. {text}\n\n{secretscan.ADVICE}"
|
|
836
|
+
body = Group(Text(text + "\n", style="red"), Syntax(command, "bash", theme="monokai", word_wrap=True))
|
|
837
|
+
key = "secret:" + hashlib.sha256(text.encode()).hexdigest()[:16]
|
|
838
|
+
ok, feedback = self.agent.perms.request(
|
|
839
|
+
key, "secret", f"This {action} looks like it holds a secret", body, command
|
|
840
|
+
)
|
|
841
|
+
if not ok:
|
|
842
|
+
return f"{feedback}\n\n{text}\n{secretscan.ADVICE}"
|
|
843
|
+
approved = True
|
|
844
|
+
if (
|
|
845
|
+
not approved
|
|
846
|
+
and not is_read_only(command)
|
|
847
|
+
and not (sandbox and self.agent.perms.mode == "auto-edit")
|
|
848
|
+
and verdict is None
|
|
849
|
+
):
|
|
827
850
|
body = Syntax(command, "bash", theme="monokai", word_wrap=True)
|
|
828
851
|
where = "in the sandbox" if sandbox else f"in {self.agent.cwd}"
|
|
829
852
|
ok, feedback = self.agent.perms.request(bash_key(command), "bash", f"Run command ({where})", body, command)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.26.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/
|
|
@@ -1,22 +1,23 @@
|
|
|
1
|
-
lcode/__init__.py,sha256=
|
|
1
|
+
lcode/__init__.py,sha256=xjCrbnr5jMJNipwGklkGM9ZGwF83DFfVEQPHFTwk3IY,473
|
|
2
2
|
lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
|
|
3
3
|
lcode/acp.py,sha256=Tpc8lsvffZSYbTRI4t7X4E2chahqZIPcKeYIcL5bV3Y,29035
|
|
4
|
-
lcode/action.py,sha256=
|
|
5
|
-
lcode/agent.py,sha256=
|
|
6
|
-
lcode/api.py,sha256=
|
|
4
|
+
lcode/action.py,sha256=wuT5F9EJCdEligPeytzmEi8FpahWBhXer528tpuTj_k,16711
|
|
5
|
+
lcode/agent.py,sha256=kAjvCq2XrXqNVyrwbgLhG0XOtXdY0TVk-ty2Oou6hnk,56297
|
|
6
|
+
lcode/api.py,sha256=i6mxPCkD1ObrVQQeJ7LacEtsjuhQFdV21fM9FTj3hrg,15418
|
|
7
7
|
lcode/backends.py,sha256=dtJHrC6ys73bRDg64z0tQz1PavmeAkiODZu1fOO9ExQ,20635
|
|
8
|
-
lcode/bench.py,sha256=
|
|
8
|
+
lcode/bench.py,sha256=7ICipu_2GXW6vE519XeZucc2GSWA44votT8JvqcNv1k,40964
|
|
9
9
|
lcode/bench_backend.py,sha256=X2fo-zRX92Xp3KMxSsFlWioc2vRupBTtowj5kLME8mw,14287
|
|
10
|
+
lcode/bench_runtime.py,sha256=EjzNgIvUkdbT8cbSy1TpmlyeIk6e-ipjlDFpM7Dnqx0,6835
|
|
10
11
|
lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
|
|
11
12
|
lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
|
|
12
13
|
lcode/cli.py,sha256=cH5mcYi54Aofcv0PDNgFmFFQX8OQjhwTGdbWMUM_FG8,40289
|
|
13
14
|
lcode/clipboard.py,sha256=nTSdJyCXPdtjH947RjvB3QvRqnL5HZsS9KB8EkC42cc,5138
|
|
14
15
|
lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
|
|
15
|
-
lcode/config.py,sha256=
|
|
16
|
+
lcode/config.py,sha256=g7oT5ElKonmbR7_4itgJllTp81wGEYZy1fRZJnAvzOw,10481
|
|
16
17
|
lcode/context.py,sha256=qJymLE3eYeaF6qVOzlUwDURqlky__8cskl_6vXeU3AA,10701
|
|
17
18
|
lcode/extensions.py,sha256=W0HFDl0BvROzr7i_qtabHdIbacBgP5o5qIJLzoM2oNE,16806
|
|
18
19
|
lcode/frontmatter.py,sha256=Bx0n4vLPhp7ZCWx-g7xqzqNy5VoKBxyeeikh5UFB_gA,2441
|
|
19
|
-
lcode/gitflow.py,sha256=
|
|
20
|
+
lcode/gitflow.py,sha256=6j_nnCBZXkX1qhTI7K4CyZ5D0NAZLpH96weFEo64AkQ,27913
|
|
20
21
|
lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
|
|
21
22
|
lcode/hooks.py,sha256=K0itOiAuH4rxndJ9gne_uCrqby6oWAzFPRrfB3tCq-E,9324
|
|
22
23
|
lcode/jobs.py,sha256=P1P3FqI6EqL4LgdMX9YGZwCW0Bh5BRXFA_FnQxpHNjE,5618
|
|
@@ -33,9 +34,10 @@ lcode/repeats.py,sha256=uzyAewX03nJf5dKwYFH4y9ZDnh0Lvy2k0qC2vdntM5Y,4061
|
|
|
33
34
|
lcode/repl.py,sha256=ZKd4l4wDGKHVaGz8bxjLGc2gRc2W-RpHPQYG9M2A_Zw,49441
|
|
34
35
|
lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
|
|
35
36
|
lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
|
|
37
|
+
lcode/secretscan.py,sha256=6Euvfk2gCtK7lZbuQD84zgWuSECi6OnSw8V5rFu1tfU,13575
|
|
36
38
|
lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
|
|
37
39
|
lcode/subagents.py,sha256=Z2RHp7an16dhOGReucVIEOAnXjxM8jx7sHvWd_Fp2V4,29142
|
|
38
|
-
lcode/tools.py,sha256=
|
|
40
|
+
lcode/tools.py,sha256=jqxMcyvvDZs0rV0AROYxpJ4krxbU6LbkOWnxiLEqq1o,48426
|
|
39
41
|
lcode/update.py,sha256=i92bnW252VneYh0vD6djxmXsDOa5MXP20bjlLZUue_c,25236
|
|
40
42
|
lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
|
|
41
43
|
lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
|
|
@@ -55,8 +57,8 @@ lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
|
|
|
55
57
|
lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
|
|
56
58
|
lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
|
|
57
59
|
lcode/servers/xai.py,sha256=h2emAvHSnkuB9WmJInlynsDhvxJwNVD2AHFX7vqMXNc,13627
|
|
58
|
-
lcode_cli-0.
|
|
59
|
-
lcode_cli-0.
|
|
60
|
-
lcode_cli-0.
|
|
61
|
-
lcode_cli-0.
|
|
62
|
-
lcode_cli-0.
|
|
60
|
+
lcode_cli-0.26.0.dist-info/METADATA,sha256=iHy2mJa4Bk0LVY3hiDAkHxhBs2uqk21--IGY27MaePw,12620
|
|
61
|
+
lcode_cli-0.26.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
62
|
+
lcode_cli-0.26.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
|
|
63
|
+
lcode_cli-0.26.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
|
|
64
|
+
lcode_cli-0.26.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|