wrencode 0.1.4.5__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.5
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
@@ -53,6 +53,19 @@ import urllib.error
53
53
  import urllib.request
54
54
  from typing import Any, Callable, Optional
55
55
 
56
+ # The PyInstaller single-file binary ships no system CA trust store, so urllib's
57
+ # TLS verification fails out of the box ("CERTIFICATE_VERIFY_FAILED") on a clean
58
+ # machine. If certifi is bundled (it is in the binary build), point the SSL
59
+ # defaults at it before any request. Soft import keeps pip/uvx installs
60
+ # dependency-free; setdefault preserves any user-provided override.
61
+ try:
62
+ import certifi
63
+
64
+ os.environ.setdefault("SSL_CERT_FILE", certifi.where())
65
+ os.environ.setdefault("REQUESTS_CA_BUNDLE", certifi.where())
66
+ except ImportError:
67
+ pass
68
+
56
69
  # Load .env (next to this script, then the current directory); real env vars win.
57
70
  for _dir in (os.path.dirname(os.path.abspath(__file__)), os.getcwd()):
58
71
  _env_path = os.path.join(_dir, ".env")
@@ -74,7 +87,7 @@ for _dir in (os.path.dirname(os.path.abspath(__file__)), os.getcwd()):
74
87
  # -----------------------------------------------------------------------------------------------
75
88
  # Backend configuration
76
89
  # -----------------------------------------------------------------------------------------------
77
- WRENCODE_VERSION = "0.1.4.5"
90
+ WRENCODE_VERSION = "0.1.4.7"
78
91
 
79
92
  # Per-backend defaults. "kind" controls how a backend is treated:
80
93
  # api - hosted HTTP API, needs an API key
@@ -267,6 +280,7 @@ def _loader_context_text() -> str:
267
280
 
268
281
 
269
282
  def loader_display(step: int) -> str:
283
+ """Render one animated loader frame for the given step."""
270
284
  sym = _COMPOSE_FRAMES[step % len(_COMPOSE_FRAMES)]
271
285
  text = _loader_context_text()
272
286
  if not colors_enabled():
@@ -275,7 +289,7 @@ def loader_display(step: int) -> str:
275
289
 
276
290
 
277
291
  def colors_enabled() -> bool:
278
- """True when ANSI styling should be applied."""
292
+ """Return True when ANSI styling should be applied."""
279
293
  if os.environ.get("NO_COLOR") is not None:
280
294
  return False
281
295
  if os.environ.get("FORCE_COLOR"):
@@ -1280,6 +1294,7 @@ def _openai_headers() -> dict[str, str]:
1280
1294
 
1281
1295
  def _warn_if_truncated(data: dict[str, Any]) -> None:
1282
1296
  """Print a stderr warning if the API truncated the response at max_tokens.
1297
+
1283
1298
  A truncated `tool_use` may be missing required fields (e.g. `content` for
1284
1299
  `write`), so callers need to know to raise MAX_TOKENS rather than silently
1285
1300
  treating an empty operation as success.
@@ -1302,6 +1317,7 @@ def _warn_if_truncated(data: dict[str, Any]) -> None:
1302
1317
 
1303
1318
  def _log_usage_debug(data: dict[str, Any]) -> None:
1304
1319
  """When WRENCODE_DEBUG=1, print one stderr line per turn with token usage.
1320
+
1305
1321
  Surfaces cache_creation_input_tokens / cache_read_input_tokens so prompt-
1306
1322
  cache hit rate is observable without external tooling.
