baxter-cli 0.1.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.
baxter/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # entry point for baxter
baxter/baxter_cli.py ADDED
@@ -0,0 +1,450 @@
1
+ import json
2
+ import os
3
+ import re
4
+ import sys
5
+
6
+ from dotenv import load_dotenv
7
+
8
+ from baxter import terminal_ui as tui
9
+ from baxter.providers import (
10
+ PROVIDERS,
11
+ call_provider,
12
+ provider_has_key,
13
+ )
14
+ from baxter.tools.registry import TOOL_NAMES, render_registry_for_prompt, run_tool
15
+
16
+
17
+ def load_baxter_env() -> None:
18
+ # 1) Load machine-level Baxter config for one-time key setup.
19
+ home = os.path.expanduser("~")
20
+ if home and home != "~":
21
+ user_env = os.path.join(home, ".baxter", ".env")
22
+ if os.path.isfile(user_env):
23
+ load_dotenv(dotenv_path=user_env, override=False)
24
+
25
+ # 2) Load per-project overrides from cwd.
26
+ load_dotenv(override=True)
27
+
28
+
29
+ load_baxter_env()
30
+
31
+ BOOT_BANNER = r"""
32
+ ██████╗ █████╗ ██╗ ██╗████████╗███████╗██████╗
33
+ ██╔══██╗██╔══██╗╚██╗██╔╝╚══██╔══╝██╔════╝██╔══██╗
34
+ ██████╔╝███████║ ╚███╔╝ ██║ █████╗ ██████╔╝
35
+ ██╔══██╗██╔══██║ ██╔██╗ ██║ ██╔══╝ ██╔══██╗
36
+ ██████╔╝██║ ██║██╔╝ ██╗ ██║ ███████╗██║ ██║
37
+ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝
38
+
39
+ ⟡ B A X T E R • Neural Reasoning Engine Online ⟡
40
+ ──────────────────────────────────────────────────────────
41
+ CORE: STABLE | TOOL MODULES: READY | SAFETY: ON
42
+ ──────────────────────────────────────────────────────────
43
+ """
44
+
45
+ MUTATING_TOOLS = {"apply_diff", "write_file", "make_dir", "delete_path", "run_cmd"}
46
+ READ_ONLY_REQUEST_HINTS = (
47
+ "what does",
48
+ "what is in",
49
+ "show me",
50
+ "read ",
51
+ "display ",
52
+ "contents",
53
+ "inside",
54
+ "cat ",
55
+ "view ",
56
+ )
57
+ MUTATING_REQUEST_HINTS = (
58
+ "edit",
59
+ "change",
60
+ "modify",
61
+ "update",
62
+ "fix",
63
+ "rewrite",
64
+ "refactor",
65
+ "create",
66
+ "add",
67
+ "delete",
68
+ "remove",
69
+ "rename",
70
+ "move",
71
+ "commit",
72
+ "push",
73
+ "run ",
74
+ "execute",
75
+ )
76
+
77
+
78
+ def build_system_prompt() -> str:
79
+ return f"""You are Baxter, a helpful coding assistant.
80
+
81
+ You have OPTIONAL access to a tool registry. Use a tool ONLY when necessary to complete the user's request correctly.
82
+
83
+ {render_registry_for_prompt()}
84
+
85
+ TOOL CALL RULES:
86
+ - If you decide to use a tool, your entire response MUST be ONLY valid JSON on a single line:
87
+ {{"tool":"<tool_name>","args":{{...}}}}
88
+ - tool must be one of: {", ".join(TOOL_NAMES)}
89
+ - Do not include any extra text before or after the JSON (no markdown, no explanation).
90
+ - Return exactly ONE tool call per response. Never return multiple JSON objects.
91
+ - If no tool is needed, respond normally in plain English.
92
+ - If the user asks what is inside a file / to view / open / read / show contents, you MUST call read_file.
93
+ - If the user asks to list a directory, you MUST call list_dir.
94
+ - If the user asks to create a folder/directory, you MUST call make_dir.
95
+ - If the user asks to delete/remove a file or folder, you MUST call delete_path.
96
+ - If the user asks to create a NEW file, you MUST call write_file.
97
+ - If the user asks to change/edit/modify a file, you MUST call read_file first, then apply_diff.
98
+ - If a file path is unknown (example: user only says "edit index.html"), call search_code first with the filename to locate the correct relative path.
99
+ - For search_code, use short search terms (filename, symbol, or key phrase), not the user's entire sentence.
100
+ - Only use write_file with overwrite=true for full rewrites when apply_diff is not suitable.
101
+ - If the user asks to run a terminal command, you MUST call run_cmd (only allowed commands will work).
102
+ - If the user asks to do git actions (status/add/commit/push/pull/etc), you MUST call git_cmd.
103
+ - If the user asks to search the codebase/files for text or symbols, you MUST call search_code.
104
+ - If the user asks to "commit and push" (or equivalent), you MUST do: git add -> git commit -> git push.
105
+ - If the user asks you to commit changes, you MUST run git add and git commit yourself via git_cmd; do not ask the user to run commands.
106
+ - If a commit message is not provided, use a concise default commit message that matches the change.
107
+ - You MUST NOT call git push if there are uncommitted changes.
108
+ - Before any git push, ensure the latest git commit step succeeded (exit_code 0).
109
+ - If replying with instructions, use numbered or bullet points.
110
+ - You MUST NOT claim you created/modified/deleted anything unless a tool result says ok:true.
111
+ - read_file, list_dir, and search_code do not modify files; never claim code was changed after those tools.
112
+ - When the user asks for an edit/fix, keep calling tools until the edit is actually applied (or you are blocked).
113
+ - Never include code blocks or include explanations when calling tools.
114
+ """
115
+
116
+
117
+ def pick_startup_provider() -> str:
118
+ for name in ("anthropic", "openai", "groq"):
119
+ if provider_has_key(name):
120
+ return name
121
+ print(
122
+ tui.c(
123
+ "WARNING: No provider API keys found. "
124
+ "Set one of ANTHROPIC_API_KEY, OPENAI_API_KEY, or GROQ_API_KEY in "
125
+ "~/.baxter/.env (or .env in the current folder).",
126
+ tui.YELLOW,
127
+ )
128
+ )
129
+ return "anthropic"
130
+
131
+
132
+ def try_parse_tool_call(text: str):
133
+ try:
134
+ obj = json.loads(text)
135
+ if isinstance(obj, dict) and "tool" in obj and "args" in obj:
136
+ return obj
137
+ except Exception:
138
+ pass
139
+
140
+ decoder = json.JSONDecoder()
141
+ i = 0
142
+ while i < len(text):
143
+ start = text.find("{", i)
144
+ if start == -1:
145
+ break
146
+ try:
147
+ obj, end = decoder.raw_decode(text[start:])
148
+ if isinstance(obj, dict) and "tool" in obj and "args" in obj:
149
+ return obj
150
+ i = start + max(1, end)
151
+ except Exception:
152
+ i = start + 1
153
+
154
+ invoke_match = re.search(
155
+ r"<invoke\s+name=\"([^\"]+)\"\s*>(.*?)</invoke>",
156
+ text,
157
+ flags=re.DOTALL | re.IGNORECASE,
158
+ )
159
+ if invoke_match:
160
+ tool_name = invoke_match.group(1).strip()
161
+ invoke_body = invoke_match.group(2)
162
+ args: dict = {}
163
+ for p in re.finditer(
164
+ r"<parameter\s+name=\"([^\"]+)\"\s*>(.*?)</parameter>",
165
+ invoke_body,
166
+ flags=re.DOTALL | re.IGNORECASE,
167
+ ):
168
+ key = p.group(1).strip()
169
+ raw_val = p.group(2).strip()
170
+ val: object = raw_val
171
+ low = raw_val.lower()
172
+ if low in {"true", "false"}:
173
+ val = low == "true"
174
+ elif re.fullmatch(r"-?\d+", raw_val):
175
+ try:
176
+ val = int(raw_val)
177
+ except Exception:
178
+ val = raw_val
179
+ elif raw_val.startswith("[") or raw_val.startswith("{"):
180
+ try:
181
+ val = json.loads(raw_val)
182
+ except Exception:
183
+ val = raw_val
184
+ args[key] = val
185
+ if tool_name and isinstance(args, dict):
186
+ return {"tool": tool_name, "args": args}
187
+ return None
188
+
189
+
190
+ def looks_like_broken_tool_call(text: str) -> bool:
191
+ t = (text or "").strip()
192
+ if not t:
193
+ return False
194
+ if '"tool"' in t and '"args"' in t:
195
+ return True
196
+ if t.startswith("{") and ("tool" in t or "args" in t):
197
+ return True
198
+ return False
199
+
200
+
201
+ def last_n_turns(messages, n_turns=6):
202
+ system = messages[0]
203
+ tail = messages[1:]
204
+ trimmed_tail = tail[-(n_turns * 2) :]
205
+ return [system] + trimmed_tail
206
+
207
+
208
+ def clip(text: str, max_chars: int = 800) -> str:
209
+ if text is None:
210
+ return ""
211
+ raw_limit = os.getenv("BAXTER_CLIP_CHARS", "").strip()
212
+ if raw_limit:
213
+ try:
214
+ max_chars = int(raw_limit)
215
+ except ValueError:
216
+ max_chars = 0
217
+ if max_chars <= 0:
218
+ return text
219
+ if len(text) <= max_chars:
220
+ return text
221
+ return text[:max_chars] + "\n...[truncated]"
222
+
223
+
224
+ def preflight_tool_check(tool_call: dict):
225
+ tool = tool_call.get("tool")
226
+ args = tool_call.get("args", {}) or {}
227
+ if tool == "git_cmd" and str(args.get("subcommand", "")).strip().lower() == "push":
228
+ status_result = run_tool(
229
+ "git_cmd",
230
+ {
231
+ "subcommand": "status",
232
+ "args": ["--porcelain"],
233
+ "cwd": args.get("cwd", "."),
234
+ },
235
+ )
236
+ if not status_result.get("ok"):
237
+ return {
238
+ "ok": False,
239
+ "error": "pre-push check failed: unable to verify working tree status",
240
+ "precheck": True,
241
+ }
242
+ if int(status_result.get("exit_code", 1)) != 0:
243
+ return {
244
+ "ok": False,
245
+ "error": "pre-push check failed: git status returned non-zero exit code",
246
+ "precheck": True,
247
+ }
248
+ if str(status_result.get("stdout", "")).strip():
249
+ return {
250
+ "ok": False,
251
+ "error": "push blocked: uncommitted changes detected. Commit or stash changes before pushing.",
252
+ "precheck": True,
253
+ }
254
+ return None
255
+
256
+
257
+ def _git_is_mutating(tool_call: dict) -> bool:
258
+ if tool_call.get("tool") != "git_cmd":
259
+ return False
260
+ sub = str((tool_call.get("args") or {}).get("subcommand", "")).strip().lower()
261
+ return sub in {"add", "commit", "push", "pull", "switch", "checkout", "restore", "rm", "mv", "stash"}
262
+
263
+
264
+ def user_allows_mutations(user_text: str) -> bool:
265
+ t = (user_text or "").strip().lower()
266
+ if not t:
267
+ return False
268
+ if any(h in t for h in MUTATING_REQUEST_HINTS):
269
+ return True
270
+ if any(h in t for h in READ_ONLY_REQUEST_HINTS):
271
+ return False
272
+ if t.endswith("?"):
273
+ return False
274
+ return False
275
+
276
+
277
+ def tool_is_mutating(tool_call: dict) -> bool:
278
+ tool = tool_call.get("tool")
279
+ if tool in MUTATING_TOOLS:
280
+ if tool == "write_file":
281
+ return bool((tool_call.get("args") or {}).get("overwrite", False)) or True
282
+ return True
283
+ return _git_is_mutating(tool_call)
284
+
285
+
286
+ def should_enforce_readonly_guard(session: dict) -> bool:
287
+ provider = str(session.get("provider", "")).strip().lower()
288
+ model = str(tui.active_model(session)).strip().lower()
289
+ return provider == "groq" and "llama" in model
290
+
291
+
292
+ def main():
293
+ system_prompt = build_system_prompt()
294
+ messages = [{"role": "system", "content": system_prompt}]
295
+
296
+ os.system("cls" if os.name == "nt" else "clear")
297
+ print(tui.c(BOOT_BANNER, tui.GREEN))
298
+ print(
299
+ tui.c("Has keys:", tui.GREEN),
300
+ f"groq={provider_has_key('groq')} openai={provider_has_key('openai')} anthropic={provider_has_key('anthropic')}",
301
+ )
302
+ print("Type 'exit' to quit.\n")
303
+ print("Use /help for provider/model commands.\n")
304
+
305
+ session = {
306
+ "provider": pick_startup_provider(),
307
+ "model_override": None,
308
+ "last_diff": None,
309
+ }
310
+ print(f"Active provider: {session['provider']} ({tui.active_model(session)})\n")
311
+
312
+ while True:
313
+ try:
314
+ user_text = tui.read_user_input(session)
315
+ except KeyboardInterrupt:
316
+ raise SystemExit(130)
317
+ if user_text is None:
318
+ continue
319
+ if not user_text.strip():
320
+ continue
321
+ if user_text.lower() in {"exit", "quit"}:
322
+ break
323
+ if tui.handle_ui_command(user_text, session):
324
+ continue
325
+
326
+ allow_mutations = user_allows_mutations(user_text)
327
+ enforce_readonly_guard = should_enforce_readonly_guard(session)
328
+ messages.append({"role": "user", "content": user_text})
329
+ tool_index = 0
330
+ malformed_tool_retry_used = False
331
+
332
+ while True:
333
+ try:
334
+ indicator = tui.WorkingIndicator()
335
+ indicator.start()
336
+ try:
337
+ reply = call_provider(
338
+ provider=session["provider"],
339
+ messages=last_n_turns(messages, 6),
340
+ model=tui.active_model(session),
341
+ temperature=0.2,
342
+ )
343
+ finally:
344
+ indicator.stop()
345
+ except Exception as e:
346
+ print(f"▢ {tui.c('Baxter:', tui.RED)} model error: {e}")
347
+ break
348
+
349
+ messages.append({"role": "assistant", "content": reply})
350
+ tool_call = try_parse_tool_call(reply)
351
+
352
+ if not tool_call:
353
+ if not malformed_tool_retry_used and looks_like_broken_tool_call(reply):
354
+ malformed_tool_retry_used = True
355
+ messages.append(
356
+ {
357
+ "role": "user",
358
+ "content": (
359
+ "Your previous response looked like a tool call but was not valid JSON. "
360
+ "Respond again with exactly one valid JSON object on a single line in the "
361
+ 'form {"tool":"<tool_name>","args":{...}} and no extra text.'
362
+ ),
363
+ }
364
+ )
365
+ continue
366
+ tui.print_assistant_reply(reply)
367
+ break
368
+
369
+ tool_index += 1
370
+ tui.print_separator(f"Tool Step {tool_index}")
371
+ tui.print_tool_event(tool_call, tool_index)
372
+
373
+ if enforce_readonly_guard and (not allow_mutations) and tool_is_mutating(tool_call):
374
+ tool_result = {
375
+ "ok": False,
376
+ "error": "mutating tool blocked for read-only request",
377
+ "blocked": True,
378
+ "tool": tool_call.get("tool"),
379
+ }
380
+ tui.print_tool_result(tool_result, clip)
381
+ messages.append(
382
+ {
383
+ "role": "user",
384
+ "content": (
385
+ "TOOL_RESULT:\n"
386
+ f"{json.dumps(tool_result)}\n\n"
387
+ "The user's current request is read-only. "
388
+ "Do not call mutating tools. "
389
+ "Use read-only tools or answer directly."
390
+ ),
391
+ }
392
+ )
393
+ continue
394
+
395
+ precheck_result = preflight_tool_check(tool_call)
396
+ if precheck_result is not None:
397
+ tool_result = precheck_result
398
+ else:
399
+ needs_confirm, confirm_prompt = tui.requires_confirmation(tool_call)
400
+ if needs_confirm and not tui.ask_confirmation(confirm_prompt, tool_call):
401
+ tool_result = {
402
+ "ok": False,
403
+ "error": "tool execution cancelled by user confirmation",
404
+ "cancelled": True,
405
+ "tool": tool_call.get("tool"),
406
+ }
407
+ tui.print_tool_result(tool_result, clip)
408
+ messages.append(
409
+ {
410
+ "role": "user",
411
+ "content": (
412
+ "TOOL_RESULT:\n"
413
+ f"{json.dumps(tool_result)}\n\n"
414
+ "The user denied this tool execution. "
415
+ "Do not retry the same mutating tool unless the user explicitly requests it. "
416
+ "Continue with safe read-only tools or provide a plain-English response."
417
+ ),
418
+ }
419
+ )
420
+ print(tui.c("Tell Baxter what to do differently", tui.GREEN))
421
+ break
422
+ else:
423
+ tool_result = run_tool(tool_call["tool"], tool_call["args"])
424
+ if (
425
+ tool_call.get("tool") == "apply_diff"
426
+ and tool_result.get("ok")
427
+ and isinstance(tool_result.get("diff"), str)
428
+ ):
429
+ session["last_diff"] = tool_result.get("diff")
430
+
431
+ tui.print_tool_result(tool_result, clip)
432
+ messages.append(
433
+ {
434
+ "role": "user",
435
+ "content": (
436
+ "TOOL_RESULT:\n"
437
+ f"{json.dumps(tool_result)}\n\n"
438
+ "You are still working on the user's current request. "
439
+ "If the request is not fully completed yet, call the next required tool now. "
440
+ "Do not stop after read/search/list tools when an edit was requested. "
441
+ "For git requests, execute git steps yourself with tools instead of asking the user to run commands. "
442
+ "Only claim edits when apply_diff/write_file/delete_path succeeded with ok=true. "
443
+ "If blocked, explain briefly and ask one concise follow-up."
444
+ ),
445
+ }
446
+ )
447
+
448
+
449
+ if __name__ == "__main__":
450
+ main()