pennyrouter 0.3.19 → 0.3.21
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.
- package/README.md +13 -2
- package/package.json +2 -1
- package/src/cli.js +13 -0
- package/src/launch.js +60 -4
- package/src/launch.test.js +8 -0
- package/standalone/.env.example +8 -0
- package/standalone/README-original.md +79 -0
- package/standalone/README.md +60 -0
- package/standalone/claude-proxy.sh +220 -0
- package/standalone/codex-proxy.sh +208 -0
- package/standalone/delta.py +41 -0
- package/standalone/demo_example.md +7 -0
- package/standalone/hud.py +122 -0
- package/standalone/install.sh +58 -0
- package/standalone/pane.py +430 -0
- package/standalone/penny-compare +318 -0
- package/standalone/penny-compare-viewer +10 -0
- package/standalone/penny-session +103 -0
- package/standalone/pricing.py +139 -0
- package/standalone/report.py +137 -0
- package/standalone/usage.py +575 -0
- package/standalone/viewer.py +232 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Point Codex at an OpenAI Responses-compatible proxy, and put it back.
|
|
3
|
+
#
|
|
4
|
+
# This writes a marker-fenced custom provider into ~/.codex/config.toml. The
|
|
5
|
+
# proxy token is read from the local .env and written only inside the managed
|
|
6
|
+
# provider block. Every setting outside the managed block is
|
|
7
|
+
# preserved, including the model provider that `off` restores.
|
|
8
|
+
#
|
|
9
|
+
# codex-proxy.sh on [url] point Codex at the proxy
|
|
10
|
+
# codex-proxy.sh off remove it, back to the previous provider
|
|
11
|
+
# codex-proxy.sh status show what is set right now
|
|
12
|
+
#
|
|
13
|
+
# The URL and token fall back to whatever claude-proxy.sh already saved in
|
|
14
|
+
# .env (PENNY_COMPARE_NATIVE_BASE_URL / PENNY_COMPARE_NATIVE_AUTH_TOKEN), so a
|
|
15
|
+
# bare `on` reuses the same proxy without retyping either. CODEX_PROXY_URL and
|
|
16
|
+
# CODEX_PROXY_TOKEN (or CODEX_PROXY_TOKEN_ENV, to name a different token
|
|
17
|
+
# variable) override that when set. Include the API prefix expected by the
|
|
18
|
+
# proxy, commonly `/v1`, in the URL.
|
|
19
|
+
#
|
|
20
|
+
# export CODEX_PROXY_URL=https://litellm.example.com/v1
|
|
21
|
+
# export CODEX_PROXY_TOKEN=sk-example
|
|
22
|
+
# ./codex-proxy.sh on
|
|
23
|
+
#
|
|
24
|
+
# The first `on` backs the config file up once per invocation, so an edit that
|
|
25
|
+
# goes wrong is recoverable from ~/.codex/config.toml.bak.<timestamp>.
|
|
26
|
+
|
|
27
|
+
set -euo pipefail
|
|
28
|
+
|
|
29
|
+
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
30
|
+
ENV_FILE="${PROXY_ENV_FILE:-$SCRIPT_DIR/.env}"
|
|
31
|
+
if [ -f "$ENV_FILE" ]; then
|
|
32
|
+
set -a
|
|
33
|
+
. "$ENV_FILE"
|
|
34
|
+
set +a
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
CONFIG="${CODEX_CONFIG:-${CODEX_HOME:-$HOME/.codex}/config.toml}"
|
|
38
|
+
# Falls back to the same proxy URL claude-proxy.sh already saved in .env, so a
|
|
39
|
+
# bare `./codex-proxy.sh on` reuses it without retyping.
|
|
40
|
+
DEFAULT_URL="${CODEX_PROXY_URL:-${PENNY_COMPARE_NATIVE_BASE_URL:-}}"
|
|
41
|
+
TOKEN_ENV="${CODEX_PROXY_TOKEN_ENV:-CODEX_PROXY_TOKEN}"
|
|
42
|
+
|
|
43
|
+
die() { echo "error: $*" >&2; exit 1; }
|
|
44
|
+
|
|
45
|
+
command -v python3 >/dev/null || die "python3 is required"
|
|
46
|
+
|
|
47
|
+
# Codex creates this on first run. Refuse to invent a new file because doing so
|
|
48
|
+
# would guess at the rest of the user's configuration.
|
|
49
|
+
[ -f "$CONFIG" ] || die "no config file at $CONFIG (run codex once first)"
|
|
50
|
+
|
|
51
|
+
backup() {
|
|
52
|
+
local dest="$CONFIG.bak.$(date +%Y%m%dT%H%M%SZ)"
|
|
53
|
+
cp "$CONFIG" "$dest"
|
|
54
|
+
echo "backed up to $dest"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Add or remove the managed provider block. Root-level TOML keys must precede
|
|
58
|
+
# the first table, so this edits text deliberately instead of appending a block.
|
|
59
|
+
# Writes use a same-directory temporary file and atomic rename.
|
|
60
|
+
edit_config() {
|
|
61
|
+
ACTION="$1" URL="${2:-}" TOKEN="${3:-}" CONFIG="$CONFIG" python3 <<'PY'
|
|
62
|
+
import base64
|
|
63
|
+
import json
|
|
64
|
+
import os
|
|
65
|
+
import re
|
|
66
|
+
import tempfile
|
|
67
|
+
|
|
68
|
+
path = os.environ["CONFIG"]
|
|
69
|
+
action = os.environ["ACTION"]
|
|
70
|
+
start = "# --- Codex proxy managed provider ---"
|
|
71
|
+
end = "# --- end Codex proxy managed provider ---"
|
|
72
|
+
provider_id = "codex_proxy"
|
|
73
|
+
|
|
74
|
+
with open(path) as fh:
|
|
75
|
+
original = fh.read()
|
|
76
|
+
|
|
77
|
+
block_pattern = re.compile(
|
|
78
|
+
rf"(?ms)^{re.escape(start)}\n.*?^{re.escape(end)}\n?"
|
|
79
|
+
)
|
|
80
|
+
old_match = block_pattern.search(original)
|
|
81
|
+
previous = None
|
|
82
|
+
if old_match:
|
|
83
|
+
saved = re.search(
|
|
84
|
+
r"(?m)^# previous_model_provider_b64: ([A-Za-z0-9+/=]+|none)$",
|
|
85
|
+
old_match.group(0),
|
|
86
|
+
)
|
|
87
|
+
if saved and saved.group(1) != "none":
|
|
88
|
+
previous = base64.b64decode(saved.group(1)).decode("utf-8")
|
|
89
|
+
|
|
90
|
+
clean = block_pattern.sub("", original)
|
|
91
|
+
lines = clean.splitlines()
|
|
92
|
+
first_table = next((i for i, line in enumerate(lines) if re.match(r"^\s*\[", line)), len(lines))
|
|
93
|
+
|
|
94
|
+
root_provider_indexes = [
|
|
95
|
+
i for i, line in enumerate(lines[:first_table])
|
|
96
|
+
if re.match(r"^\s*model_provider\s*=", line)
|
|
97
|
+
]
|
|
98
|
+
if action == "on":
|
|
99
|
+
if previous is None and root_provider_indexes:
|
|
100
|
+
previous = lines[root_provider_indexes[0]]
|
|
101
|
+
lines = [line for i, line in enumerate(lines) if i not in root_provider_indexes]
|
|
102
|
+
|
|
103
|
+
clean_without_root_provider = "\n".join(lines)
|
|
104
|
+
if re.search(rf"(?m)^\s*\[model_providers\.{provider_id}\]\s*$", clean_without_root_provider):
|
|
105
|
+
raise SystemExit(
|
|
106
|
+
f"error: config already defines [model_providers.{provider_id}] outside the managed block"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
first_table = next((i for i, line in enumerate(lines) if re.match(r"^\s*\[", line)), len(lines))
|
|
110
|
+
saved_previous = (
|
|
111
|
+
base64.b64encode(previous.encode("utf-8")).decode("ascii")
|
|
112
|
+
if previous is not None else "none"
|
|
113
|
+
)
|
|
114
|
+
block = [
|
|
115
|
+
start,
|
|
116
|
+
f"# previous_model_provider_b64: {saved_previous}",
|
|
117
|
+
f'model_provider = "{provider_id}"',
|
|
118
|
+
"",
|
|
119
|
+
f"[model_providers.{provider_id}]",
|
|
120
|
+
'name = "Codex proxy"',
|
|
121
|
+
f'base_url = {json.dumps(os.environ["URL"])}',
|
|
122
|
+
'wire_api = "responses"',
|
|
123
|
+
"supports_websockets = false",
|
|
124
|
+
f'experimental_bearer_token = {json.dumps(os.environ["TOKEN"])}',
|
|
125
|
+
end,
|
|
126
|
+
]
|
|
127
|
+
if first_table > 0 and lines[first_table - 1].strip():
|
|
128
|
+
block.insert(0, "")
|
|
129
|
+
if first_table < len(lines) and lines[first_table].strip():
|
|
130
|
+
block.append("")
|
|
131
|
+
lines[first_table:first_table] = block
|
|
132
|
+
elif action == "off":
|
|
133
|
+
# If the user added a new root provider while the proxy was active, keep it
|
|
134
|
+
# instead of replacing it with the older saved value.
|
|
135
|
+
if previous is not None and not root_provider_indexes:
|
|
136
|
+
lines = clean.splitlines()
|
|
137
|
+
first_table = next((i for i, line in enumerate(lines) if re.match(r"^\s*\[", line)), len(lines))
|
|
138
|
+
insertion = [previous]
|
|
139
|
+
if first_table > 0 and lines[first_table - 1].strip():
|
|
140
|
+
insertion.insert(0, "")
|
|
141
|
+
lines[first_table:first_table] = insertion
|
|
142
|
+
else:
|
|
143
|
+
raise SystemExit(f"error: unknown action: {action}")
|
|
144
|
+
|
|
145
|
+
text = "\n".join(lines)
|
|
146
|
+
text = re.sub(r"\n{3,}", "\n\n", text).strip() + "\n"
|
|
147
|
+
directory = os.path.dirname(path) or "."
|
|
148
|
+
with tempfile.NamedTemporaryFile("w", dir=directory, delete=False) as tmp:
|
|
149
|
+
tmp.write(text)
|
|
150
|
+
temp_path = tmp.name
|
|
151
|
+
os.chmod(temp_path, 0o600)
|
|
152
|
+
os.replace(temp_path, path)
|
|
153
|
+
PY
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
show_status() {
|
|
157
|
+
CONFIG="$CONFIG" python3 <<'PY'
|
|
158
|
+
import json
|
|
159
|
+
import os
|
|
160
|
+
import re
|
|
161
|
+
|
|
162
|
+
with open(os.environ["CONFIG"]) as fh:
|
|
163
|
+
text = fh.read()
|
|
164
|
+
|
|
165
|
+
match = re.search(
|
|
166
|
+
r'(?ms)^# --- Codex proxy managed provider ---\n(.*?)^# --- end Codex proxy managed provider ---$',
|
|
167
|
+
text,
|
|
168
|
+
)
|
|
169
|
+
if not match:
|
|
170
|
+
print("proxy OFF (Codex uses its previous/default provider)")
|
|
171
|
+
raise SystemExit
|
|
172
|
+
|
|
173
|
+
url_match = re.search(r"(?m)^base_url\s*=\s*(\"(?:[^\"\\]|\\.)*\")\s*$", match.group(1))
|
|
174
|
+
url = json.loads(url_match.group(1)) if url_match else "UNKNOWN"
|
|
175
|
+
token_match = re.search(r"(?m)^experimental_bearer_token\s*=\s*", match.group(1))
|
|
176
|
+
token_status = "set" if token_match else "MISSING"
|
|
177
|
+
print(f"proxy ON\n base_url: {url}\n token: {token_status}")
|
|
178
|
+
PY
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
case "${1:-status}" in
|
|
182
|
+
on)
|
|
183
|
+
url="${2:-$DEFAULT_URL}"
|
|
184
|
+
[ -n "$url" ] || die "usage: $(basename "$0") on <proxy-url> (or set CODEX_PROXY_URL)"
|
|
185
|
+
[[ "$TOKEN_ENV" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || die "invalid token environment variable name: $TOKEN_ENV"
|
|
186
|
+
# Falls back to the same proxy token claude-proxy.sh already saved in
|
|
187
|
+
# .env, so a bare `./codex-proxy.sh on` reuses it without retyping.
|
|
188
|
+
token="${!TOKEN_ENV:-${PENNY_COMPARE_NATIVE_AUTH_TOKEN:-}}"
|
|
189
|
+
[ -n "$token" ] || die "$TOKEN_ENV is not set (or set PENNY_COMPARE_NATIVE_AUTH_TOKEN in .env)"
|
|
190
|
+
|
|
191
|
+
backup
|
|
192
|
+
edit_config on "${url%/}" "$token"
|
|
193
|
+
echo "Codex -> ${url%/}"
|
|
194
|
+
echo "Restart any running Codex session to pick this up."
|
|
195
|
+
;;
|
|
196
|
+
off)
|
|
197
|
+
backup
|
|
198
|
+
edit_config off
|
|
199
|
+
echo "Codex -> previous/default provider"
|
|
200
|
+
echo "Restart any running Codex session to pick this up."
|
|
201
|
+
;;
|
|
202
|
+
status)
|
|
203
|
+
show_status
|
|
204
|
+
;;
|
|
205
|
+
*)
|
|
206
|
+
die "usage: $(basename "$0") {on [url]|off|status}"
|
|
207
|
+
;;
|
|
208
|
+
esac
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Derives the cross-pane comparison.
|
|
3
|
+
|
|
4
|
+
The percentages describe the pair, not either pane, so they are computed once here and written
|
|
5
|
+
where the penny pane can display them above its readout.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def read(path):
|
|
14
|
+
try:
|
|
15
|
+
with open(path) as handle:
|
|
16
|
+
cost, total, write = handle.read().split()[:3]
|
|
17
|
+
return float(cost), int(total), int(write)
|
|
18
|
+
except (OSError, ValueError):
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main():
|
|
23
|
+
state, out = sys.argv[1], sys.argv[2]
|
|
24
|
+
while True:
|
|
25
|
+
native = read(os.path.join(state, "native.txt"))
|
|
26
|
+
penny = read(os.path.join(state, "penny.txt"))
|
|
27
|
+
text = ""
|
|
28
|
+
if native and penny and native[0] > 0 and native[1] > 0:
|
|
29
|
+
cost_delta = (penny[0] - native[0]) / native[0] * 100
|
|
30
|
+
token_delta = (penny[1] - native[1]) / native[1] * 100
|
|
31
|
+
text = f"{cost_delta:+.0f}% cost {token_delta:+.0f}% tok"
|
|
32
|
+
try:
|
|
33
|
+
with open(out, "w") as handle:
|
|
34
|
+
handle.write(text)
|
|
35
|
+
except OSError:
|
|
36
|
+
pass
|
|
37
|
+
time.sleep(1)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
main()
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
are you familiar with the omop common data model
|
|
2
|
+
|
|
3
|
+
can you write me a sql query to find patients with gerd using omop
|
|
4
|
+
|
|
5
|
+
great what is a good resource for finding concept sets with omop queries
|
|
6
|
+
|
|
7
|
+
perfect can you make me a simple HTML showing the components of the OMOP CDM and how they connect
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Draws the token readout above a harness session.
|
|
2
|
+
|
|
3
|
+
The HUD owns a fixed block of rows at the top of the terminal and repaints them in place, so the
|
|
4
|
+
session below scrolls untouched. Values roll toward their targets rather than snapping, which is
|
|
5
|
+
what makes a turn's tokens legible as they land.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
GREEN = "\033[38;5;46m"
|
|
14
|
+
DIM_GREEN = "\033[38;5;28m"
|
|
15
|
+
AMBER = "\033[38;5;214m"
|
|
16
|
+
RED = "\033[38;5;203m"
|
|
17
|
+
GREY = "\033[38;5;242m"
|
|
18
|
+
WHITE = "\033[38;5;253m"
|
|
19
|
+
BOLD = "\033[1m"
|
|
20
|
+
RESET = "\033[0m"
|
|
21
|
+
|
|
22
|
+
WIDTH = 26
|
|
23
|
+
ROWS = 7
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def abbreviate(value: int) -> str:
|
|
27
|
+
if value >= 1_000_000:
|
|
28
|
+
return f"{value / 1_000_000:.2f}M"
|
|
29
|
+
if value >= 1_000:
|
|
30
|
+
return f"{value / 1_000:.1f}k"
|
|
31
|
+
return str(value)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CountUp:
|
|
35
|
+
"""Eases displayed values toward their targets.
|
|
36
|
+
|
|
37
|
+
Counts arrive in jumps and at any magnitude, so each value chases its target continuously:
|
|
38
|
+
a target that moves mid-roll retargets rather than restarting. Closing the tail as a share of
|
|
39
|
+
the whole jump keeps a roll the same duration whether it counts dozens or hundreds of
|
|
40
|
+
thousands.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
RATE = 0.88
|
|
44
|
+
MIN_STEP_SHARE = 0.55
|
|
45
|
+
|
|
46
|
+
def __init__(self):
|
|
47
|
+
self._shown: dict[str, float] = {}
|
|
48
|
+
self._origin: dict[str, float] = {}
|
|
49
|
+
|
|
50
|
+
def advance(self, targets: dict[str, int], elapsed: float) -> dict[str, int]:
|
|
51
|
+
remaining = (1 - self.RATE) ** max(0.0, elapsed)
|
|
52
|
+
out: dict[str, int] = {}
|
|
53
|
+
for key, target in targets.items():
|
|
54
|
+
goal = float(target)
|
|
55
|
+
if key not in self._shown:
|
|
56
|
+
# A session's existing total is not motion worth watching.
|
|
57
|
+
self._shown[key] = self._origin[key] = goal
|
|
58
|
+
out[key] = target
|
|
59
|
+
continue
|
|
60
|
+
current = self._shown[key]
|
|
61
|
+
if goal < current:
|
|
62
|
+
# A drop is a new session; land rather than counting backwards.
|
|
63
|
+
current = goal
|
|
64
|
+
self._origin[key] = goal
|
|
65
|
+
else:
|
|
66
|
+
if goal > self._origin[key] and current >= goal:
|
|
67
|
+
self._origin[key] = current
|
|
68
|
+
span = max(1.0, goal - min(self._origin[key], current))
|
|
69
|
+
floor_step = span * self.MIN_STEP_SHARE * max(0.0, elapsed)
|
|
70
|
+
current += max((goal - current) * (1 - remaining), min(goal - current, floor_step))
|
|
71
|
+
self._shown[key] = current
|
|
72
|
+
out[key] = min(target, int(current))
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
def reset(self) -> None:
|
|
76
|
+
self._shown.clear()
|
|
77
|
+
self._origin.clear()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _row(label: str, value: str, colour: str) -> str:
|
|
81
|
+
pad = WIDTH - len(label) - len(value)
|
|
82
|
+
return f"{GREY}{label}{' ' * max(1, pad)}{colour}{BOLD}{value}{RESET}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def render(title: str, model: str, usage, cost: float, delta: str | None = None,
|
|
86
|
+
waiting: bool = False) -> list[str]:
|
|
87
|
+
"""The readout as a list of lines, top-aligned and padded to a fixed height."""
|
|
88
|
+
lines: list[str] = []
|
|
89
|
+
|
|
90
|
+
if waiting:
|
|
91
|
+
# Zeros would read as a measurement; the pane has simply not seen a turn yet.
|
|
92
|
+
return ["",
|
|
93
|
+
f"{GREEN}{BOLD}{title}{RESET}",
|
|
94
|
+
f"{GREY}{'waiting for first turn':<{WIDTH}}{RESET}",
|
|
95
|
+
"", "", "", ""]
|
|
96
|
+
|
|
97
|
+
if delta:
|
|
98
|
+
# Right-aligned within the block; the pad is interior so it covers nothing beside it.
|
|
99
|
+
pad = max(0, WIDTH - len(delta))
|
|
100
|
+
lines.append(f"{' ' * pad}{GREEN}{BOLD}{delta}{RESET}")
|
|
101
|
+
else:
|
|
102
|
+
lines.append("")
|
|
103
|
+
|
|
104
|
+
short = (model or "").split("/")[-1] or "—"
|
|
105
|
+
# A dated model id would run past the block and break the right alignment.
|
|
106
|
+
room = WIDTH - len(title) - 1
|
|
107
|
+
if len(short) > room:
|
|
108
|
+
short = short[:room]
|
|
109
|
+
head_pad = WIDTH - len(title) - len(short)
|
|
110
|
+
lines.append(f"{GREEN}{BOLD}{title}{RESET}{' ' * max(1, head_pad)}{DIM_GREEN}{short}{RESET}")
|
|
111
|
+
|
|
112
|
+
lines.append(_row("read", abbreviate(usage["cache_read"]), GREEN))
|
|
113
|
+
lines.append(_row("write", abbreviate(usage["cache_write"]), AMBER))
|
|
114
|
+
lines.append(_row("in", abbreviate(usage["fresh_input"]), GREEN))
|
|
115
|
+
lines.append(_row("out", abbreviate(usage["output"]), GREEN))
|
|
116
|
+
|
|
117
|
+
total = (usage["cache_read"] + usage["cache_write"]
|
|
118
|
+
+ usage["fresh_input"] + usage["output"])
|
|
119
|
+
lines.append(_row("total", abbreviate(total), WHITE))
|
|
120
|
+
|
|
121
|
+
lines.append(_row("cost", f"${cost:,.2f}", WHITE))
|
|
122
|
+
return lines
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Put penny-compare and penny-session on PATH.
|
|
3
|
+
#
|
|
4
|
+
# ./install.sh symlink into ~/.local/bin
|
|
5
|
+
# ./install.sh ~/bin symlink into a directory you choose
|
|
6
|
+
#
|
|
7
|
+
# Symlinks, not copies: the scripts resolve their own directory, so they keep
|
|
8
|
+
# finding pane.py and friends here. Move this folder and re-run to repoint.
|
|
9
|
+
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
|
|
12
|
+
here="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
13
|
+
bindir="${1:-$HOME/.local/bin}"
|
|
14
|
+
|
|
15
|
+
missing=()
|
|
16
|
+
command -v tmux >/dev/null 2>&1 || missing+=(tmux)
|
|
17
|
+
command -v python3 >/dev/null 2>&1 || missing+=(python3)
|
|
18
|
+
if [ ${#missing[@]} -gt 0 ]; then
|
|
19
|
+
echo "Missing: ${missing[*]}" >&2
|
|
20
|
+
echo " macOS: brew install ${missing[*]}" >&2
|
|
21
|
+
echo " Debian: sudo apt install ${missing[*]}" >&2
|
|
22
|
+
exit 1
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
python3 - <<'PY' || { echo "python3 must be 3.10 or newer (the code uses \`X | None\` annotations)." >&2; exit 1; }
|
|
26
|
+
import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)
|
|
27
|
+
PY
|
|
28
|
+
|
|
29
|
+
mkdir -p "$bindir"
|
|
30
|
+
for script in penny-compare penny-session penny-compare-viewer; do
|
|
31
|
+
chmod +x "$here/$script"
|
|
32
|
+
ln -sfn "$here/$script" "$bindir/$script"
|
|
33
|
+
echo " $bindir/$script -> $here/$script"
|
|
34
|
+
done
|
|
35
|
+
chmod +x "$here"/*.py
|
|
36
|
+
|
|
37
|
+
echo
|
|
38
|
+
if ! printf '%s' ":$PATH:" | grep -q ":$bindir:"; then
|
|
39
|
+
echo "NOTE: $bindir is not on your PATH. Add it:"
|
|
40
|
+
echo " echo 'export PATH=\"$bindir:\$PATH\"' >> ~/.zshrc && exec zsh"
|
|
41
|
+
echo
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
harnesses=()
|
|
45
|
+
for h in claude codex opencode penny; do
|
|
46
|
+
command -v "$h" >/dev/null 2>&1 && harnesses+=("$h")
|
|
47
|
+
done
|
|
48
|
+
if [ ${#harnesses[@]} -gt 0 ]; then
|
|
49
|
+
echo "Harnesses found: ${harnesses[*]}"
|
|
50
|
+
else
|
|
51
|
+
echo "No harnesses found on PATH yet (claude / codex / opencode / penny)."
|
|
52
|
+
fi
|
|
53
|
+
if ! command -v penny >/dev/null 2>&1; then
|
|
54
|
+
echo "For the penny-routed panes, install PennyRouter: npx pennyrouter install"
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
echo
|
|
58
|
+
echo "Done. Try: penny-compare"
|