loom-code 0.1.1__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.
- loom_code/__init__.py +22 -0
- loom_code/_post_commit.py +119 -0
- loom_code/agent.py +544 -0
- loom_code/approval.py +616 -0
- loom_code/browse/__init__.py +291 -0
- loom_code/browse/act.py +467 -0
- loom_code/browse/observe.py +249 -0
- loom_code/browse/session.py +96 -0
- loom_code/browse/verify.py +194 -0
- loom_code/checkpoint.py +283 -0
- loom_code/cli.py +495 -0
- loom_code/code_index.py +703 -0
- loom_code/compact.py +143 -0
- loom_code/consent.py +47 -0
- loom_code/credentials.py +527 -0
- loom_code/edit_tool.py +635 -0
- loom_code/extensions.py +522 -0
- loom_code/file_history.py +322 -0
- loom_code/file_tools.py +93 -0
- loom_code/git_hook.py +200 -0
- loom_code/grep_tool.py +430 -0
- loom_code/hooks.py +297 -0
- loom_code/loominit/__init__.py +23 -0
- loom_code/loominit/_ast_walk.py +429 -0
- loom_code/loominit/_files.py +284 -0
- loom_code/loominit/_graph.py +141 -0
- loom_code/loominit/_resolve.py +392 -0
- loom_code/loominit/_tests_map.py +108 -0
- loom_code/loominit/extractor.py +332 -0
- loom_code/loominit/repomap.py +225 -0
- loom_code/loominit/schema.py +242 -0
- loom_code/lsp_tools.py +396 -0
- loom_code/mcp_host.py +79 -0
- loom_code/operator.py +449 -0
- loom_code/paste.py +97 -0
- loom_code/paths.py +52 -0
- loom_code/permissions.py +177 -0
- loom_code/project.py +104 -0
- loom_code/prompts.py +451 -0
- loom_code/render.py +783 -0
- loom_code/repl.py +4080 -0
- loom_code/rules.py +267 -0
- loom_code/sandboxed_bash.py +176 -0
- loom_code/scribe.py +88 -0
- loom_code/skills/__init__.py +16 -0
- loom_code/skills/graphify/SKILL.md +97 -0
- loom_code/skills/graphify/tools.py +570 -0
- loom_code/trust.py +216 -0
- loom_code/turn.py +169 -0
- loom_code/web_fetch.py +370 -0
- loom_code/workers.py +758 -0
- loom_code/worktree.py +134 -0
- loom_code-0.1.1.dist-info/METADATA +224 -0
- loom_code-0.1.1.dist-info/RECORD +58 -0
- loom_code-0.1.1.dist-info/WHEEL +5 -0
- loom_code-0.1.1.dist-info/entry_points.txt +2 -0
- loom_code-0.1.1.dist-info/licenses/LICENSE +21 -0
- loom_code-0.1.1.dist-info/top_level.txt +1 -0
loom_code/cli.py
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"""loom-code CLI entry point.
|
|
2
|
+
|
|
3
|
+
Two modes:
|
|
4
|
+
|
|
5
|
+
* ``loom-code "do X"`` — one-shot. Detects the project, builds the
|
|
6
|
+
loomflow Agent, streams one run, exits.
|
|
7
|
+
* ``loom-code`` (no args) — interactive REPL. Chat, code, approve,
|
|
8
|
+
repeat — with conversation continuity across turns.
|
|
9
|
+
|
|
10
|
+
Both go through the same project detection + agent build; the REPL
|
|
11
|
+
just loops and adds slash commands.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import functools
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
import anyio
|
|
22
|
+
|
|
23
|
+
from .agent import DEFAULT_MODEL, build_agent
|
|
24
|
+
from .approval import ApprovalGate
|
|
25
|
+
from .credentials import ensure_key_for_model, load_credentials
|
|
26
|
+
from .project import Project, detect_project
|
|
27
|
+
from .render import StreamRenderer, banner, console
|
|
28
|
+
from .repl import run_repl
|
|
29
|
+
|
|
30
|
+
# Substrings we treat as "this bash call was a verify step." Hit
|
|
31
|
+
# anywhere in the lowercased command. Detected, not declared by the
|
|
32
|
+
# agent — we sniff the tool stream so the summary is honest about
|
|
33
|
+
# what was actually run rather than what the agent claims.
|
|
34
|
+
_VERIFY_PATTERNS = (
|
|
35
|
+
"pytest", "py.test", "jest", "vitest", "mocha",
|
|
36
|
+
"cargo test", "go test", "mvn test", "gradle test",
|
|
37
|
+
"rake test", "phpunit", "python -m unittest", "tox",
|
|
38
|
+
"make test", "make check", "npm test", "yarn test",
|
|
39
|
+
"pnpm test", "bundle exec",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _run_once(
|
|
44
|
+
prompt: str,
|
|
45
|
+
model: str,
|
|
46
|
+
yes: bool,
|
|
47
|
+
*,
|
|
48
|
+
sandbox: bool = False,
|
|
49
|
+
sandbox_allow_network: bool = False,
|
|
50
|
+
output_format: str = "text",
|
|
51
|
+
) -> int:
|
|
52
|
+
"""Detect project, build agent, stream one run. Returns an
|
|
53
|
+
exit code.
|
|
54
|
+
|
|
55
|
+
``yes`` swaps the interactive approval gate for the
|
|
56
|
+
auto-approve handler — for unattended runs (CI, scripted use)
|
|
57
|
+
where there's no TTY to answer prompts.
|
|
58
|
+
|
|
59
|
+
``output_format="json"`` silences the Rich UI and prints ONE
|
|
60
|
+
machine-readable envelope on stdout when the run ends — for
|
|
61
|
+
CI / scripting / benchmark harnesses. Usually paired with
|
|
62
|
+
``--yes`` (there's no visible approval prompt in quiet mode).
|
|
63
|
+
|
|
64
|
+
Consumed via :func:`anyio.run` (NOT ``asyncio.run``) — the
|
|
65
|
+
streaming generator from ``Agent.stream`` opens internal
|
|
66
|
+
``anyio`` task groups, and exiting those cleanly requires the
|
|
67
|
+
anyio event loop. loomflow's rule: anyio everywhere.
|
|
68
|
+
"""
|
|
69
|
+
json_mode = output_format == "json"
|
|
70
|
+
if json_mode:
|
|
71
|
+
# Silence every Rich print (banner, stream, summary) — the
|
|
72
|
+
# envelope at the end is the only stdout. ``console`` is the
|
|
73
|
+
# shared module singleton, so this covers render.py too.
|
|
74
|
+
console.quiet = True
|
|
75
|
+
|
|
76
|
+
project = detect_project()
|
|
77
|
+
banner(
|
|
78
|
+
model,
|
|
79
|
+
str(project.root),
|
|
80
|
+
project.is_git,
|
|
81
|
+
sandbox=sandbox,
|
|
82
|
+
sandbox_allow_network=sandbox_allow_network,
|
|
83
|
+
)
|
|
84
|
+
if project.context_file:
|
|
85
|
+
console.print(
|
|
86
|
+
f" [dim]loaded context: "
|
|
87
|
+
f"{project.context_file.name}[/dim]\n"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# --yes routes through the gate in YOLO mode rather than the raw
|
|
91
|
+
# auto_approve — so settings.toml ``deny`` rules (e.g.
|
|
92
|
+
# deny "edit(*.env)") and the irreversible-danger gate STILL fire
|
|
93
|
+
# in unattended runs, instead of a blanket allow-everything.
|
|
94
|
+
from pathlib import Path as _Path
|
|
95
|
+
|
|
96
|
+
from .permissions import Mode, load_rules
|
|
97
|
+
|
|
98
|
+
rules = load_rules(
|
|
99
|
+
[_Path.home() / ".loom-code", project.root / ".loom"]
|
|
100
|
+
)
|
|
101
|
+
gate = ApprovalGate(
|
|
102
|
+
rules=rules,
|
|
103
|
+
mode=Mode.YOLO if yes else Mode.DEFAULT,
|
|
104
|
+
project_root=project.root,
|
|
105
|
+
)
|
|
106
|
+
handler = gate.handler
|
|
107
|
+
agent, workspace = build_agent(
|
|
108
|
+
project,
|
|
109
|
+
model=model,
|
|
110
|
+
approval_handler=handler,
|
|
111
|
+
sandbox=sandbox,
|
|
112
|
+
sandbox_allow_network=sandbox_allow_network,
|
|
113
|
+
)
|
|
114
|
+
renderer = StreamRenderer(sandbox=sandbox)
|
|
115
|
+
# "thinking..." spinner until the first user-visible event —
|
|
116
|
+
# same pattern as the REPL so one-shot mode has the same
|
|
117
|
+
# responsiveness feedback. ``started`` is internal framing;
|
|
118
|
+
# the spinner drops on the first non-started event.
|
|
119
|
+
status = console.status(
|
|
120
|
+
"[dim]thinking...[/dim]", spinner="dots"
|
|
121
|
+
)
|
|
122
|
+
status.start()
|
|
123
|
+
spinner_dropped = False
|
|
124
|
+
|
|
125
|
+
def _drop_spinner() -> None:
|
|
126
|
+
nonlocal spinner_dropped
|
|
127
|
+
if not spinner_dropped:
|
|
128
|
+
status.stop()
|
|
129
|
+
spinner_dropped = True
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
async for event in agent.stream(prompt, user_id="loom-code"):
|
|
133
|
+
if str(event.kind) != "started":
|
|
134
|
+
_drop_spinner()
|
|
135
|
+
renderer.handle(event)
|
|
136
|
+
except KeyboardInterrupt:
|
|
137
|
+
_drop_spinner()
|
|
138
|
+
console.print("\n[yellow]interrupted[/yellow]")
|
|
139
|
+
if json_mode:
|
|
140
|
+
_emit_json_envelope(
|
|
141
|
+
project, renderer, exit_code=130, error="interrupted"
|
|
142
|
+
)
|
|
143
|
+
return 130
|
|
144
|
+
except Exception as exc: # noqa: BLE001 — top-level guard
|
|
145
|
+
_drop_spinner()
|
|
146
|
+
# anyio task groups surface as an opaque ExceptionGroup —
|
|
147
|
+
# flatten to the real cause(s) and add an actionable hint,
|
|
148
|
+
# same presentation as the REPL's turn errors.
|
|
149
|
+
from .repl import _flatten_exception_group, friendly_error_hint
|
|
150
|
+
|
|
151
|
+
inners = (
|
|
152
|
+
_flatten_exception_group(exc)
|
|
153
|
+
if isinstance(exc, BaseExceptionGroup)
|
|
154
|
+
else [exc]
|
|
155
|
+
)
|
|
156
|
+
for inner in inners:
|
|
157
|
+
console.print(
|
|
158
|
+
f"\n[bold red]fatal: "
|
|
159
|
+
f"{type(inner).__name__}: {inner}[/bold red]"
|
|
160
|
+
)
|
|
161
|
+
hint = friendly_error_hint(inner)
|
|
162
|
+
if hint:
|
|
163
|
+
console.print(f" [yellow]→ {hint}[/yellow]")
|
|
164
|
+
if json_mode:
|
|
165
|
+
first = inners[0] if inners else exc
|
|
166
|
+
_emit_json_envelope(
|
|
167
|
+
project,
|
|
168
|
+
renderer,
|
|
169
|
+
exit_code=1,
|
|
170
|
+
error=f"{type(first).__name__}: {first}",
|
|
171
|
+
)
|
|
172
|
+
return 1
|
|
173
|
+
finally:
|
|
174
|
+
_drop_spinner()
|
|
175
|
+
|
|
176
|
+
# Self-improvement loop: a clean one-shot completion is treated
|
|
177
|
+
# as success — credit the notes the agent read so future runs
|
|
178
|
+
# rank them higher. (The REPL is more nuanced: it waits for a
|
|
179
|
+
# /good / /bad signal or "moved on" before attributing.)
|
|
180
|
+
result = renderer.last_result
|
|
181
|
+
if result and not result.get("interrupted"):
|
|
182
|
+
slugs = result.get("cited_slugs") or []
|
|
183
|
+
if slugs:
|
|
184
|
+
try:
|
|
185
|
+
n = await workspace.attribute_outcome(
|
|
186
|
+
success=True, slugs=slugs, user_id="loom-code"
|
|
187
|
+
)
|
|
188
|
+
if n:
|
|
189
|
+
console.print(
|
|
190
|
+
f" [dim]credited {n} note(s) — future "
|
|
191
|
+
f"runs will rank them higher[/dim]"
|
|
192
|
+
)
|
|
193
|
+
except Exception: # noqa: BLE001 — best-effort
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
# End-of-run summary — what changed on disk, what was verified,
|
|
197
|
+
# what the agent captured. Detected from the event stream + git;
|
|
198
|
+
# the agent doesn't have to report any of it itself.
|
|
199
|
+
_print_run_summary(project, renderer)
|
|
200
|
+
if json_mode:
|
|
201
|
+
_emit_json_envelope(project, renderer, exit_code=0)
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _emit_json_envelope(
|
|
206
|
+
project: Project,
|
|
207
|
+
renderer: StreamRenderer,
|
|
208
|
+
*,
|
|
209
|
+
exit_code: int,
|
|
210
|
+
error: str | None = None,
|
|
211
|
+
) -> None:
|
|
212
|
+
"""The single machine-readable line ``--output-format json``
|
|
213
|
+
promises: printed with plain ``print`` (the Rich console is quiet
|
|
214
|
+
in json mode), one JSON object, always the last stdout of the run.
|
|
215
|
+
Append-only contract — add fields, never rename them, so CI
|
|
216
|
+
parsers don't break."""
|
|
217
|
+
import json
|
|
218
|
+
|
|
219
|
+
result = renderer.last_result or {}
|
|
220
|
+
envelope = {
|
|
221
|
+
"is_error": exit_code != 0,
|
|
222
|
+
"exit_code": exit_code,
|
|
223
|
+
"error": error,
|
|
224
|
+
"output": str(result.get("output") or ""),
|
|
225
|
+
"turns": int(result.get("turns", 0)),
|
|
226
|
+
"tokens_in": int(result.get("tokens_in", 0)),
|
|
227
|
+
"cached_tokens_in": int(result.get("cached_tokens_in", 0)),
|
|
228
|
+
"tokens_out": int(result.get("tokens_out", 0)),
|
|
229
|
+
"cost_usd": float(result.get("cost_usd", 0.0)),
|
|
230
|
+
"files_changed": (
|
|
231
|
+
_git_changes(project) if project.is_git else []
|
|
232
|
+
),
|
|
233
|
+
"verify_commands": [
|
|
234
|
+
c
|
|
235
|
+
for c in renderer.bash_commands
|
|
236
|
+
if _is_verify_command(c)
|
|
237
|
+
],
|
|
238
|
+
"notes_written": [
|
|
239
|
+
{"kind": k, "title": t}
|
|
240
|
+
for k, t in renderer.notes_written
|
|
241
|
+
],
|
|
242
|
+
}
|
|
243
|
+
print(json.dumps(envelope))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _is_verify_command(cmd: str) -> bool:
|
|
247
|
+
"""True for bash commands that look like a project's test /
|
|
248
|
+
build runner — what the agent's VERIFY step should produce."""
|
|
249
|
+
head = cmd.lstrip().lower()
|
|
250
|
+
return any(p in head for p in _VERIFY_PATTERNS)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _git_changes(project: Project) -> list[str]:
|
|
254
|
+
"""Lines from ``git status --short`` for the user's repo, with
|
|
255
|
+
loom-code's own ``.loom/`` state filtered out (it's runtime
|
|
256
|
+
chatter, not part of what the agent changed for the user)."""
|
|
257
|
+
res = subprocess.run(
|
|
258
|
+
["git", "-C", str(project.root), "status", "--short"],
|
|
259
|
+
capture_output=True, text=True,
|
|
260
|
+
)
|
|
261
|
+
out: list[str] = []
|
|
262
|
+
for raw in res.stdout.splitlines():
|
|
263
|
+
if not raw.strip():
|
|
264
|
+
continue
|
|
265
|
+
# Status lines are ``XY path`` — split off the path so we
|
|
266
|
+
# can filter on it.
|
|
267
|
+
parts = raw.split(maxsplit=1)
|
|
268
|
+
if len(parts) < 2:
|
|
269
|
+
continue
|
|
270
|
+
path = parts[1].strip().strip('"')
|
|
271
|
+
if path == ".loom" or path == ".loom/" or path.startswith(".loom/"):
|
|
272
|
+
continue
|
|
273
|
+
out.append(raw)
|
|
274
|
+
return out
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _print_run_summary(
|
|
278
|
+
project: Project, renderer: StreamRenderer
|
|
279
|
+
) -> None:
|
|
280
|
+
"""Append a structured summary after the agent finishes —
|
|
281
|
+
cost line (one-shot owns this since there's no REPL pre-prompt
|
|
282
|
+
status to show it), then files changed / verified / notes."""
|
|
283
|
+
result = renderer.last_result or {}
|
|
284
|
+
turns = result.get("turns", 0)
|
|
285
|
+
cost = float(result.get("cost_usd", 0.0))
|
|
286
|
+
tin = int(result.get("tokens_in", 0))
|
|
287
|
+
cached = int(result.get("cached_tokens_in", 0))
|
|
288
|
+
tout = int(result.get("tokens_out", 0))
|
|
289
|
+
if turns or cost or tin or tout:
|
|
290
|
+
from rich.text import Text # local — keeps cli.py top tidy
|
|
291
|
+
console.print(
|
|
292
|
+
Text.assemble(
|
|
293
|
+
(" ", ""),
|
|
294
|
+
(f"{turns} turns", "dim"),
|
|
295
|
+
(" · ", "dim"),
|
|
296
|
+
(f"{tin:,}+{cached:,} in / {tout:,} out", "dim"),
|
|
297
|
+
(" · ", "dim"),
|
|
298
|
+
(f"${cost:.4f}", "dim green"),
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
# Stable, machine-parseable usage marker on its own line. The
|
|
302
|
+
# pretty line above is for humans (Rich markup, commas); this one
|
|
303
|
+
# is for tooling — a benchmark harness / script greps LOOM_USAGE
|
|
304
|
+
# and reads plain ints, no formatting to unpick. Kept minimal and
|
|
305
|
+
# append-only so parsers don't break when fields are added.
|
|
306
|
+
console.print(
|
|
307
|
+
f"LOOM_USAGE turns={turns} "
|
|
308
|
+
f"tokens_in={tin} cached_in={cached} "
|
|
309
|
+
f"tokens_out={tout} cost_usd={cost:.6f}"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
if project.is_git:
|
|
313
|
+
changes = _git_changes(project)
|
|
314
|
+
if changes:
|
|
315
|
+
console.print()
|
|
316
|
+
console.print(" [bold]Files changed:[/bold]")
|
|
317
|
+
for line in changes[:20]: # cap noise on huge changesets
|
|
318
|
+
console.print(f" [dim]{line}[/dim]")
|
|
319
|
+
if len(changes) > 20:
|
|
320
|
+
console.print(
|
|
321
|
+
f" [dim]... (+{len(changes) - 20} more)[/dim]"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
verify = [c for c in renderer.bash_commands if _is_verify_command(c)]
|
|
325
|
+
if verify:
|
|
326
|
+
console.print()
|
|
327
|
+
console.print(" [bold]What was verified:[/bold]")
|
|
328
|
+
for cmd in verify[:5]:
|
|
329
|
+
short = cmd if len(cmd) <= 80 else cmd[:80] + "…"
|
|
330
|
+
console.print(f" [dim]$[/dim] {short}")
|
|
331
|
+
|
|
332
|
+
if renderer.notes_written:
|
|
333
|
+
console.print()
|
|
334
|
+
console.print(" [bold]Notes captured:[/bold]")
|
|
335
|
+
for kind, title in renderer.notes_written[:5]:
|
|
336
|
+
tag = f"[dim]{kind}:[/dim] " if kind else ""
|
|
337
|
+
short = title if len(title) <= 80 else title[:80] + "…"
|
|
338
|
+
console.print(f" • {tag}{short}")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def main() -> None:
|
|
342
|
+
"""``loom-code`` console-script entry point."""
|
|
343
|
+
parser = argparse.ArgumentParser(
|
|
344
|
+
prog="loom-code",
|
|
345
|
+
description=(
|
|
346
|
+
"loom-code — a loomflow-native terminal coding agent"
|
|
347
|
+
),
|
|
348
|
+
)
|
|
349
|
+
parser.add_argument(
|
|
350
|
+
"prompt",
|
|
351
|
+
nargs="*",
|
|
352
|
+
help=(
|
|
353
|
+
"The task. Omit it to drop into the interactive REPL."
|
|
354
|
+
),
|
|
355
|
+
)
|
|
356
|
+
parser.add_argument(
|
|
357
|
+
"--model",
|
|
358
|
+
default=None,
|
|
359
|
+
help=(
|
|
360
|
+
f"Model to use (default: last-used, else {DEFAULT_MODEL}). "
|
|
361
|
+
"Accepts any "
|
|
362
|
+
"string loomflow's resolver routes: Anthropic names "
|
|
363
|
+
"(claude-sonnet-4-6, claude-opus-4-7, ...), OpenAI "
|
|
364
|
+
"names (gpt-4.1-mini, gpt-4.1, o4-mini, ...), local "
|
|
365
|
+
"Ollama (ollama/llama3, ollama/qwen2.5-coder), or "
|
|
366
|
+
"force LiteLLM with litellm/<anything>."
|
|
367
|
+
),
|
|
368
|
+
)
|
|
369
|
+
parser.add_argument(
|
|
370
|
+
"--yes",
|
|
371
|
+
"-y",
|
|
372
|
+
action="store_true",
|
|
373
|
+
help=(
|
|
374
|
+
"Auto-approve all destructive tool calls — no prompts. "
|
|
375
|
+
"For unattended / scripted runs on a disposable tree. "
|
|
376
|
+
"One-shot mode only."
|
|
377
|
+
),
|
|
378
|
+
)
|
|
379
|
+
parser.add_argument(
|
|
380
|
+
"--sandbox",
|
|
381
|
+
action="store_true",
|
|
382
|
+
help=(
|
|
383
|
+
"Run the coder's bash inside an OS sandbox (macOS Seatbelt "
|
|
384
|
+
"/ Linux bwrap): commands may read anywhere but only WRITE "
|
|
385
|
+
"under the project root, with no network. edit/write keep "
|
|
386
|
+
"the approval gate. Off by default."
|
|
387
|
+
),
|
|
388
|
+
)
|
|
389
|
+
parser.add_argument(
|
|
390
|
+
"--sandbox-allow-network",
|
|
391
|
+
action="store_true",
|
|
392
|
+
help="With --sandbox, permit network access from bash commands.",
|
|
393
|
+
)
|
|
394
|
+
parser.add_argument(
|
|
395
|
+
"--output-format",
|
|
396
|
+
choices=("text", "json"),
|
|
397
|
+
default="text",
|
|
398
|
+
help=(
|
|
399
|
+
"One-shot output format. 'json' silences the UI and "
|
|
400
|
+
"prints one machine-readable envelope on stdout (output, "
|
|
401
|
+
"tokens, cost, files changed, exit_code) — for CI and "
|
|
402
|
+
"scripting; usually paired with --yes. Ignored in the "
|
|
403
|
+
"interactive REPL."
|
|
404
|
+
),
|
|
405
|
+
)
|
|
406
|
+
parser.add_argument(
|
|
407
|
+
"--continue",
|
|
408
|
+
dest="continue_",
|
|
409
|
+
action="store_true",
|
|
410
|
+
help=(
|
|
411
|
+
"Start the REPL resumed on this project's most recent "
|
|
412
|
+
"session (same as typing /resume first)."
|
|
413
|
+
),
|
|
414
|
+
)
|
|
415
|
+
parser.add_argument(
|
|
416
|
+
"--resume",
|
|
417
|
+
action="store_true",
|
|
418
|
+
help=(
|
|
419
|
+
"Start the REPL with a picker of recent sessions to "
|
|
420
|
+
"resume (same as typing /resume pick first)."
|
|
421
|
+
),
|
|
422
|
+
)
|
|
423
|
+
args = parser.parse_args()
|
|
424
|
+
|
|
425
|
+
# 1. Read ~/.loom-code/credentials so any keys saved on a
|
|
426
|
+
# previous run are available without the user having to
|
|
427
|
+
# `export` again.
|
|
428
|
+
# 2. If the chosen model still needs a key, prompt for it
|
|
429
|
+
# inline (hidden input), save it, and continue.
|
|
430
|
+
# Both happen BEFORE any Agent is constructed — loomflow's
|
|
431
|
+
# model adapter would crash at init on a missing key otherwise.
|
|
432
|
+
load_credentials()
|
|
433
|
+
# Resolve the startup model: an explicit --model wins; else the model
|
|
434
|
+
# the user last chose (persisted via /model or /set_model); else the
|
|
435
|
+
# built-in default. This makes the chosen model STICK across launches.
|
|
436
|
+
if args.model is None:
|
|
437
|
+
from .credentials import load_preferred_model
|
|
438
|
+
|
|
439
|
+
args.model = load_preferred_model() or DEFAULT_MODEL
|
|
440
|
+
# Expand friendly provider aliases (e.g. ``nvidia/nemotron-...`` →
|
|
441
|
+
# ``litellm/nvidia_nim/nvidia/nemotron-...``) so the rest of the
|
|
442
|
+
# pipeline — key prompt, resolver, persistence — sees the canonical
|
|
443
|
+
# form loomflow understands.
|
|
444
|
+
from .credentials import normalize_model, quiet_litellm_model_warnings
|
|
445
|
+
|
|
446
|
+
args.model = normalize_model(args.model)
|
|
447
|
+
# litellm-routed models trigger loomflow "unknown model" warnings
|
|
448
|
+
# (context window + pricing) that loom-code already handles; hush
|
|
449
|
+
# them so the startup output stays clean. Native models are
|
|
450
|
+
# untouched, so a real misconfig there still surfaces.
|
|
451
|
+
quiet_litellm_model_warnings(args.model)
|
|
452
|
+
if not ensure_key_for_model(args.model, console):
|
|
453
|
+
sys.exit(1)
|
|
454
|
+
# Remember it so the next launch starts here too.
|
|
455
|
+
from .credentials import save_preferred_model
|
|
456
|
+
|
|
457
|
+
save_preferred_model(args.model)
|
|
458
|
+
|
|
459
|
+
if not args.prompt:
|
|
460
|
+
# No task → interactive REPL. --yes is meaningless here
|
|
461
|
+
# (the REPL is interactive by definition); ignore it.
|
|
462
|
+
project = detect_project()
|
|
463
|
+
resume = (
|
|
464
|
+
"pick" if args.resume else "last" if args.continue_ else None
|
|
465
|
+
)
|
|
466
|
+
exit_code = anyio.run(
|
|
467
|
+
functools.partial(
|
|
468
|
+
run_repl,
|
|
469
|
+
project,
|
|
470
|
+
args.model,
|
|
471
|
+
sandbox=args.sandbox,
|
|
472
|
+
sandbox_allow_network=args.sandbox_allow_network,
|
|
473
|
+
resume=resume,
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
sys.exit(exit_code)
|
|
477
|
+
|
|
478
|
+
# One-shot mode.
|
|
479
|
+
prompt = " ".join(args.prompt)
|
|
480
|
+
exit_code = anyio.run(
|
|
481
|
+
functools.partial(
|
|
482
|
+
_run_once,
|
|
483
|
+
prompt,
|
|
484
|
+
args.model,
|
|
485
|
+
args.yes,
|
|
486
|
+
sandbox=args.sandbox,
|
|
487
|
+
sandbox_allow_network=args.sandbox_allow_network,
|
|
488
|
+
output_format=args.output_format,
|
|
489
|
+
)
|
|
490
|
+
)
|
|
491
|
+
sys.exit(exit_code)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
if __name__ == "__main__":
|
|
495
|
+
main()
|