wrencode 0.1.4.6__tar.gz → 0.1.4.7__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wrencode
3
- Version: 0.1.4.6
3
+ Version: 0.1.4.7
4
4
  Summary: A minimal agentic coding assistant in a single Python file
5
5
  Project-URL: Homepage, https://github.com/almostly/wrencode
6
6
  Project-URL: Repository, https://github.com/almostly/wrencode
@@ -87,7 +87,7 @@ for _dir in (os.path.dirname(os.path.abspath(__file__)), os.getcwd()):
87
87
  # -----------------------------------------------------------------------------------------------
88
88
  # Backend configuration
89
89
  # -----------------------------------------------------------------------------------------------
90
- WRENCODE_VERSION = "0.1.4.6"
90
+ WRENCODE_VERSION = "0.1.4.7"
91
91
 
92
92
  # Per-backend defaults. "kind" controls how a backend is treated:
93
93
  # api - hosted HTTP API, needs an API key
@@ -280,6 +280,7 @@ def _loader_context_text() -> str:
280
280
 
281
281
 
282
282
  def loader_display(step: int) -> str:
283
+ """Render one animated loader frame for the given step."""
283
284
  sym = _COMPOSE_FRAMES[step % len(_COMPOSE_FRAMES)]
284
285
  text = _loader_context_text()
285
286
  if not colors_enabled():
@@ -288,7 +289,7 @@ def loader_display(step: int) -> str:
288
289
 
289
290
 
290
291
  def colors_enabled() -> bool:
291
- """True when ANSI styling should be applied."""
292
+ """Return True when ANSI styling should be applied."""
292
293
  if os.environ.get("NO_COLOR") is not None:
293
294
  return False
294
295
  if os.environ.get("FORCE_COLOR"):
@@ -1293,6 +1294,7 @@ def _openai_headers() -> dict[str, str]:
1293
1294
 
1294
1295
  def _warn_if_truncated(data: dict[str, Any]) -> None:
1295
1296
  """Print a stderr warning if the API truncated the response at max_tokens.
1297
+
1296
1298
  A truncated `tool_use` may be missing required fields (e.g. `content` for
1297
1299
  `write`), so callers need to know to raise MAX_TOKENS rather than silently
1298
1300
  treating an empty operation as success.
@@ -1315,6 +1317,7 @@ def _warn_if_truncated(data: dict[str, Any]) -> None:
1315
1317
 
1316
1318
  def _log_usage_debug(data: dict[str, Any]) -> None:
1317
1319
  """When WRENCODE_DEBUG=1, print one stderr line per turn with token usage.
1320
+
1318
1321
  Surfaces cache_creation_input_tokens / cache_read_input_tokens so prompt-
1319
1322
  cache hit rate is observable without external tooling.
