meshapi-code 0.4.2__tar.gz → 0.4.4__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.
- {meshapi_code-0.4.2 → meshapi_code-0.4.4}/.gitignore +5 -0
- meshapi_code-0.4.4/CLAUDE.md +126 -0
- {meshapi_code-0.4.2 → meshapi_code-0.4.4}/PKG-INFO +1 -1
- {meshapi_code-0.4.2 → meshapi_code-0.4.4}/pyproject.toml +1 -1
- meshapi_code-0.4.4/src/meshapi/__init__.py +1 -0
- {meshapi_code-0.4.2 → meshapi_code-0.4.4}/src/meshapi/attachments.py +53 -22
- {meshapi_code-0.4.2 → meshapi_code-0.4.4}/src/meshapi/cli.py +268 -57
- {meshapi_code-0.4.2 → meshapi_code-0.4.4}/src/meshapi/commands.py +32 -7
- {meshapi_code-0.4.2 → meshapi_code-0.4.4}/src/meshapi/config.py +50 -0
- meshapi_code-0.4.4/src/meshapi/permissions.py +70 -0
- {meshapi_code-0.4.2 → meshapi_code-0.4.4}/src/meshapi/plan.py +21 -0
- meshapi_code-0.4.4/src/meshapi/safety.py +261 -0
- meshapi_code-0.4.4/src/meshapi/statusbar.py +114 -0
- {meshapi_code-0.4.2 → meshapi_code-0.4.4}/src/meshapi/tools.py +27 -10
- meshapi_code-0.4.2/CLAUDE.md +0 -69
- meshapi_code-0.4.2/src/meshapi/__init__.py +0 -1
- meshapi_code-0.4.2/src/meshapi/permissions.py +0 -35
- meshapi_code-0.4.2/src/meshapi/statusbar.py +0 -37
- {meshapi_code-0.4.2 → meshapi_code-0.4.4}/.github/workflows/publish.yml +0 -0
- {meshapi_code-0.4.2 → meshapi_code-0.4.4}/LICENSE +0 -0
- {meshapi_code-0.4.2 → meshapi_code-0.4.4}/NOTICE +0 -0
- {meshapi_code-0.4.2 → meshapi_code-0.4.4}/README.md +0 -0
- {meshapi_code-0.4.2 → meshapi_code-0.4.4}/src/meshapi/__main__.py +0 -0
- {meshapi_code-0.4.2 → meshapi_code-0.4.4}/src/meshapi/client.py +0 -0
- {meshapi_code-0.4.2 → meshapi_code-0.4.4}/src/meshapi/keywatcher.py +0 -0
- {meshapi_code-0.4.2 → meshapi_code-0.4.4}/src/meshapi/render.py +0 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# meshapi-code — Claude Context
|
|
2
|
+
|
|
3
|
+
Terminal chat REPL for [Mesh API](https://meshapi.ai), the OpenAI-compatible LLM gateway. Modeled on Claude Code and Aider. It is now an **agentic** CLI: it does tool calling (file read/write, shell, background servers, plans), image attachments, and permission modes — not just chat.
|
|
4
|
+
|
|
5
|
+
PyPI package = `meshapi-code`. Command on `$PATH` = `meshapi` (same split Claude Code uses: package `@anthropic-ai/claude-code`, command `claude`).
|
|
6
|
+
|
|
7
|
+
## Commands
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pipx install -e . # local dev install (or: uv tool install -e .)
|
|
11
|
+
meshapi # launch REPL
|
|
12
|
+
meshapi --version
|
|
13
|
+
python -m build # build wheel + sdist for PyPI
|
|
14
|
+
twine check dist/* # validate before upload
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
To run the working tree without reinstalling: `PYTHONPATH=src python -m meshapi`.
|
|
18
|
+
|
|
19
|
+
## Env Vars
|
|
20
|
+
|
|
21
|
+
| Var | Purpose |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `MESHAPI_API_KEY` | Mesh API data-plane key (`rsk_…`). Falls back to `MESH_API_KEY` for one release. |
|
|
24
|
+
| `MESHAPI_BASE_URL` | Override gateway URL. Default `https://api.meshapi.ai/v1`. |
|
|
25
|
+
|
|
26
|
+
State under `~/.meshapi/`: `config.json` (settings, never the API key), `history` (input history, scrubbed + 0600), `servers.json` (backgrounded server records for crash-recovery). All written 0600.
|
|
27
|
+
|
|
28
|
+
## Architecture
|
|
29
|
+
|
|
30
|
+
Single-process REPL → stream `/v1/chat/completions` (SSE, OpenAI-compatible) → `rich.live.Live` markdown render → if the model returned `tool_calls`, run the agentic loop → loop back to the prompt.
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
src/meshapi/
|
|
34
|
+
cli.py # argparse + REPL loop, agentic tool-call loop, cost line, server lifecycle
|
|
35
|
+
client.py # stream_chat — yields content deltas + tool_calls + final {usage, cost} dict
|
|
36
|
+
commands.py # slash command handlers (/model, /route, /file, /image, /mode, /cost, ...)
|
|
37
|
+
config.py # ~/.meshapi/ load/save (config, history, servers.json), env override, 0600
|
|
38
|
+
tools.py # TOOLS schema, build_system_prompt, execute(), summarize_call, PLAN_TOOLS
|
|
39
|
+
permissions.py # Mode enum, AUTO_APPROVE sets, ORDER, next_mode, LABELS, SHOW_ESC_HINT
|
|
40
|
+
safety.py # auto-approval guardrails (path denylist, cwd-scope, bad commands, SSRF)
|
|
41
|
+
attachments.py # image load → base64 data URL; quote-aware auto-detect of image paths/URLs
|
|
42
|
+
statusbar.py # mode indicator: bottom_toolbar (live) + print_line (scrollback)
|
|
43
|
+
keywatcher.py # daemon thread: shift+tab (CSI Z) while prompt_toolkit isn't reading stdin
|
|
44
|
+
plan.py # plan state model for create_plan / update_step
|
|
45
|
+
render.py # rich Console singleton, render_stream, fmt_usd
|
|
46
|
+
__main__.py # python -m meshapi
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Agentic tool-calling loop
|
|
50
|
+
|
|
51
|
+
`handle_tool_calls` (cli.py) appends the assistant `tool_calls` message + one `tool` result message per call, then the turn loops: re-stream, run any new tool calls, repeat until the model stops calling tools or we hit the hop cap (`MAX_HOPS_NO_PLAN`, raised to `MAX_HOPS_WITH_PLAN` once a plan exists).
|
|
52
|
+
|
|
53
|
+
Tools (`tools.py` `TOOLS`): `write_file`, `read_file`, `run_bash`, `start_server`, and the two **plan** tools `create_plan` / `update_step` (`PLAN_TOOLS` — pure bookkeeping, no side effects, never gated). `read_file` refuses image files and tells the model to ask the user to attach them (the CLI auto-attaches — see below).
|
|
54
|
+
|
|
55
|
+
`start_server` runs a long-lived process in the background, waits for readiness, and prints the URL. Server records persist to `servers.json`; `_shutdown_servers` (atexit + SIGTERM/SIGHUP handlers) kills them on exit, and `_adopt_orphaned_servers` offers to clean up survivors of a hard kill on next launch.
|
|
56
|
+
|
|
57
|
+
## Permission modes & shift+tab
|
|
58
|
+
|
|
59
|
+
`permissions.Mode`: `DEFAULT` (ask every tool) → `ACCEPT_EDITS` (auto write_file) → `AUTO` (+ run_bash) → `BYPASS` (+ read_file, start_server). `AUTO_APPROVE[mode]` is the set of tool names that skip the y/n confirm. shift+tab cycles via `next_mode`.
|
|
60
|
+
|
|
61
|
+
- **At the prompt:** the `@kb.add("s-tab")` binding cycles the mode and calls `event.app.invalidate()`.
|
|
62
|
+
- **During streaming / tool execution:** `keywatcher.KeyWatcher` reads stdin in cbreak mode and fires the same cycle. It `paused()`s around `session.prompt(...)` so prompt_toolkit owns the termios state cleanly.
|
|
63
|
+
|
|
64
|
+
The mode indicator is a prompt_toolkit **`bottom_toolbar`** (`statusbar.bottom_toolbar`), NOT a scrollback line — that's what makes it update **live** on shift+tab (the toolbar is re-evaluated on every `invalidate()`). It's right-aligned, degrades on narrow terminals (drops the esc hint, then the cycle hint), has a trailing pad line, and uses `noreverse` to kill prompt_toolkit's default inverted bar. `statusbar.print_line` still prints a one-shot scrollback line once per tool batch (when no prompt/toolbar is active). Don't move the indicator back to a pre-prompt scrollback print — it can't repaint on keypress and the toggle appears frozen.
|
|
65
|
+
|
|
66
|
+
## Safety guardrails (`safety.py`)
|
|
67
|
+
|
|
68
|
+
Auto-approval is gated by safety checks; a failing check **never hard-denies** — it downgrades to the y/n confirm (the user is the source of truth) and prints `⚠ auto-approval blocked: <reason>`.
|
|
69
|
+
|
|
70
|
+
- `is_path_safe_for_auto_write` — denylist (`~/.ssh`, `~/.aws`, `~/.meshapi`, `/etc`, `*.pem`, … — blocks **even under BYPASS**) + cwd-scope for `AUTO`/`ACCEPT_EDITS`. Resolves symlinks first.
|
|
71
|
+
- `is_path_safe_for_auto_read` — same denylist, no cwd-scope (reading outside cwd is usually legit; denylist still bites so secrets don't leak to the provider).
|
|
72
|
+
- `is_command_safe_for_auto` — blocks destructive/exfil shapes for `AUTO`/`BYPASS` (`rm -rf`, `sudo`, `curl|sh`, fork bomb, `dd`, raw-device writes, reading `/etc/passwd`, …).
|
|
73
|
+
- `is_url_safe_for_fetch` — SSRF guard for `/image` URL fetch; re-resolves DNS and rejects loopback/private/link-local/reserved/multicast.
|
|
74
|
+
- `SESSION_IMAGE_BYTE_CAP` (100 MB) — cumulative attachment budget per session; per-image hard limit is `attachments.HARD_LIMIT_BYTES` (20 MB).
|
|
75
|
+
|
|
76
|
+
## Image attachments (`attachments.py`)
|
|
77
|
+
|
|
78
|
+
`load_image` always base64-encodes into a `data:image/...;base64,...` URL (Mesh docs warn some providers reject public URLs). Surfaced explicitly via `/image`, and **auto-detected** in any prompt: `find_image_tokens` scans for paths/URLs ending in a known image extension and attaches them, rewriting the token to `[Image #N]`.
|
|
79
|
+
|
|
80
|
+
The tokenizer is **quote-aware** (`_TOKEN_RE = '...' | "..." | \S+`) — it must keep quoted spans whole so drag-dropped paths **with spaces** (e.g. `'/Users/me/snake game/img.png'`) aren't shredded by whitespace splitting. A leading backtick (`` `foo.png` ``) is an explicit "treat as text" escape. Don't regress this back to `text.split()`.
|
|
81
|
+
|
|
82
|
+
## Mesh-specific conventions
|
|
83
|
+
|
|
84
|
+
- **Base URL:** `https://api.meshapi.ai/v1` (production).
|
|
85
|
+
- **Auth:** `Authorization: Bearer rsk_…` — `rsk_` is the data-plane key prefix.
|
|
86
|
+
- **Model format:** `provider/model-name` (e.g. `anthropic/claude-opus-4.8`, `openai/gpt-4o-mini`). See `meshapi-docs/fern/`.
|
|
87
|
+
- **Cost in stream:** the final SSE chunk includes a `cost` field (string USD) alongside `usage`. `client.stream_chat` captures it as the generator's last yield (a dict, not a string), which `render.render_stream` separates from content.
|
|
88
|
+
- **Routing:** request body accepts a `route` key (`cheapest`, `fastest`, `balanced`). Surfaced via `/route` — Mesh's wedge over generic OpenAI-compat CLIs.
|
|
89
|
+
|
|
90
|
+
## Reusable utilities
|
|
91
|
+
|
|
92
|
+
- `render.fmt_usd(value)` — port of `fmtUsd` from `../routersvc-client/src/lib/utils.ts`. **Always 6 decimals** with K/M abbreviations. Use this for every USD amount; never raw `f"{n:.2f}"`. Keeps CLI cost display identical to the dashboard.
|
|
93
|
+
|
|
94
|
+
## Slash commands
|
|
95
|
+
|
|
96
|
+
`/model` `/route` `/file` `/image` `/system` `/mode` `/cost` `/clear` `/help` `/exit` (`/quit`, `/q`).
|
|
97
|
+
|
|
98
|
+
## Distribution & release
|
|
99
|
+
|
|
100
|
+
- **Version lives in TWO places** — bump both: `pyproject.toml` `version` and `src/meshapi/__init__.py` `__version__`. Verify with `python -m meshapi --version`.
|
|
101
|
+
- **PyPI** (`meshapi-code`): `.github/workflows/publish.yml` builds + uploads on a **`v*` tag push** via [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (no token). A plain push to `main` does NOT publish. Trusted Publisher = `aifiesta/meshapi-code` repo + `publish.yml`.
|
|
102
|
+
- **Release flow:** commit to `main` → (only on explicit ship-it) `git tag -a vX.Y.Z -m "…" && git push origin vX.Y.Z` → watch with `gh run watch <id> --exit-status` → confirm at `https://pypi.org/pypi/meshapi-code/<version>/json` (the `/json` "latest" field is CDN-cached and lags a few minutes; the version-specific endpoint is authoritative).
|
|
103
|
+
- ⚠️ **Never auto-publish.** Stop at "ready to test" and wait for an explicit ship-it before tagging/pushing a `v*` tag. PyPI uploads of a given version are immutable — you can't re-upload `0.4.3`.
|
|
104
|
+
- **Install paths users use:** `pipx install meshapi-code`, `uv tool install meshapi-code`, `pip install meshapi-code`. Upgrade: `pipx upgrade meshapi-code`.
|
|
105
|
+
- **npm port** (`meshapi-code`): planned. Node rewrite using `ink` + `chalk`, same UX.
|
|
106
|
+
|
|
107
|
+
## Gotchas / hard-won learnings
|
|
108
|
+
|
|
109
|
+
- **`pipx` vs editable shadowing:** an activated `.build-venv` (`pip install -e .`) prepends its `bin/` to `$PATH`, so `meshapi` runs the editable working-tree copy and shadows the pipx-installed one. `pipx upgrade` still updates the pipx copy; it just won't be what `meshapi` resolves to until that venv is off PATH. Editable installs report the working-tree version live.
|
|
110
|
+
- **Testing prompt_toolkit in a pty:** it needs a terminal size or it can't render (toolbar/CPR). Set `TIOCSWINSZ` via `fcntl.ioctl` AND answer the `\x1b[6n` cursor-position query with `\x1b[<row>;<col>R`, or you'll see "terminal doesn't support CPR" and no toolbar. shift+tab to send is `\x1b[Z` (CSI Z).
|
|
111
|
+
- **No test suite** — verify changes by importing every module (`PYTHONPATH=src python -c "import meshapi.<mod>"`), unit-calling the pure functions (safety guards, `find_image_tokens`, `bottom_toolbar`), and a pty harness for the interactive bits.
|
|
112
|
+
|
|
113
|
+
## Testing the REPL end-to-end
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
MESHAPI_API_KEY=rsk_… meshapi
|
|
117
|
+
> hello # streamed markdown reply, then cost line
|
|
118
|
+
> /model openai/gpt-4o-mini # switch model mid-session
|
|
119
|
+
> /route cheapest # ask gateway to pick cheapest route
|
|
120
|
+
> /file ./pyproject.toml # inject file into context
|
|
121
|
+
> write a hello.py and run it # tool calling: write_file + run_bash
|
|
122
|
+
> [shift+tab] # cycle permission mode (toolbar updates live)
|
|
123
|
+
> describe '/path/with spaces/img.png' # auto-attaches the image (quote-aware)
|
|
124
|
+
> /cost # cumulative session spend
|
|
125
|
+
> /exit
|
|
126
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.4"
|
|
@@ -13,11 +13,19 @@ so a dragged-in file path can be attached without an explicit slash command.
|
|
|
13
13
|
"""
|
|
14
14
|
import base64
|
|
15
15
|
import mimetypes
|
|
16
|
+
import re
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
from urllib.parse import urlparse
|
|
18
19
|
|
|
19
20
|
import httpx
|
|
20
21
|
|
|
22
|
+
# Tokenizer for find_image_tokens: a single- or double-quoted span (kept
|
|
23
|
+
# whole, INCLUDING internal spaces) OR a run of non-whitespace. Quoted
|
|
24
|
+
# alternatives come first so a drag-dropped path like
|
|
25
|
+
# `'/Users/me/snake game/img.png'` stays one token instead of being shredded
|
|
26
|
+
# on the spaces by str.split().
|
|
27
|
+
_TOKEN_RE = re.compile(r"'[^']*'|\"[^\"]*\"|\S+")
|
|
28
|
+
|
|
21
29
|
# Size guardrails. We don't refuse — vision tokens are the user's call — but we
|
|
22
30
|
# do report sizes back so the user sees the cost.
|
|
23
31
|
HARD_LIMIT_BYTES = 20 * 1024 * 1024 # 20 MB
|
|
@@ -60,43 +68,58 @@ def load_image(source: str, detail: str = "auto") -> tuple[dict, dict]:
|
|
|
60
68
|
)
|
|
61
69
|
|
|
62
70
|
|
|
63
|
-
def find_image_tokens(text: str) -> list[str]:
|
|
64
|
-
"""Return
|
|
71
|
+
def find_image_tokens(text: str) -> list[tuple[str, str]]:
|
|
72
|
+
"""Return `(raw_token, normalized)` pairs for image references in `text`.
|
|
65
73
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
74
|
+
Strategy: be liberal about what looks like a file/URL, then verify by
|
|
75
|
+
actually checking existence (local) or extension (URL). The user's
|
|
76
|
+
natural workflow is "drag the file in" — terminals wrap drag-dropped
|
|
77
|
+
paths in single quotes when convenient, so we strip wrapping quotes.
|
|
78
|
+
A bare filename like `screenshot.png` also matches if it exists in the
|
|
79
|
+
cwd. The only escape is a backtick prefix: `` `foo.png` `` is treated
|
|
80
|
+
as text.
|
|
70
81
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
82
|
+
- http(s) URLs ending in a known image extension
|
|
83
|
+
- Any token that, after stripping wrapping quotes and trailing
|
|
84
|
+
punctuation, resolves to an existing file with an image extension
|
|
74
85
|
|
|
75
|
-
|
|
86
|
+
`raw_token` is the exact substring to find/replace in the original
|
|
87
|
+
text (so quotes are preserved when stripping); `normalized` is the
|
|
88
|
+
cleaned path or URL to pass into load_image().
|
|
76
89
|
"""
|
|
77
|
-
matches: list[str] = []
|
|
78
|
-
for raw in
|
|
79
|
-
if not raw
|
|
90
|
+
matches: list[tuple[str, str]] = []
|
|
91
|
+
for raw in _TOKEN_RE.findall(text):
|
|
92
|
+
if not raw:
|
|
93
|
+
continue
|
|
94
|
+
# Backtick prefix = explicit "treat as text" escape.
|
|
95
|
+
if raw.startswith("`"):
|
|
80
96
|
continue
|
|
97
|
+
|
|
81
98
|
token = raw
|
|
99
|
+
# Strip a matching wrapping pair of single or double quotes
|
|
100
|
+
# (drag-drop on macOS Terminal/iTerm2 quotes paths automatically).
|
|
101
|
+
if len(token) >= 2 and token[0] == token[-1] and token[0] in "'\"":
|
|
102
|
+
token = token[1:-1]
|
|
103
|
+
# Strip trailing sentence punctuation but leave URL query strings.
|
|
82
104
|
while token and token[-1] in ".,;:!?)":
|
|
83
105
|
token = token[:-1]
|
|
84
106
|
if not token:
|
|
85
107
|
continue
|
|
108
|
+
|
|
86
109
|
low = token.lower()
|
|
87
110
|
if low.startswith(("http://", "https://")):
|
|
88
111
|
path_part = token.split("?", 1)[0]
|
|
89
112
|
if path_part.lower().endswith(IMAGE_EXTS):
|
|
90
|
-
matches.append(token)
|
|
113
|
+
matches.append((raw, token))
|
|
91
114
|
continue
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
115
|
+
|
|
116
|
+
if low.endswith(IMAGE_EXTS):
|
|
117
|
+
try:
|
|
118
|
+
p = Path(token).expanduser()
|
|
119
|
+
if p.is_file():
|
|
120
|
+
matches.append((raw, token))
|
|
121
|
+
except (OSError, ValueError):
|
|
122
|
+
pass
|
|
100
123
|
return matches
|
|
101
124
|
|
|
102
125
|
|
|
@@ -109,6 +132,14 @@ def _looks_like_url(s: str) -> bool:
|
|
|
109
132
|
|
|
110
133
|
|
|
111
134
|
def _fetch_url(url: str) -> tuple[bytes, str, str]:
|
|
135
|
+
# SSRF guard: refuse loopback/private/link-local before issuing the
|
|
136
|
+
# request. Imported lazily so attachments.py doesn't pull in safety on
|
|
137
|
+
# every code path.
|
|
138
|
+
from .safety import is_url_safe_for_fetch
|
|
139
|
+
|
|
140
|
+
ok, reason = is_url_safe_for_fetch(url)
|
|
141
|
+
if not ok:
|
|
142
|
+
raise AttachmentError(f"refusing to fetch {url}: {reason}")
|
|
112
143
|
try:
|
|
113
144
|
with httpx.Client(timeout=30, follow_redirects=True) as client:
|
|
114
145
|
r = client.get(url)
|