1307
1323
  """
@@ -1347,6 +1363,8 @@ def _parse_native_response(data: dict[str, Any]) -> tuple[str, list["ToolCall"]]
1347
1363
 
1348
1364
  @dataclass
1349
1365
  class ToolCall:
1366
+ """A single tool invocation parsed from a model response."""
1367
+
1350
1368
  id: str
1351
1369
  name: str
1352
1370
  input: dict[str, Any]
@@ -2026,6 +2044,39 @@ def persist_backend_choice(cfg: dict[str, str]) -> None:
2026
2044
  )
2027
2045
 
2028
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
+
2029
2080
  def pick_model_interactive(backend: str) -> Optional[str]:
2030
2081
  """Arrow-key model picker for a backend; returns model id or None."""
2031
2082
  models = list_models_for_backend(backend)
@@ -2140,21 +2191,36 @@ def choose_backend_interactive() -> None:
2140
2191
  raise SystemExit(1)
2141
2192
 
2142
2193
  cfg: dict[str, str] = {"backend": choice, "model": model}
2194
+ spec = BACKEND_SPECS[choice]
2143
2195
  _prompt_api_key_if_needed(choice, existing, cfg)
2144
2196
  persist_backend_choice(cfg)
2145
2197
  print_system(f"✓ Saved backend choice to {CONFIG_FILE}")
2146
- print()
2147
-
2148
2198
 
2149
- 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:
2150
2223
  """Decide which backend to use: env override > saved config > interactive > error."""
2151
- if force_chooser:
2152
- if not sys.stdin.isatty():
2153
- print(f"{RED}--configure needs an interactive terminal.{RESET}")
2154
- raise SystemExit(1)
2155
- choose_backend_interactive()
2156
- return
2157
-
2158
2224
  # 1. Explicit BACKEND env var — power users / CI. Unchanged from prior behaviour.
2159
2225
  env_backend = os.environ.get("BACKEND")
2160
2226
  if env_backend:
@@ -2170,9 +2236,26 @@ def resolve_configuration(force_chooser: bool = False) -> None:
2170
2236
  # 2. A choice saved from a previous run.
2171
2237
  cfg = load_config()
2172
2238
  if cfg.get("backend") in BACKEND_SPECS:
2173
- apply_backend(
2174
- 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")
2175
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", ""))
2176
2259
  return
2177
2260
 
2178
2261
  # 3. First run with a real terminal — ask the user.
@@ -2184,7 +2267,7 @@ def resolve_configuration(force_chooser: bool = False) -> None:
2184
2267
  print(f"{RED}No backend configured.{RESET}")
2185
2268
  print(
2186
2269
  "Set BACKEND=<name> (plus the matching API key), "
2187
- "or run `wrencode --configure` in a terminal."
2270
+ "or run `wrencode` in a terminal to choose one."
2188
2271
  )
2189
2272
  raise SystemExit(1)
2190
2273
 
@@ -2203,7 +2286,7 @@ def load_model() -> Optional[tuple[Any, Any]]:
2203
2286
  except ImportError:
2204
2287
  print(f"{RED}MLX backend needs mlx-lm:{RESET} pip install mlx-lm")
2205
2288
  print(
2206
- f"{DIM}Or run `wrencode --configure` to pick a hosted backend.{RESET}"
2289
+ f"{DIM}Or run `wrencode` to pick a hosted backend.{RESET}"
2207
2290
  )
2208
2291
  raise SystemExit(1)
2209
2292
  print(f"{YELLOW}Loading model...{RESET}")
@@ -2225,7 +2308,7 @@ def load_model() -> Optional[tuple[Any, Any]]:
2225
2308
  "pip install transformers torch"
2226
2309
  )
2227
2310
  print(
2228
- f"{DIM}Or run `wrencode --configure` to pick a hosted backend.{RESET}"
2311
+ f"{DIM}Or run `wrencode` to pick a hosted backend.{RESET}"
2229
2312
  )
2230
2313
  raise SystemExit(1)
2231
2314
  print(f"{YELLOW}Loading model via transformers...{RESET}")
@@ -2270,7 +2353,7 @@ def load_model() -> Optional[tuple[Any, Any]]:
2270
2353
  key_env = BACKEND_SPECS[BACKEND]["key_env"]
2271
2354
  print(f"{RED}{key_env} not set.{RESET}")
2272
2355
  print(
2273
- 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}"
2274
2357
  )
2275
2358
  raise SystemExit(1)
2276
2359
  print_system(f"{BACKEND} ({MODEL})")
@@ -2280,15 +2363,36 @@ def load_model() -> Optional[tuple[Any, Any]]:
2280
2363
  # -----------------------------------------------------------------------------------------------
2281
2364
  # Entrypoint
2282
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
+
2283
2387
  def print_help() -> None:
2284
2388
  """Print CLI usage."""
2285
2389
  print("wrencode — a minimal agentic coding assistant\n")
2286
2390
  print("Usage: wrencode [options]\n")
2287
2391
  print("Options:")
2288
- print(" --configure (re)choose backend and model (↑↓ picker)")
2289
- print(" --yes auto-approve all writes/commands (WRENCODE_AUTO_APPROVE)")
2290
- print(" --version, -V print version and exit")
2291
- 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")
2292
2396
  print("Slash commands: /backend /model /c /compact /q (see /help in session)")
2293
2397
  print(
2294
2398
  "Environment overrides: BACKEND, MODEL, and the backend's API key "
@@ -2310,14 +2414,16 @@ def main() -> None:
2310
2414
  if "--version" in args or "-V" in args:
2311
2415
  print(f"wrencode {WRENCODE_VERSION}")
2312
2416
  return
2313
- 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
2314
2420
  if "--yes" in args or "--auto-approve" in args:
2315
2421
  os.environ["WRENCODE_AUTO_APPROVE"] = "1"
2316
2422
 
2317
2423
  os.environ.setdefault(
2318
2424
  "WRENCODE_WORKSPACE", str(pathlib.Path.cwd().resolve())
2319
2425
  )
2320
- resolve_configuration(force_chooser=force)
2426
+ resolve_configuration()
2321
2427
 
2322
2428
  sys.stdout.write("\033]0;wrencode\007") # set terminal tab/window title
2323
2429
  print(WREN_BANNER)
File without changes
File without changes
File without changes