1320
1323
  """
@@ -1360,6 +1363,8 @@ def _parse_native_response(data: dict[str, Any]) -> tuple[str, list["ToolCall"]]
1360
1363
 
1361
1364
  @dataclass
1362
1365
  class ToolCall:
1366
+ """A single tool invocation parsed from a model response."""
1367
+
1363
1368
  id: str
1364
1369
  name: str
1365
1370
  input: dict[str, Any]
@@ -2039,6 +2044,39 @@ def persist_backend_choice(cfg: dict[str, str]) -> None:
2039
2044
  )
2040
2045
 
2041
2046
 
2047
+ def verify_api_key() -> tuple[str, str]:
2048
+ """Probe the current backend with API_KEY to see if the key actually works.
2049
+
2050
+ Returns (state, detail): "ok" (accepted), "invalid" (rejected by the
2051
+ service), or "unknown" (couldn't reach it — network/timeout). Local
2052
+ backends and missing keys short-circuit to "ok"/"invalid" without a call.
2053
+ """
2054
+ spec = BACKEND_SPECS[BACKEND]
2055
+ if spec["kind"] != "api":
2056
+ return ("ok", "")
2057
+ if not API_KEY:
2058
+ return ("invalid", "no key")
2059
+ probes = {
2060
+ "anthropic": ("https://api.anthropic.com/v1/models", _anthropic_headers()),
2061
+ "openai": ("https://api.openai.com/v1/models", _openai_headers()),
2062
+ "openrouter": ("https://openrouter.ai/api/v1/key", _openai_headers()),
2063
+ }
2064
+ if BACKEND not in probes:
2065
+ return ("unknown", "")
2066
+ url, headers = probes[BACKEND]
2067
+ req = urllib.request.Request(url, headers=headers, method="GET")
2068
+ try:
2069
+ with urllib.request.urlopen(req, timeout=10) as resp:
2070
+ resp.read(1)
2071
+ return ("ok", "")
2072
+ except urllib.error.HTTPError as err:
2073
+ if err.code in (401, 403):
2074
+ return ("invalid", f"HTTP {err.code}")
2075
+ return ("unknown", f"HTTP {err.code}")
2076
+ except Exception as err:
2077
+ return ("unknown", str(err))
2078
+
2079
+
2042
2080
  def pick_model_interactive(backend: str) -> Optional[str]:
2043
2081
  """Arrow-key model picker for a backend; returns model id or None."""
2044
2082
  models = list_models_for_backend(backend)
@@ -2153,21 +2191,36 @@ def choose_backend_interactive() -> None:
2153
2191
  raise SystemExit(1)
2154
2192
 
2155
2193
  cfg: dict[str, str] = {"backend": choice, "model": model}
2194
+ spec = BACKEND_SPECS[choice]
2156
2195
  _prompt_api_key_if_needed(choice, existing, cfg)
2157
2196
  persist_backend_choice(cfg)
2158
2197
  print_system(f"✓ Saved backend choice to {CONFIG_FILE}")
2159
- print()
2160
-
2161
2198
 
2162
- def resolve_configuration(force_chooser: bool = False) -> None:
2199
+ # Verify the key actually works before declaring the backend ready, so a
2200
+ # typo or revoked key surfaces here instead of mid-chat. Re-prompt on a
2201
+ # hard rejection; don't block on a transient network failure.
2202
+ for attempt in range(3):
2203
+ state, detail = verify_api_key()
2204
+ if state == "ok":
2205
+ print_system(f"✓ {choice}:{MODEL} ready")
2206
+ return
2207
+ if state == "unknown":
2208
+ print(f"{YELLOW}⚠ Couldn't verify {spec['key_env']} ({detail}).{RESET}")
2209
+ return
2210
+ print(f"{RED}✗ {spec['key_env']} was rejected ({detail}).{RESET}")
2211
+ if not sys.stdin.isatty() or attempt == 2:
2212
+ return
2213
+ newkey = getpass.getpass(
2214
+ f"{BLUE}❯{RESET} re-enter {spec['key_env']} (input hidden): "
2215
+ ).strip()
2216
+ if not newkey:
2217
+ return
2218
+ cfg["api_key"] = newkey
2219
+ persist_backend_choice(cfg)
2220
+
2221
+
2222
+ def resolve_configuration() -> None:
2163
2223
  """Decide which backend to use: env override > saved config > interactive > error."""
2164
- if force_chooser:
2165
- if not sys.stdin.isatty():
2166
- print(f"{RED}--configure needs an interactive terminal.{RESET}")
2167
- raise SystemExit(1)
2168
- choose_backend_interactive()
2169
- return
2170
-
2171
2224
  # 1. Explicit BACKEND env var — power users / CI. Unchanged from prior behaviour.
2172
2225
  env_backend = os.environ.get("BACKEND")
2173
2226
  if env_backend:
@@ -2183,9 +2236,26 @@ def resolve_configuration(force_chooser: bool = False) -> None:
2183
2236
  # 2. A choice saved from a previous run.
2184
2237
  cfg = load_config()
2185
2238
  if cfg.get("backend") in BACKEND_SPECS:
2186
- apply_backend(
2187
- cfg["backend"], cfg.get("model", ""), cfg.get("api_key", "")
2239
+ backend = cfg["backend"]
2240
+ spec = BACKEND_SPECS[backend]
2241
+ # A hosted backend with no usable key — env var unset and nothing
2242
+ # saved (e.g. configured in a dir whose .env supplied the key, so it
2243
+ # was never persisted) — would dead-end in load_model() with a
2244
+ # SystemExit. In a terminal, re-run the chooser so the user can pick a
2245
+ # backend and enter a key instead of the tool exiting immediately.
2246
+ key_missing = (
2247
+ spec["kind"] == "api"
2248
+ and not os.environ.get(spec["key_env"])
2249
+ and not cfg.get("api_key")
2188
2250
  )
2251
+ if key_missing and sys.stdin.isatty():
2252
+ print(
2253
+ f"{YELLOW}Saved backend '{backend}' has no API key "
2254
+ f"({spec['key_env']} is unset and none was saved).{RESET}"
2255
+ )
2256
+ choose_backend_interactive()
2257
+ return
2258
+ apply_backend(backend, cfg.get("model", ""), cfg.get("api_key", ""))
2189
2259
  return
2190
2260
 
2191
2261
  # 3. First run with a real terminal — ask the user.
@@ -2197,7 +2267,7 @@ def resolve_configuration(force_chooser: bool = False) -> None:
2197
2267
  print(f"{RED}No backend configured.{RESET}")
2198
2268
  print(
2199
2269
  "Set BACKEND=<name> (plus the matching API key), "
2200
- "or run `wrencode --configure` in a terminal."
2270
+ "or run `wrencode` in a terminal to choose one."
2201
2271
  )
2202
2272
  raise SystemExit(1)
2203
2273
 
@@ -2216,7 +2286,7 @@ def load_model() -> Optional[tuple[Any, Any]]:
2216
2286
  except ImportError:
2217
2287
  print(f"{RED}MLX backend needs mlx-lm:{RESET} pip install mlx-lm")
2218
2288
  print(
2219
- f"{DIM}Or run `wrencode --configure` to pick a hosted backend.{RESET}"
2289
+ f"{DIM}Or run `wrencode` to pick a hosted backend.{RESET}"
2220
2290
  )
2221
2291
  raise SystemExit(1)
2222
2292
  print(f"{YELLOW}Loading model...{RESET}")
@@ -2238,7 +2308,7 @@ def load_model() -> Optional[tuple[Any, Any]]:
2238
2308
  "pip install transformers torch"
2239
2309
  )
2240
2310
  print(
2241
- f"{DIM}Or run `wrencode --configure` to pick a hosted backend.{RESET}"
2311
+ f"{DIM}Or run `wrencode` to pick a hosted backend.{RESET}"
2242
2312
  )
2243
2313
  raise SystemExit(1)
2244
2314
  print(f"{YELLOW}Loading model via transformers...{RESET}")
@@ -2283,7 +2353,7 @@ def load_model() -> Optional[tuple[Any, Any]]:
2283
2353
  key_env = BACKEND_SPECS[BACKEND]["key_env"]
2284
2354
  print(f"{RED}{key_env} not set.{RESET}")
2285
2355
  print(
2286
- f"{DIM}Set {key_env}, or run `wrencode --configure` to re-enter it.{RESET}"
2356
+ f"{DIM}Set {key_env}, or run `wrencode` in a terminal to enter a key.{RESET}"
2287
2357
  )
2288
2358
  raise SystemExit(1)
2289
2359
  print_system(f"{BACKEND} ({MODEL})")
@@ -2293,15 +2363,36 @@ def load_model() -> Optional[tuple[Any, Any]]:
2293
2363
  # -----------------------------------------------------------------------------------------------
2294
2364
  # Entrypoint
2295
2365
  # -----------------------------------------------------------------------------------------------
2366
+ def uninstall() -> None:
2367
+ """Delete saved state and print how to remove the program for this install."""
2368
+ if CONFIG_DIR.exists():
2369
+ try:
2370
+ shutil.rmtree(CONFIG_DIR)
2371
+ print_system(f"✓ Removed saved config {CONFIG_DIR}")
2372
+ except OSError as err:
2373
+ print(f"{YELLOW}Could not remove {CONFIG_DIR}: {err}{RESET}")
2374
+ else:
2375
+ print_system(f"No saved config at {CONFIG_DIR}")
2376
+
2377
+ print_system("To remove the program itself:")
2378
+ if is_frozen():
2379
+ # install.sh drops a standalone binary; sys.executable is that file.
2380
+ print(f"rm {sys.executable}")
2381
+ else:
2382
+ print(f"{DIM}uv tool install:{RESET} uv tool uninstall wrencode")
2383
+ print(f"{DIM}pip install:{RESET} pip uninstall wrencode")
2384
+ print(f"{DIM}uvx cache:{RESET} uv cache clean wrencode")
2385
+
2386
+
2296
2387
  def print_help() -> None:
2297
2388
  """Print CLI usage."""
2298
2389
  print("wrencode — a minimal agentic coding assistant\n")
2299
2390
  print("Usage: wrencode [options]\n")
2300
2391
  print("Options:")
2301
- print(" --configure (re)choose backend and model (↑↓ picker)")
2302
- print(" --yes auto-approve all writes/commands (WRENCODE_AUTO_APPROVE)")
2303
- print(" --version, -V print version and exit")
2304
- print(" --help, -h show this help\n")
2392
+ print("--yes auto-approve all writes/commands (WRENCODE_AUTO_APPROVE)")
2393
+ print("--uninstall remove saved config and show how to delete wrencode")
2394
+ print("--version, -V print version and exit")
2395
+ print("--help, -h show this help\n")
2305
2396
  print("Slash commands: /backend /model /c /compact /q (see /help in session)")
2306
2397
  print(
2307
2398
  "Environment overrides: BACKEND, MODEL, and the backend's API key "
@@ -2323,14 +2414,16 @@ def main() -> None:
2323
2414
  if "--version" in args or "-V" in args:
2324
2415
  print(f"wrencode {WRENCODE_VERSION}")
2325
2416
  return
2326
- force = "--configure" in args or (bool(args) and args[0] == "configure")
2417
+ if "--uninstall" in args or (bool(args) and args[0] == "uninstall"):
2418
+ uninstall()
2419
+ return
2327
2420
  if "--yes" in args or "--auto-approve" in args:
2328
2421
  os.environ["WRENCODE_AUTO_APPROVE"] = "1"
2329
2422
 
2330
2423
  os.environ.setdefault(
2331
2424
  "WRENCODE_WORKSPACE", str(pathlib.Path.cwd().resolve())
2332
2425
  )
2333
- resolve_configuration(force_chooser=force)
2426
+ resolve_configuration()
2334
2427
 
2335
2428
  sys.stdout.write("\033]0;wrencode\007") # set terminal tab/window title
2336
2429
  print(WREN_BANNER)
File without changes
File without changes
File without changes