lcode-cli 0.25.0__py3-none-any.whl → 0.27.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 CHANGED
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.25.0"
6
+ __version__ = "0.27.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
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
@@ -233,6 +233,8 @@ class Settings:
233
233
  notify: bool = False # desktop notifications after long requests (lcode.notify)
234
234
  notify_after: int = 30 # seconds: a request this long notifies when it's done or waits for an answer
235
235
  max_parallel_agents: int = 1
236
+ secret_check: bool = True # scan what git commit and git push would add (lcode.secretscan)
237
+ compact_at: int = 0 # tokens: make room at this size even when the window is larger (0: only at 85%)
236
238
  repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
237
239
 
238
240
 
@@ -269,6 +271,7 @@ class Agent:
269
271
  self.agent_runs: list[subagents.Record] = [] # subagents run in this session, for /agents
270
272
  self.plan = "" # the plan the user approved; kept through compaction
271
273
  self.pruned = self.compacted = 0 # how often the context was pruned or summarized automatically
274
+ self.summarized_size = 0 # tokens right after the last summary (0: none in this conversation)
272
275
  self._extensions: tuple[Path, extensions.Extensions] | None = None
273
276
  self.skills_loaded: set[str] = set() # skills whose instructions are in the conversation
274
277
  # Set on subagents (see lcode.subagents):
@@ -513,6 +516,7 @@ class Agent:
513
516
  return WEB_PROMPT.format(tools=tools, date=dt.date.today().isoformat())
514
517
 
515
518
  def reset(self) -> None:
519
+ self.summarized_size = 0
516
520
  self.messages = [{"role": "system", "content": self.system_prompt()}]
517
521
  self._mcp_prompt = ""
518
522
  self.tools.read_mtimes.clear()
@@ -1045,13 +1049,27 @@ class Agent:
1045
1049
  return text + ("\n\n" + "\n\n".join(attached) if attached else "")
1046
1050
 
1047
1051
  # -- context management
1052
+ def compact_limit(self) -> int:
1053
+ """The tokens in use at which lcode makes room: 85% of the window, or `compact_at` when that's lower.
1054
+ Small models lose track in long contexts well before a large window is full."""
1055
+ limit = int(AUTO_COMPACT_RATIO * self.settings.context)
1056
+ if self.settings.compact_at > 0:
1057
+ limit = min(limit, self.settings.compact_at)
1058
+ return limit
1059
+
1048
1060
  def maybe_compact(self) -> None:
1049
- if self.ctx_used <= AUTO_COMPACT_RATIO * self.settings.context:
1061
+ limit = self.compact_limit()
1062
+ if self.ctx_used <= limit:
1050
1063
  return
1064
+ enough = int(limit * PRUNED_ENOUGH / AUTO_COMPACT_RATIO) # after pruning, a summary isn't needed below this
1051
1065
  if self.settings.prune:
1052
- saved = context_tools.prune(self.messages) # tool output from before the last two requests
1053
- if self.ctx_used - saved // 3 > PRUNED_ENOUGH * self.settings.context:
1054
- saved += context_tools.prune(self.messages, keep=1) # not enough: before the last request
1066
+ removed: list[str] = [] # files and pages whose only copy goes
1067
+ saved = context_tools.prune(self.messages, removed=removed) # tool output from before the last two requests
1068
+ if self.ctx_used - saved // 3 > enough:
1069
+ saved += context_tools.prune(
1070
+ self.messages, keep=1, removed=removed
1071
+ ) # not enough: before the last request
1072
+ context_tools.note_removed(self.messages, removed)
1055
1073
  if saved:
1056
1074
  self.pruned += 1
1057
1075
  self.ctx_used = max(len(self.messages[0]["content"]) // 3, self.ctx_used - saved // 3)
@@ -1059,9 +1077,17 @@ class Agent:
1059
1077
  f"[dim]Removed old tool output from the conversation (~{format_tokens(saved // 3)} tokens) to "
1060
1078
  "make room.[/]"
1061
1079
  )
1062
- if self.ctx_used <= PRUNED_ENOUGH * self.settings.context:
1080
+ if self.ctx_used <= enough:
1063
1081
  return
