agent7 0.1.0__tar.gz

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.
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .env
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ .pytest_cache/
9
+ .ruff_cache/
agent7-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 r1cardohj
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
agent7-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.5
2
+ Name: agent7
3
+ Version: 0.1.0
4
+ Summary: The simplest coding agent — four tools, one loop, ~1200 lines of Python
5
+ Project-URL: Homepage, https://github.com/r1cardohj/agent7
6
+ Project-URL: Repository, https://github.com/r1cardohj/agent7
7
+ Project-URL: Issues, https://github.com/r1cardohj/agent7/issues
8
+ Author-email: r1cardohj <2413302357@qq.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agent,cli,coding-agent,deepseek,kimi,llm,openai-compatible
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: openai>=1.40.0
25
+ Requires-Dist: prompt-toolkit>=3.0.0
26
+ Requires-Dist: rich>=13.0.0
27
+ Description-Content-Type: text/markdown
28
+
29
+ # agent7
30
+
31
+ **The simplest coding agent. ~1200 lines of Python, four tools, one loop.**
32
+
33
+ No orchestration: no plan mode, no subagents, no parallel tasks, no background jobs.
34
+ You give it one sentence; it reads files, edits files, runs commands, then stops.
35
+ Built for people who work on one thing at a time — you hold one task in your head, and so does it.
36
+
37
+ ```
38
+ agent7/ 1252 lines
39
+ __main__.py 355 CLI + REPL + @path completion
40
+ agent.py 398 tools + agent loop + compaction
41
+ commands.py 236 slash commands + model switching
42
+ providers.py 161 API key auth
43
+ session.py 101 session state
44
+ ```
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install agent7
50
+ # or: uv tool install agent7
51
+ # or from source: uv sync && uv run agent7
52
+ ```
53
+
54
+ ## Auth
55
+
56
+ Paste an API key; there is no OAuth. Keys are stored in `~/.config/agent7/auth.json`.
57
+
58
+ ```bash
59
+ export DEEPSEEK_API_KEY=sk-...
60
+ # export KIMI_API_KEY=...
61
+ # export MOONSHOT_API_KEY=sk-...
62
+ # export AGENT7_MODEL=deepseek-flash
63
+ ```
64
+
65
+ You can also log in from inside the REPL:
66
+
67
+ ```text
68
+ /login deepseek
69
+ /login kimi # optional: /login kimi global
70
+ /login moonshot
71
+ /model # list models for every logged-in provider
72
+ /model deepseek-flash
73
+ ```
74
+
75
+ ## Usage
76
+
77
+ ```bash
78
+ uv run agent7 "create hello.py that prints hi" # one-shot
79
+ uv run agent7 # REPL
80
+ ```
81
+
82
+ REPL commands: `/help` `/model` `/status` `/login` `/logout` `/compact`.
83
+ Exit with an empty line or Ctrl-D.
84
+
85
+ Type `@` in the REPL to recursively complete files and directories from the cwd:
86
+ `fix @agent7/agent.py`.
87
+ Tab accepts, directories keep going (`@agent7/` → `@agent7/session.py`), and spaces
88
+ are escaped (`@my\ file.txt`), so the path the model sees is the path it can read.
89
+
90
+ `--cwd` sets the working directory, `--max-turns` caps the loop (default 30 turns;
91
+ when it runs out you get `# (stopped after N turns)`). A run that ends with no visible
92
+ text never ends silently: you get either `# (stopped after N turns)`,
93
+ `# no text in the reply …` or `# … hit the output token limit …`.
94
+
95
+ ## Only four tools
96
+
97
+ | Tool | What it does | Output budget |
98
+ |---|---|---|
99
+ | `read` | Read a file with line numbers | 200 lines per call by default; page with `offset=` |
100
+ | `write` | Write a file; with `old_string=` replace one unique span | `path +N -M` diff stats |
101
+ | `bash` | Run a shell command in the cwd | ~12k chars |
102
+ | `grep` | rg/grep search | 100 lines / ~12k chars |
103
+
104
+ That's it. No `edit`, no `list_dir`, no `todo`, no `task`.
105
+ Need more capability? Let the agent use `bash`.
106
+
107
+ Tools run **serially**, one after another — so the log always reads in order and
108
+ you never get three outputs interleaved. Edits are applied without a confirmation
109
+ prompt; you just get a one-line diff stat.
110
+
111
+ ## Context
112
+
113
+ At 75% context usage, old tool/assistant text is compacted automatically: the last
114
+ 3 user turns are kept, or for one-shot tasks the last 4 tool results. `/compact`
115
+ triggers the same compaction manually.
116
+
117
+ `AGENT7_CONTEXT_LIMIT` overrides the context limit.
118
+
119
+ ## What it is not
120
+
121
+ - Not an orchestrator: no branching, no subagents
122
+ - Not a parallel framework: single-threaded, one thing at a time
123
+ - Not a background service: a foreground process, stopped with Ctrl-C
124
+ - No sandbox: tools run as you, and `bash` can do anything your account can do
125
+
126
+ If that kind of "small" is exactly what you want, this is enough. If it's missing
127
+ the abstraction you had in mind, that omission is probably the point — not an oversight.
agent7-0.1.0/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # agent7
2
+
3
+ **The simplest coding agent. ~1200 lines of Python, four tools, one loop.**
4
+
5
+ No orchestration: no plan mode, no subagents, no parallel tasks, no background jobs.
6
+ You give it one sentence; it reads files, edits files, runs commands, then stops.
7
+ Built for people who work on one thing at a time — you hold one task in your head, and so does it.
8
+
9
+ ```
10
+ agent7/ 1252 lines
11
+ __main__.py 355 CLI + REPL + @path completion
12
+ agent.py 398 tools + agent loop + compaction
13
+ commands.py 236 slash commands + model switching
14
+ providers.py 161 API key auth
15
+ session.py 101 session state
16
+ ```
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install agent7
22
+ # or: uv tool install agent7
23
+ # or from source: uv sync && uv run agent7
24
+ ```
25
+
26
+ ## Auth
27
+
28
+ Paste an API key; there is no OAuth. Keys are stored in `~/.config/agent7/auth.json`.
29
+
30
+ ```bash
31
+ export DEEPSEEK_API_KEY=sk-...
32
+ # export KIMI_API_KEY=...
33
+ # export MOONSHOT_API_KEY=sk-...
34
+ # export AGENT7_MODEL=deepseek-flash
35
+ ```
36
+
37
+ You can also log in from inside the REPL:
38
+
39
+ ```text
40
+ /login deepseek
41
+ /login kimi # optional: /login kimi global
42
+ /login moonshot
43
+ /model # list models for every logged-in provider
44
+ /model deepseek-flash
45
+ ```
46
+
47
+ ## Usage
48
+
49
+ ```bash
50
+ uv run agent7 "create hello.py that prints hi" # one-shot
51
+ uv run agent7 # REPL
52
+ ```
53
+
54
+ REPL commands: `/help` `/model` `/status` `/login` `/logout` `/compact`.
55
+ Exit with an empty line or Ctrl-D.
56
+
57
+ Type `@` in the REPL to recursively complete files and directories from the cwd:
58
+ `fix @agent7/agent.py`.
59
+ Tab accepts, directories keep going (`@agent7/` → `@agent7/session.py`), and spaces
60
+ are escaped (`@my\ file.txt`), so the path the model sees is the path it can read.
61
+
62
+ `--cwd` sets the working directory, `--max-turns` caps the loop (default 30 turns;
63
+ when it runs out you get `# (stopped after N turns)`). A run that ends with no visible
64
+ text never ends silently: you get either `# (stopped after N turns)`,
65
+ `# no text in the reply …` or `# … hit the output token limit …`.
66
+
67
+ ## Only four tools
68
+
69
+ | Tool | What it does | Output budget |
70
+ |---|---|---|
71
+ | `read` | Read a file with line numbers | 200 lines per call by default; page with `offset=` |
72
+ | `write` | Write a file; with `old_string=` replace one unique span | `path +N -M` diff stats |
73
+ | `bash` | Run a shell command in the cwd | ~12k chars |
74
+ | `grep` | rg/grep search | 100 lines / ~12k chars |
75
+
76
+ That's it. No `edit`, no `list_dir`, no `todo`, no `task`.
77
+ Need more capability? Let the agent use `bash`.
78
+
79
+ Tools run **serially**, one after another — so the log always reads in order and
80
+ you never get three outputs interleaved. Edits are applied without a confirmation
81
+ prompt; you just get a one-line diff stat.
82
+
83
+ ## Context
84
+
85
+ At 75% context usage, old tool/assistant text is compacted automatically: the last
86
+ 3 user turns are kept, or for one-shot tasks the last 4 tool results. `/compact`
87
+ triggers the same compaction manually.
88
+
89
+ `AGENT7_CONTEXT_LIMIT` overrides the context limit.
90
+
91
+ ## What it is not
92
+
93
+ - Not an orchestrator: no branching, no subagents
94
+ - Not a parallel framework: single-threaded, one thing at a time
95
+ - Not a background service: a foreground process, stopped with Ctrl-C
96
+ - No sandbox: tools run as you, and `bash` can do anything your account can do
97
+
98
+ If that kind of "small" is exactly what you want, this is enough. If it's missing
99
+ the abstraction you had in mind, that omission is probably the point — not an oversight.
@@ -0,0 +1,3 @@
1
+ """agent7 — minimal coding agent."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,357 @@
1
+ """CLI: python -m agent7 [task] [--cwd DIR]"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import os
8
+ import re
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+
13
+ from openai import AsyncOpenAI
14
+ from prompt_toolkit import PromptSession
15
+ from prompt_toolkit.application.current import get_app
16
+ from prompt_toolkit.completion import Completer, Completion, CompleteEvent
17
+ from prompt_toolkit.document import Document
18
+ from prompt_toolkit.enums import EditingMode
19
+ from prompt_toolkit.formatted_text import HTML
20
+ from prompt_toolkit.history import InMemoryHistory
21
+ from prompt_toolkit.key_binding.vi_state import InputMode
22
+ from rich.console import Console
23
+ from rich.markdown import Markdown
24
+ from rich.text import Text
25
+
26
+ from .agent import SYSTEM, Interrupted, run_agent, tool_summary
27
+ from .commands import dispatch, fetch_models, known_names, switch_model
28
+ from .providers import get_provider, resolve_all, resolve_default
29
+ from .session import Session, format_context_prompt
30
+
31
+ console, err = Console(stderr=False), Console(stderr=True)
32
+ _THINK_FRAMES = ("thinking ", "thinking. ", "thinking.. ", "thinking...")
33
+
34
+ def _load_dotenv(path: Path = Path(".env")) -> None:
35
+ if not path.is_file():
36
+ return
37
+ for line in path.read_text(encoding="utf-8").splitlines():
38
+ line = line.strip()
39
+ if not line or line.startswith("#") or "=" not in line:
40
+ continue
41
+ key, _, val = line.partition("=")
42
+ os.environ.setdefault(key.strip(), val.strip().strip("'").strip('"'))
43
+
44
+ def _force_utf8_stdio() -> None:
45
+ for stream in (sys.stdin, sys.stdout, sys.stderr):
46
+ try:
47
+ stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
48
+ except Exception:
49
+ pass
50
+ os.environ.setdefault("PYTHONIOENCODING", "utf-8")
51
+
52
+ class ReplyPrinter:
53
+ def __init__(self) -> None:
54
+ self.buf: list[str] = []
55
+ self._chars = 0
56
+ self._thinking_open = False
57
+ self._thinking_t0 = 0.0
58
+ self._counting = False
59
+
60
+ def __enter__(self) -> ReplyPrinter:
61
+ return self
62
+
63
+ def __exit__(self, *exc: object) -> None:
64
+ self._close_thinking()
65
+ self._clear_counter()
66
+ text = "".join(self.buf)
67
+ self.buf.clear()
68
+ self._chars = 0
69
+ if text.strip():
70
+ console.print()
71
+ console.print(Markdown(text))
72
+ console.print()
73
+
74
+ def _clear_status_line(self) -> None:
75
+ if err.is_terminal:
76
+ sys.stderr.write("\r\033[K")
77
+ sys.stderr.flush()
78
+
79
+ def _close_thinking(self) -> None:
80
+ if self._thinking_open:
81
+ self._clear_status_line()
82
+ self._thinking_open = False
83
+
84
+ def _clear_counter(self) -> None:
85
+ if self._counting:
86
+ self._clear_status_line()
87
+ self._counting = False
88
+
89
+ def _show_counter(self) -> None:
90
+ if not err.is_terminal:
91
+ return
92
+ self._close_thinking()
93
+ self._counting = True
94
+ sys.stderr.write(f"\r\033[K … {self._chars} chars")
95
+ sys.stderr.flush()
96
+
97
+ def on_thinking(self, text: str) -> None:
98
+ if not text:
99
+ return
100
+ self._clear_counter()
101
+ if not self._thinking_open:
102
+ self._thinking_open, self._thinking_t0 = True, time.monotonic()
103
+ if err.is_terminal:
104
+ frame = int((time.monotonic() - self._thinking_t0) / 0.4) % len(_THINK_FRAMES)
105
+ sys.stderr.write(f"\r\033[K {_THINK_FRAMES[frame]}")
106
+ sys.stderr.flush()
107
+
108
+ def on_answer_to_thinking(self) -> None:
109
+ if not self.buf:
110
+ return
111
+ self.buf.clear()
112
+ self._chars = 0
113
+ self._clear_counter()
114
+ self.on_thinking(".")
115
+
116
+ def on_delta(self, text: str) -> None:
117
+ self.buf.append(text)
118
+ self._chars += len(text)
119
+ self._show_counter()
120
+
121
+ def on_tool(self, turn: int, name: str, args: dict, result: str) -> None:
122
+ self._close_thinking()
123
+ self._clear_counter()
124
+ self.buf.clear()
125
+ self._chars = 0
126
+ ok = not result.startswith("error:")
127
+ mark = "[green]✓[/green]" if ok else "[red]✗[/red]"
128
+ meta = ""
129
+ if name == "bash" and result.startswith("exit="):
130
+ code = result.split("\n", 1)[0].removeprefix("exit=")
131
+ color = "green" if code == "0" else "yellow"
132
+ meta = f" [{color}]exit={code}[/{color}]"
133
+ err.print(f" {mark} [bold]{name:<5}[/bold] [dim]{tool_summary(name, args)}[/dim]{meta}")
134
+
135
+ def on_preview(self, stats: str) -> None:
136
+ self._close_thinking()
137
+ self._clear_counter()
138
+ line = (stats.splitlines() or [""])[0]
139
+ if not line:
140
+ return
141
+ path, _, rest = line.rpartition(" ")
142
+ head = Text("# write ", style="dim")
143
+ head.append(path or line, style="dim")
144
+ if path and rest.startswith("+"):
145
+ head.append(" ")
146
+ for part in rest.split():
147
+ head.append(part + " ", style="green bold" if part[0] == "+" else "red bold")
148
+ head.right_crop(1)
149
+ err.print(head)
150
+
151
+ _AT_PATH = re.compile(r"(?<![A-Za-z0-9_.\-/@])@((?:\\ |[^\s@])*)$")
152
+
153
+ def at_path_prefix(text: str) -> str | None:
154
+ """The path fragment after an '@' that starts the current word ('@' -> '')."""
155
+ m = _AT_PATH.search(text)
156
+ return m.group(1) if m else None
157
+
158
+ def path_completions(cwd: Path, text: str) -> list[Completion]:
159
+ """Complete files/dirs as `@path` for the word being typed at the end of text."""
160
+ fragment = at_path_prefix(text)
161
+ if fragment is None:
162
+ return []
163
+ raw = os.path.expanduser(fragment.replace("\\ ", " "))
164
+ head, _, prefix = raw.rpartition("/")
165
+ target = Path(head) if head else Path()
166
+ if not target.is_absolute():
167
+ target = cwd / target
168
+ entries: list[tuple[Path, str]] = []
169
+ show_hidden = prefix.startswith(".")
170
+ for root, dirs, files in os.walk(target):
171
+ if not show_hidden:
172
+ dirs[:] = [name for name in dirs if not name.startswith(".")]
173
+ for name in dirs + files:
174
+ if (name.startswith(".") and not show_hidden) or not name.startswith(prefix):
175
+ continue
176
+ p = Path(root) / name
177
+ entries.append((p, p.relative_to(target).as_posix()))
178
+ entries.sort(key=lambda entry: (not entry[0].is_dir(), entry[1].lower()))
179
+ out = []
180
+ for p, path in entries:
181
+ is_dir = p.is_dir()
182
+ rel = f"{head}/{path}" if head else path
183
+ out.append(Completion(
184
+ (rel + "/" if is_dir else rel).replace(" ", "\\ "),
185
+ start_position=-len(fragment),
186
+ display=path + ("/" if is_dir else ""),
187
+ ))
188
+ return out
189
+
190
+ class ReplCompleter(Completer):
191
+ """Slash commands, plus `@path` file completion from the cwd."""
192
+
193
+ def __init__(self, session: Session) -> None:
194
+ self.session = session
195
+
196
+ def get_completions(self, document: Document, complete_event: CompleteEvent):
197
+ text = document.text_before_cursor
198
+ if not text.startswith("/"):
199
+ yield from path_completions(self.session.cwd, text)
200
+ return
201
+ parts = text[1:].split(" ", 1)
202
+ cmd = parts[0].lower()
203
+ if len(parts) == 1 and " " not in text:
204
+ for name in known_names():
205
+ if name.startswith(cmd):
206
+ yield Completion(name, start_position=-len(cmd), display=f"/{name}")
207
+ return
208
+ if len(parts) == 2:
209
+ prefix = parts[1]
210
+ if cmd == "model":
211
+ for mid in self.session.models_cache or []:
212
+ bare = mid.partition("/")[2]
213
+ if mid.startswith(prefix) or bare.startswith(prefix) or prefix.lower() in mid.lower():
214
+ yield Completion(mid, start_position=-len(prefix), display=mid)
215
+ elif cmd in ("login", "logout"):
216
+ for mid in ("deepseek", "kimi", "moonshot", "global"):
217
+ if mid.startswith(prefix.lower()):
218
+ yield Completion(mid, start_position=-len(prefix), display=mid)
219
+
220
+ NO_TEXT_NOTICE = "no text in the reply — the model stopped without content (retry, or /model)"
221
+ TRUNCATED_NOTICE = "no text in the reply — the model hit the output token limit (retry with a smaller step)"
222
+ STOPPED_PREFIX = "(stopped after "
223
+
224
+ def reply_notice(answer: str, finish_reason: str | None = None) -> str | None:
225
+ """Dim note for a run that produced nothing visible on screen."""
226
+ if answer.startswith(STOPPED_PREFIX): # "ran out of turns" is itself the message
227
+ return answer
228
+ if answer.strip():
229
+ return None
230
+ return TRUNCATED_NOTICE if finish_reason == "length" else NO_TEXT_NOTICE
231
+
232
+ async def _run_once(session: Session, task: str) -> str:
233
+ finish: list[str] = []
234
+ with ReplyPrinter() as rp:
235
+ answer = await run_agent(
236
+ task, cwd=session.cwd, client=session.client, model=session.model,
237
+ max_turns=session.max_turns, messages=session.messages,
238
+ on_delta=rp.on_delta, on_thinking=rp.on_thinking,
239
+ on_answer_to_thinking=rp.on_answer_to_thinking, on_tool=rp.on_tool,
240
+ on_preview=rp.on_preview, on_usage=session.update_usage,
241
+ context_limit=session.context_limit, prompt_tokens=session.prompt_tokens,
242
+ on_compact=lambda n: err.print(f"[dim]# compacted {n} message(s)[/dim]"),
243
+ on_finish=finish.append,
244
+ )
245
+ # never leave a tool call as the last thing on screen
246
+ if notice := reply_notice(answer, finish[-1] if finish else None):
247
+ err.print(f"[dim]# {notice}[/dim]")
248
+ return answer
249
+
250
+ async def async_main(argv: list[str] | None = None) -> None:
251
+ _force_utf8_stdio()
252
+ _load_dotenv()
253
+ p = argparse.ArgumentParser(description="agent7 — minimal coding agent")
254
+ p.add_argument("task", nargs="*", help="task; omit for REPL")
255
+ p.add_argument("--cwd", type=Path, default=None)
256
+ p.add_argument("--max-turns", type=int, default=30)
257
+ args = p.parse_args(argv)
258
+ task = " ".join(args.task).strip() or None
259
+ cwd = (args.cwd or Path.cwd()).resolve()
260
+ model = os.environ.get("AGENT7_MODEL") or ""
261
+
262
+ all_creds = resolve_all()
263
+ picked = resolve_default()
264
+ if picked is None and all_creds:
265
+ pid = next(iter(all_creds))
266
+ prov = get_provider(pid)
267
+ picked = (prov, all_creds[pid]) if prov else None
268
+ if picked:
269
+ prov, cred = picked
270
+ client = prov.make_client(cred)
271
+ else:
272
+ client = AsyncOpenAI(api_key="missing", base_url="https://api.deepseek.com")
273
+ if task:
274
+ print("No credentials. Set DEEPSEEK_API_KEY / KIMI_API_KEY / MOONSHOT_API_KEY,\n"
275
+ "or run `uv run agent7` and `/login`.", file=sys.stderr)
276
+ sys.exit(1)
277
+ prov, cred = None, None
278
+ state = Session(
279
+ model=model or "pending", cwd=cwd, client=client, max_turns=args.max_turns,
280
+ messages=[{"role": "system", "content": SYSTEM}],
281
+ provider_id=prov.id if prov else None, cred_source=cred.source if cred else None,
282
+ credential=cred, credentials=dict(all_creds),
283
+ )
284
+ try:
285
+ ids: list[str] = []
286
+ if all_creds:
287
+ try:
288
+ ids = await fetch_models(state)
289
+ except Exception as e: # noqa: BLE001
290
+ err.print(f"[dim]# models fetch failed: {e}[/dim]")
291
+ if model:
292
+ result = await switch_model(state, model)
293
+ if result.message.startswith(("unknown", "ambiguous")):
294
+ err.print(f"[dim]# {result.message}[/dim]")
295
+ if ids:
296
+ await switch_model(state, ids[0])
297
+ else:
298
+ state.set_model(model)
299
+ elif state.model in ("", "pending"):
300
+ if ids:
301
+ await switch_model(state, ids[0])
302
+ else:
303
+ state.set_model("deepseek-chat")
304
+ err.print(f"[dim]# agent7 model={state.model_ref or state.model} "
305
+ f"providers=[{','.join(state.credentials) or 'none'}] cwd={state.cwd}[/dim]")
306
+ if task:
307
+ try:
308
+ await _run_once(state, task)
309
+ except Interrupted:
310
+ err.print("\n[dim]# interrupted[/dim]")
311
+ return
312
+ err.print("[dim]# REPL /help · /login · /model · @file · empty line / Ctrl-D to quit[/dim]")
313
+ if not all_creds:
314
+ err.print("[dim]# no credentials — /login deepseek | /login kimi[/dim]")
315
+
316
+ def _vi_rprompt() -> HTML:
317
+ mode = get_app().vi_state.input_mode
318
+ label, color = ("INSERT", "ansigreen") if mode in (InputMode.INSERT, InputMode.INSERT_MULTIPLE) else (
319
+ ("NORMAL", "ansiyellow") if mode == InputMode.NAVIGATION else ("REPLACE", "ansired")
320
+ )
321
+ return HTML(f'<style fg="{color}">-- {label} --</style>')
322
+
323
+ prompt = PromptSession(
324
+ history=InMemoryHistory(),
325
+ completer=ReplCompleter(state),
326
+ complete_while_typing=True,
327
+ editing_mode=EditingMode.VI,
328
+ rprompt=_vi_rprompt,
329
+ )
330
+ while True:
331
+ try:
332
+ line = (await prompt.prompt_async(lambda: f"{format_context_prompt(state)} > ")).strip()
333
+ except (EOFError, KeyboardInterrupt):
334
+ print(file=sys.stderr)
335
+ break
336
+ if not line:
337
+ break
338
+ result = await dispatch(state, line)
339
+ if result is not None:
340
+ for row in result.message.splitlines():
341
+ err.print(f"[dim]{row}[/dim]")
342
+ continue
343
+ if state.credential is None:
344
+ err.print("[yellow]# login first: /login deepseek | /login kimi[/yellow]")
345
+ continue
346
+ try:
347
+ await _run_once(state, line)
348
+ except (Interrupted, asyncio.CancelledError, KeyboardInterrupt):
349
+ err.print("\n[dim]# interrupted[/dim]")
350
+ finally:
351
+ await state.client.close()
352
+
353
+ def main(argv: list[str] | None = None) -> None:
354
+ asyncio.run(async_main(argv))
355
+
356
+ if __name__ == "__main__":
357
+ main()