1064
- self.console.print("[yellow]Context is nearly full — compacting the conversation…[/]")
1082
+ window_full = self.ctx_used > AUTO_COMPACT_RATIO * self.settings.context
1083
+ if not window_full and self.summarized_size and self.ctx_used - self.summarized_size < limit // 4:
1084
+ return # summarized not long ago: summarizing again so soon would only lose more
1085
+ if limit < AUTO_COMPACT_RATIO * self.settings.context:
1086
+ self.console.print(
1087
+ f"[yellow]The conversation reached {format_tokens(limit)} tokens (compact_at) — summarizing it…[/]"
1088
+ )
1089
+ else:
1090
+ self.console.print("[yellow]Context is nearly full — compacting the conversation…[/]")
1065
1091
  self.compacted += 1
1066
1092
  self.compact()
1067
1093
 
@@ -1082,9 +1108,13 @@ class Agent:
1082
1108
  with self.console.status("[cyan]Compacting…[/]"):
1083
1109
  for chunk in self.chat([*self.messages, {"role": "user", "content": instructions}], None, False):
1084
1110
  summary += chunk.get("message", {}).get("content", "")
1111
+ requests = context_tools.user_requests(self.messages)
1085
1112
  self.messages = [
1086
1113
  {"role": "system", "content": self.system_prompt()},
1087
- {"role": "user", "content": f"[Summary of our conversation so far]\n\n{summary}"},
1114
+ {
1115
+ "role": "user",
1116
+ "content": f"{sessions.SUMMARY_PREFIX}\n\n{summary}" + context_tools.requests_block(requests),
1117
+ },
1088
1118
  {"role": "assistant", "content": "Got it — I have the context from the summary and will continue."},
1089
1119
  ]
1090
1120
  if self.plan: # the approved plan stays word for word
@@ -1096,6 +1126,7 @@ class Agent:
1096
1126
  )
1097
1127
  self.skills_loaded.clear()
1098
1128
  self.ctx_used = sum(len(m["content"]) for m in self.messages) // 3
1129
+ self.summarized_size = self.ctx_used
1099
1130
  self.reflected = len(self.messages)
1100
1131
  self.console.print(Panel(Markdown(summary), title="Compacted summary", border_style="blue"))
1101
1132
 
lcode/api.py CHANGED
@@ -194,7 +194,9 @@ 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"],
199
+ compact_at=cfg["compact_at"],
198
200
  )
199
201
  agent = Agent(ollama, settings, cwd, console=console)
200
202
  agent.interactive = options.interactive
lcode/cli.py CHANGED
@@ -579,6 +579,7 @@ def cmd_bench(args) -> None:
579
579
  web="off",
580
580
  checkpoints=False,
581
581
  prune=not args.no_prune,
582
+ compact_at=cfg["compact_at"] if args.compact_at is None else args.compact_at,
582
583
  repo_map=args.repo_map,
583
584
  embed_model=cfg["embed_model"] if args.code_search else "off",
584
585
  )
@@ -678,6 +679,14 @@ def cmd_chat(args) -> None:
678
679
  sys.exit(exit_code)
679
680
 
680
681
 
682
+ def compact_tokens(value: str) -> int:
683
+ """`--compact-at`: a token count such as 64k, or 0."""
684
+ try:
685
+ return config.parse_compact_at(value)
686
+ except ConfigError as e:
687
+ raise argparse.ArgumentTypeError(str(e)) from e
688
+
689
+
681
690
  def build_parser() -> argparse.ArgumentParser:
682
691
  parser = argparse.ArgumentParser(
683
692
  prog="lcode",
@@ -765,6 +774,12 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
765
774
  p.add_argument("--repo-map", action="store_true", help="give the model the repository map (off by default)")
766
775
  p.add_argument("--code-search", action="store_true", help="index each task's folder and offer semantic code search")
767
776
  p.add_argument("--no-prune", action="store_true", help="don't remove old tool output (to compare, with --session)")
777
+ p.add_argument(
778
+ "--compact-at",
779
+ type=compact_tokens,
780
+ metavar="TOKENS",
781
+ help="summarize at this many tokens, e.g. 64k (default: the compact_at setting; 0: only at 85%%)",
782
+ )
768
783
  subs["bench"] = p
769
784
  p = argparse.ArgumentParser(
770
785
  prog="lcode index",
lcode/config.py CHANGED
@@ -60,6 +60,8 @@ 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"),
64
+ "compact_at": (65536, int, "summarize at this many tokens even when the window is larger (0: only at 85%)"),
63
65
  "max_parallel_agents": (1, int, "subagents that may run at the same time (more needs OLLAMA_NUM_PARALLEL)"),
64
66
  }
65
67
  ENV_OVERRIDES = {
@@ -80,6 +82,19 @@ class ConfigError(ValueError):
80
82
  pass
81
83
 
82
84
 
85
+ MIN_COMPACT_AT = 16384 # below this, the system prompt and a summary alone come close to the ceiling
86
+
87
+
88
+ def parse_compact_at(value: str | int) -> int:
89
+ """compact_at: a token count like parse_context's, at least 16K, or 0 (off)."""
90
+ if str(value).strip().lower() in ("0", "off", "false", "none"):
91
+ return 0
92
+ tokens = parse_context(value)
93
+ if tokens < MIN_COMPACT_AT:
94
+ raise ConfigError(f"compact_at {tokens} is too small: use at least 16k, or 0 to turn it off")
95
+ return tokens
96
+
97
+
83
98
  def parse_context(value: str | int) -> int:
84
99
  """Parse '131072', '128k', '128K' or '1m' into a token count (k = 1024)."""
85
100
  if isinstance(value, int):
@@ -117,6 +132,8 @@ def coerce(key: str, value: object) -> object:
117
132
  _, typ, _ = SETTINGS[key]
118
133
  if key == "context":
119
134
  return parse_context(value) # type: ignore[arg-type]
135
+ if key == "compact_at":
136
+ return parse_compact_at(value) # type: ignore[arg-type]
120
137
  if key in ("ollama_host", "searxng_url"):
121
138
  return normalize_host(str(value))
122
139
  if key == "base_url":
lcode/context.py CHANGED
@@ -29,6 +29,10 @@ if TYPE_CHECKING:
29
29
  KEEP_RECENT_REQUESTS = 2 # tool results of the last requests stay as they are
30
30
  MIN_PRUNE_CHARS = 400 # shorter results aren't worth replacing
31
31
  PRUNED = "[lcode: " # how stubs start, so they're never pruned twice
32
+ GONE = ( # a removed file read: firm, because models answer from the stub instead of reading it again
33
+ "earlier; its content was removed to save context and isn't in this conversation any more. Read it again "
34
+ "before answering anything about it.]"
35
+ )
32
36
  KEEP_TOOLS = {"agent", "skill", "memory", "todo_write", "present_plan"} # short, or still guiding the work
33
37
  ERROR_LINE = re.compile(
34
38
  r"error|exception|traceback|failed|failure|fatal|panic|assert|denied|not found|cannot|can't|undefined", re.I
@@ -37,6 +41,56 @@ MAX_ERROR_LINES = 40
37
41
  OUTPUT_DAYS = 7 # saved full outputs are deleted after this
38
42
 
39
43
 
44
+ # ----------------------------------------------------------------------------- the user's requests
45
+
46
+ REQUESTS_HEADER = "[The user's requests in this conversation, word for word, oldest first]"
47
+ MAX_REQUESTS = 8 # the most recent ones are kept through a summary
48
+ MAX_REQUEST_CHARS = 1500
49
+ # What lcode appends to the user's words: attached files, images and folders, and its own notes.
50
+ ATTACHED = re.compile(r"\n\n(?:<file path=|<image path=|<directory path=|\[lcode[:\]] |\[The user wants the )")
51
+
52
+
53
+ def request_text(content: str) -> str:
54
+ """The user's own words in a message, without what lcode attached to it."""
55
+ cut = ATTACHED.search(content)
56
+ return (content[: cut.start()] if cut else content).strip()
57
+
58
+
59
+ def user_requests(messages: list[dict]) -> list[str]:
60
+ """What the user asked in the conversation, oldest first, including the requests an earlier summary kept."""
61
+ requests: list[str] = []
62
+ for message in messages:
63
+ content = message.get("content") or ""
64
+ if message.get("role") != "user":
65
+ continue
66
+ if content.startswith(SUMMARY_PREFIX):
67
+ start = content.find(REQUESTS_HEADER)
68
+ if start >= 0:
69
+ rest = content[start + len(REQUESTS_HEADER) :]
70
+ try:
71
+ kept, _ = json.JSONDecoder().raw_decode(rest[rest.index("[") :])
72
+ requests += [str(r) for r in kept]
73
+ except ValueError:
74
+ pass
75
+ continue
76
+ if content.startswith("[lcode]"):
77
+ continue # lcode's own nudges
78
+ text = request_text(content)
79
+ if text:
80
+ requests.append(text)
81
+ return requests
82
+
83
+
84
+ def requests_block(requests: list[str]) -> str:
85
+ """The user's latest requests, word for word, to keep the goal through a summary."""
86
+ if not requests:
87
+ return ""
88
+ kept = [r if len(r) <= MAX_REQUEST_CHARS else f"{r[: MAX_REQUEST_CHARS - 200]} […] {r[-150:]}" for r in requests]
89
+ older = len(kept) - MAX_REQUESTS
90
+ note = f" (and {older} earlier, in the summary)" if older > 0 else ""
91
+ return f"\n\n{REQUESTS_HEADER}{note}\n{json.dumps(kept[-MAX_REQUESTS:], ensure_ascii=False, indent=1)}"
92
+
93
+
40
94
  # ----------------------------------------------------------------------------- pruning
41
95
 
42
96
 
@@ -63,7 +117,7 @@ def stub(name: str, args: dict, content: str) -> str:
63
117
  if args.get("offset") or args.get("limit"):
64
118
  start = int(args.get("offset") or 1)
65
119
  span = f" from line {start}"
66
- return f"{PRUNED}read {args.get('path', 'a file')}{span} earlier; removed to save context. Read it again if you need it.]"
120
+ return f"{PRUNED}read {args.get('path', 'a file')}{span} {GONE}"
67
121
  if name == "bash":
68
122
  command = " ".join(str(args.get("command", "")).split())
69
123
  command = command if len(command) <= 80 else command[:79] + "…"
@@ -92,8 +146,9 @@ def boundary(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS) -> int:
92
146
  return requests[-keep] if len(requests) >= keep else 0
93
147
 
94
148
 
95
- def prune(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS) -> int:
96
- """Replace stale tool results with stubs, in place. Returns the number of characters saved."""
149
+ def prune(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS, removed: list[str] | None = None) -> int:
150
+ """Replace stale tool results with stubs, in place. Returns the number of characters saved; the files and
151
+ pages whose only copy went are added to `removed`."""
97
152
  calls = calls_for_results(messages)
98
153
  edge = boundary(messages, keep)
99
154
  # A read is superseded by a later read of the whole file, or of the same lines: not by a later
@@ -118,12 +173,34 @@ def prune(messages: list[dict], keep: int = KEEP_RECENT_REQUESTS) -> int:
118
173
  continue
119
174
  new = stub(name, args, content)
120
175
  if reread:
121
- new = new.replace("earlier; removed", "earlier (read again later); removed")
176
+ new = new.replace(
177
+ GONE, "earlier (read again later); removed to save context. Read it again if you need it.]"
178
+ )
179
+ elif removed is not None and name in ("read_file", "web_fetch"):
180
+ what = str(args.get("path") or args.get("url") or "")
181
+ if what and what not in removed:
182
+ removed.append(what)
122
183
  messages[i]["content"] = new
123
184
  saved += len(content) - len(new)
124
185
  return saved
125
186
 
126
187
 
188
+ def note_removed(messages: list[dict], removed: list[str]) -> None:
189
+ """Tell the model, next to the request it's working on, which files it no longer has: a stub deep in the
190
+ conversation is easy to overlook, and models then answer from memory instead of reading again."""
191
+ if not removed:
192
+ return
193
+ for message in reversed(messages):
194
+ content = str(message.get("content", ""))
195
+ if message.get("role") == "user" and not content.startswith(("[lcode]", SUMMARY_PREFIX)):
196
+ names = ", ".join(removed[:10]) + (f" and {len(removed) - 10} more" if len(removed) > 10 else "")
197
+ message["content"] = (
198
+ f"{content}\n\n[lcode] To make room, lcode removed what you read earlier from: {names}. It isn't in "
199
+ "this conversation any more: read it again before answering anything about it, don't answer from memory."
200
+ )
201
+ return
202
+
203
+
127
204
  # ----------------------------------------------------------------------------- long output
128
205
 
129
206
 
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/repl.py CHANGED
@@ -242,7 +242,8 @@ def context_command(agent: Agent, arg: str, hardware: Hardware) -> None:
242
242
  c.print(
243
243
  f"In use: {format_tokens(agent.ctx_used)} of {format_tokens(s.context)} tokens "
244
244
  f"({100 * agent.ctx_used / s.context:.0f}%) in {len(agent.messages)} message"
245
- f"{'' if len(agent.messages) == 1 else 's'}; auto-compacts at 85%."
245
+ f"{'' if len(agent.messages) == 1 else 's'}; makes room at {format_tokens(agent.compact_limit())} "
246
+ + ("(compact_at)." if agent.compact_limit() < AUTO_COMPACT_RATIO * s.context else "(85%).")
246
247
  )
247
248
  if agent.mcp and agent.mcp.ready():
248
249
  how = "found on demand" if agent.mcp.searching(s.context) else "sent with every request"
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
- if not is_read_only(command) and not (sandbox and self.agent.perms.mode == "auto-edit") and verdict is None:
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.25.0
3
+ Version: 0.27.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,23 +1,23 @@
1
- lcode/__init__.py,sha256=znAn1x1nESR2keV7zRFdQd7reQE9a4zKFE1_LsWuoas,473
1
+ lcode/__init__.py,sha256=JUcNjfBPUQX6hKCFSklgC04DhC6xrDnysFFhCU7p5Xc,473
2
2
  lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
3
  lcode/acp.py,sha256=Tpc8lsvffZSYbTRI4t7X4E2chahqZIPcKeYIcL5bV3Y,29035
4
- lcode/action.py,sha256=W-QqdGt4VAVLq7mcIdvL9UrEOwDEWvkZ26gNDv8k1kg,16330
5
- lcode/agent.py,sha256=naua7OJW9BXhv8mpF1QLdclnFy6RHZAjw9LYTt_dBy0,56201
6
- lcode/api.py,sha256=FQ6aVLB936Cu2XEBmuOoq0u1DfOiExmgap0Y3WxJ2mQ,15376
4
+ lcode/action.py,sha256=wuT5F9EJCdEligPeytzmEi8FpahWBhXer528tpuTj_k,16711
5
+ lcode/agent.py,sha256=tGNjbs8cw2X7IUBb70juI3JH5IpXmmIXOcteTsMhOtA,57936
6
+ lcode/api.py,sha256=aqWnGCy1jNuIktJEHo9ncbHNTXn2QO3BoNiggyo9ZmQ,15456
7
7
  lcode/backends.py,sha256=dtJHrC6ys73bRDg64z0tQz1PavmeAkiODZu1fOO9ExQ,20635
8
8
  lcode/bench.py,sha256=7ICipu_2GXW6vE519XeZucc2GSWA44votT8JvqcNv1k,40964
9
9
  lcode/bench_backend.py,sha256=X2fo-zRX92Xp3KMxSsFlWioc2vRupBTtowj5kLME8mw,14287
10
10
  lcode/bench_runtime.py,sha256=EjzNgIvUkdbT8cbSy1TpmlyeIk6e-ipjlDFpM7Dnqx0,6835
11
11
  lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
12
12
  lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
13
- lcode/cli.py,sha256=cH5mcYi54Aofcv0PDNgFmFFQX8OQjhwTGdbWMUM_FG8,40289
13
+ lcode/cli.py,sha256=M6Mjd5BROSGlZj4J6TrSZMJqI-OVfUEWPoC0TQyOQ28,40831
14
14
  lcode/clipboard.py,sha256=nTSdJyCXPdtjH947RjvB3QvRqnL5HZsS9KB8EkC42cc,5138
15
15
  lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
16
- lcode/config.py,sha256=fGjK4fM_YRY0qDQmbIVT3FAfL2I2UxLfYEFxnAI-Xu4,10365
17
- lcode/context.py,sha256=qJymLE3eYeaF6qVOzlUwDURqlky__8cskl_6vXeU3AA,10701
16
+ lcode/config.py,sha256=q1zGzH3p5U23ai6O9Jai3dS-s30CuQ9gc5cWX0_yxWE,11195
17
+ lcode/context.py,sha256=_f0NbXyjA4SyH2RCr49AiWwtAfr_PVDaqyFQ5fZDhkg,14404
18
18
  lcode/extensions.py,sha256=W0HFDl0BvROzr7i_qtabHdIbacBgP5o5qIJLzoM2oNE,16806
19
19
  lcode/frontmatter.py,sha256=Bx0n4vLPhp7ZCWx-g7xqzqNy5VoKBxyeeikh5UFB_gA,2441
20
- lcode/gitflow.py,sha256=y78dqqh80is-MiDTnSHnK9vjx0vFovor_SZ8hW1NJaU,27167
20
+ lcode/gitflow.py,sha256=6j_nnCBZXkX1qhTI7K4CyZ5D0NAZLpH96weFEo64AkQ,27913
21
21
  lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
22
22
  lcode/hooks.py,sha256=K0itOiAuH4rxndJ9gne_uCrqby6oWAzFPRrfB3tCq-E,9324
23
23
  lcode/jobs.py,sha256=P1P3FqI6EqL4LgdMX9YGZwCW0Bh5BRXFA_FnQxpHNjE,5618
@@ -31,12 +31,13 @@ lcode/permissions.py,sha256=i04YLtqYCxwApgawomPp4d35uk9HAJtpKY__gfw8j9s,6347
31
31
  lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
32
32
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
33
33
  lcode/repeats.py,sha256=uzyAewX03nJf5dKwYFH4y9ZDnh0Lvy2k0qC2vdntM5Y,4061
34
- lcode/repl.py,sha256=ZKd4l4wDGKHVaGz8bxjLGc2gRc2W-RpHPQYG9M2A_Zw,49441
34
+ lcode/repl.py,sha256=Y53tTvdoTP6CZOtq96CzbvPcwVlYNc9fppMV0hAlRSY,49573
35
35
  lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
36
36
  lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
37
+ lcode/secretscan.py,sha256=6Euvfk2gCtK7lZbuQD84zgWuSECi6OnSw8V5rFu1tfU,13575
37
38
  lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
38
39
  lcode/subagents.py,sha256=Z2RHp7an16dhOGReucVIEOAnXjxM8jx7sHvWd_Fp2V4,29142
39
- lcode/tools.py,sha256=pOdA4ihQHSs8yWUzfxhiLw3GajF5Dr1edxVX0Akr6CU,47257
40
+ lcode/tools.py,sha256=jqxMcyvvDZs0rV0AROYxpJ4krxbU6LbkOWnxiLEqq1o,48426
40
41
  lcode/update.py,sha256=i92bnW252VneYh0vD6djxmXsDOa5MXP20bjlLZUue_c,25236
41
42
  lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
42
43
  lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
@@ -56,8 +57,8 @@ lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
56
57
  lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
57
58
  lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
58
59
  lcode/servers/xai.py,sha256=h2emAvHSnkuB9WmJInlynsDhvxJwNVD2AHFX7vqMXNc,13627
59
- lcode_cli-0.25.0.dist-info/METADATA,sha256=j0rf2PzRDAvWLYN-2RizBuLuel-tmvhfgf_m_pvTHkE,12620
60
- lcode_cli-0.25.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
61
- lcode_cli-0.25.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
62
- lcode_cli-0.25.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
63
- lcode_cli-0.25.0.dist-info/RECORD,,
60
+ lcode_cli-0.27.0.dist-info/METADATA,sha256=pASpYtQ_MeN-50aD2sdpTfxMKuX2Tz00LaJa2pT1DmM,12620
61
+ lcode_cli-0.27.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
62
+ lcode_cli-0.27.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
63
+ lcode_cli-0.27.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
64
+ lcode_cli-0.27.0.dist-info/RECORD,,