roundtable-sh 0.1.1__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.
@@ -0,0 +1,52 @@
1
+ """Thin console-script launcher for Roundtable.
2
+
3
+ `uv tool install roundtable-sh` / `pipx install roundtable-sh` expose a `roundtable` command that simply
4
+ execs the bundled bash engine (bin/roundtable), which sources lib/*. The engine
5
+ and its libs are bundled as package data under roundtable_cli/_engine/ (see
6
+ pyproject.toml [tool.hatch.build.targets.wheel.force-include]); the bin/ and
7
+ lib/ sibling layout is preserved so the dispatcher's `../lib` resolution works.
8
+
9
+ Keep this tiny: all real behavior lives in the bash engine.
10
+ """
11
+
12
+ import os
13
+ import shutil
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ __version__ = "0.1.1"
18
+
19
+ _ENGINE = Path(__file__).resolve().parent / "_engine" / "bin" / "roundtable"
20
+
21
+
22
+ def engine_path() -> Path:
23
+ """Absolute path to the bundled dispatcher."""
24
+ return _ENGINE
25
+
26
+
27
+ def main() -> "int":
28
+ engine = engine_path()
29
+ if not engine.exists():
30
+ sys.stderr.write(
31
+ "roundtable: bundled engine not found at %s\n"
32
+ "The package may be damaged. Reinstall it:\n"
33
+ " uv tool install --reinstall roundtable-sh\n"
34
+ % engine
35
+ )
36
+ return 1
37
+
38
+ # Exec via bash for portability: wheels do not reliably preserve the
39
+ # executable bit on data files, so we never rely on it. This replaces the
40
+ # current process, so the engine owns stdin/stdout/stderr and the exit code.
41
+ bash = shutil.which("bash") or "/bin/bash"
42
+ argv = [bash, str(engine), *sys.argv[1:]]
43
+ try:
44
+ os.execv(bash, argv)
45
+ except OSError as exc: # pragma: no cover - exec almost never returns
46
+ sys.stderr.write("roundtable: failed to launch engine: %s\n" % exc)
47
+ return 1
48
+ return 0 # unreachable on success
49
+
50
+
51
+ if __name__ == "__main__":
52
+ raise SystemExit(main())
@@ -0,0 +1,6 @@
1
+ """Enable `python -m roundtable_cli`."""
2
+
3
+ from roundtable_cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env bash
2
+ # roundtable — a council of frontier models (a Frontier Infra project)
3
+ #
4
+ # Portable subcommand dispatcher. Targets macOS bash 3.2 + Linux.
5
+ # Resolves its own real directory without relying on GNU `readlink -f`.
6
+ #
7
+ # roundtable "question" ask the council (default)
8
+ # roundtable ask "question" ... same, explicit
9
+ # roundtable auth|models|heads|doctor|config|version|update
10
+ # roundtable mcp ... MCP server (lib/mcp.py)
11
+ # roundtable install ... harness auto-wire (lib/install.sh)
12
+
13
+ # ── resolve the real script dir (follow symlinks, portable) ────────────────
14
+ SOURCE="${BASH_SOURCE[0]:-$0}"
15
+ while [ -h "$SOURCE" ]; do
16
+ DIR="$(cd -P "$(dirname "$SOURCE")" >/dev/null 2>&1 && pwd)"
17
+ SOURCE="$(readlink "$SOURCE")"
18
+ case "$SOURCE" in /*) ;; *) SOURCE="$DIR/$SOURCE" ;; esac
19
+ done
20
+ BINDIR="$(cd -P "$(dirname "$SOURCE")" >/dev/null 2>&1 && pwd)"
21
+ LIBDIR="$BINDIR/../lib"
22
+ [ -d "$LIBDIR" ] && LIBDIR="$(cd -P "$LIBDIR" >/dev/null 2>&1 && pwd)"
23
+
24
+ RT_CONFIG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/roundtable/config.env"
25
+
26
+ usage() {
27
+ cat <<EOF
28
+ Roundtable — a council of frontier models (a Frontier Infra project)
29
+
30
+ Usage:
31
+ roundtable "question" Ask the council (advisory, all heads)
32
+ roundtable ask "question" [flags] Same, explicit
33
+ roundtable <command> [args]
34
+
35
+ Commands:
36
+ ask Run the council (default when no command matches)
37
+ auth Add / view provider API keys (masked paste)
38
+ models Set the model id used by each head
39
+ heads Choose which heads are enabled by default
40
+ doctor Show per-head config + reachability
41
+ config Show (masked) or edit the config file ('config edit')
42
+ mcp Run the MCP server (e.g. 'roundtable mcp serve')
43
+ install Auto-wire Roundtable into detected harnesses
44
+ version Print the version
45
+ update Re-run the installer to upgrade
46
+ help Show this help
47
+
48
+ Engine flags (passed through to 'ask'):
49
+ -q, --question TEXT The question (also accepted as a positional)
50
+ -c, --context FILE Prepend a shared context file
51
+ --heads CSV Subset: claude,openai,grok,glm,minimax,gemini,codex
52
+ --rounds N N>=2 -> deliberation to consensus (default 1 = advisory)
53
+ --timeout S Per-head timeout, seconds
54
+ --research Allow web/tool use where heads support it
55
+ --out FILE Also save the transcript to FILE
56
+
57
+ Config: $RT_CONFIG_FILE
58
+ Docs: https://roundtable.sh
59
+ EOF
60
+ }
61
+
62
+ # Value of KEY from config.env only (last match wins).
63
+ rt_cfg_value() {
64
+ local name="$1"
65
+ [ -f "$RT_CONFIG_FILE" ] || return 0
66
+ grep -E "^[[:space:]]*${name}=" "$RT_CONFIG_FILE" 2>/dev/null | tail -n1 | sed -E "s/^[[:space:]]*${name}=//"
67
+ }
68
+
69
+ # Hand off to the engine, injecting default --heads when configured and the
70
+ # user didn't pass their own --heads.
71
+ exec_engine() {
72
+ if [ ! -f "$LIBDIR/core.sh" ]; then
73
+ echo "roundtable: engine not found ($LIBDIR/core.sh). Reinstall: curl -fsSL https://roundtable.sh/install.sh | bash" >&2
74
+ exit 1
75
+ fi
76
+ local has_heads=0 a
77
+ for a in "$@"; do [ "$a" = "--heads" ] && has_heads=1; done
78
+ local heads_default="${ROUNDTABLE_HEADS:-}"
79
+ [ -z "$heads_default" ] && heads_default="$(rt_cfg_value ROUNDTABLE_HEADS)"
80
+ if [ "$has_heads" -eq 0 ] && [ -n "$heads_default" ]; then
81
+ exec bash "$LIBDIR/core.sh" --heads "$heads_default" "$@"
82
+ fi
83
+ exec bash "$LIBDIR/core.sh" "$@"
84
+ }
85
+
86
+ cmd="${1:-}"
87
+ case "$cmd" in
88
+ ""|-h|--help|help)
89
+ usage; exit 0 ;;
90
+
91
+ -V|--version)
92
+ exec bash "$LIBDIR/config.sh" version ;;
93
+
94
+ auth|models|heads|doctor|config|version|update)
95
+ if [ ! -f "$LIBDIR/config.sh" ]; then
96
+ echo "roundtable: $LIBDIR/config.sh is missing" >&2; exit 1
97
+ fi
98
+ exec bash "$LIBDIR/config.sh" "$@" ;;
99
+
100
+ mcp)
101
+ shift
102
+ if [ -f "$LIBDIR/mcp.py" ]; then exec python3 "$LIBDIR/mcp.py" "$@"; fi
103
+ echo "roundtable: MCP server not available ($LIBDIR/mcp.py missing)" >&2; exit 1 ;;
104
+
105
+ install)
106
+ shift
107
+ if [ -f "$LIBDIR/install.sh" ]; then exec bash "$LIBDIR/install.sh" "$@"; fi
108
+ echo "roundtable: installer not available ($LIBDIR/install.sh missing)" >&2; exit 1 ;;
109
+
110
+ ask)
111
+ shift; exec_engine "$@" ;;
112
+
113
+ *)
114
+ exec_engine "$@" ;;
115
+ esac
@@ -0,0 +1,494 @@
1
+ #!/usr/bin/env bash
2
+ # roundtable/lib/config.sh — auth / models / heads / doctor / config / version / update
3
+ #
4
+ # Sourced or executed by bin/roundtable. Portable: macOS bash 3.2 + Linux.
5
+ # No associative arrays, no config parser/framework, no new deps.
6
+ #
7
+ # Config home: ${XDG_CONFIG_HOME:-$HOME/.config}/roundtable/config.env (dotenv, chmod 600)
8
+ # The engine (lib/core.sh) sources the same file plus reads ROUNDTABLE_* env overrides.
9
+ #
10
+ # Keyed heads + env keys:
11
+ # claude=ANTHROPIC_API_KEY openai=OPENAI_API_KEY grok=XAI_API_KEY
12
+ # glm=ZAI_API_KEY minimax=MINIMAX_API_KEY gemini=GEMINI_API_KEY (or GOOGLE_API_KEY)
13
+ # codex=local `codex` CLI login (no key stored)
14
+
15
+ # ── self-location (resolve symlinks without GNU `readlink -f`) ──────────────
16
+ _rt_cfg_src="${BASH_SOURCE[0]:-$0}"
17
+ while [ -h "$_rt_cfg_src" ]; do
18
+ _rt_cfg_dir="$(cd -P "$(dirname "$_rt_cfg_src")" >/dev/null 2>&1 && pwd)"
19
+ _rt_cfg_src="$(readlink "$_rt_cfg_src")"
20
+ case "$_rt_cfg_src" in /*) ;; *) _rt_cfg_src="$_rt_cfg_dir/$_rt_cfg_src" ;; esac
21
+ done
22
+ RT_LIBDIR="$(cd -P "$(dirname "$_rt_cfg_src")" >/dev/null 2>&1 && pwd)"
23
+ RT_REPODIR="$(cd -P "$RT_LIBDIR/.." >/dev/null 2>&1 && pwd)"
24
+
25
+ RT_CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/roundtable"
26
+ RT_CONFIG_FILE="$RT_CONFIG_DIR/config.env"
27
+ RT_INSTALL_URL="https://roundtable.sh/install.sh"
28
+
29
+ # All heads (display/iteration order) and the subset that take an API key.
30
+ ALL_HEADS="claude openai grok glm minimax gemini codex"
31
+ KEYED_HEADS="claude openai grok glm minimax gemini"
32
+
33
+ # ── env-key name for a keyed head ──────────────────────────────────────────
34
+ key_for_head() {
35
+ case "$1" in
36
+ claude) echo "ANTHROPIC_API_KEY" ;;
37
+ openai) echo "OPENAI_API_KEY" ;;
38
+ grok) echo "XAI_API_KEY" ;;
39
+ glm) echo "ZAI_API_KEY" ;;
40
+ minimax) echo "MINIMAX_API_KEY" ;;
41
+ gemini) echo "GEMINI_API_KEY" ;;
42
+ *) echo "" ;;
43
+ esac
44
+ }
45
+
46
+ # ════════════════════════════════════════════════════════════════════════════
47
+ # Embedded python3 helpers (passed via `python3 -c` so stdin stays the caller's
48
+ # real stdin — required so a piped key is read, not the heredoc). No single
49
+ # quotes inside, so the whole program can live in a single-quoted bash string.
50
+ # ════════════════════════════════════════════════════════════════════════════
51
+ RT_SECRET_PY='
52
+ import sys
53
+ def from_stdin():
54
+ return sys.stdin.readline().rstrip("\r\n")
55
+ def masked(prompt):
56
+ try:
57
+ ti = open("/dev/tty", "r"); to = open("/dev/tty", "w")
58
+ except Exception:
59
+ return None
60
+ try:
61
+ import termios, tty
62
+ fd = ti.fileno()
63
+ old = termios.tcgetattr(fd)
64
+ buf = []
65
+ to.write(prompt); to.flush()
66
+ try:
67
+ tty.setraw(fd)
68
+ while True:
69
+ ch = ti.read(1)
70
+ if ch == "" or ch in ("\r", "\n"):
71
+ break
72
+ if ch == "\x03":
73
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
74
+ to.write("\n"); to.flush()
75
+ sys.exit(130)
76
+ if ch in ("\x7f", "\b"):
77
+ if buf:
78
+ buf.pop(); to.write("\b \b"); to.flush()
79
+ continue
80
+ if ch == "\x1b":
81
+ continue
82
+ buf.append(ch); to.write("*"); to.flush()
83
+ finally:
84
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
85
+ to.write("\n"); to.flush()
86
+ return "".join(buf)
87
+ except Exception:
88
+ return None
89
+ prompt = sys.argv[1] if len(sys.argv) > 1 else "Paste key: "
90
+ if not sys.stdin.isatty():
91
+ sys.stdout.write(from_stdin())
92
+ else:
93
+ v = masked(prompt)
94
+ if v is None:
95
+ import getpass
96
+ try:
97
+ v = getpass.getpass(prompt)
98
+ except Exception:
99
+ v = from_stdin()
100
+ sys.stdout.write(v)
101
+ '
102
+
103
+ RT_UPSERT_PY='
104
+ import sys, os
105
+ path, key, val = sys.argv[1], sys.argv[2], sys.argv[3]
106
+ lines, found = [], False
107
+ if os.path.exists(path):
108
+ with open(path) as f:
109
+ for line in f:
110
+ s = line.lstrip()
111
+ head = s.split("=", 1)[0].strip() if "=" in s else None
112
+ if head == key:
113
+ if not found:
114
+ lines.append(key + "=" + val + "\n"); found = True
115
+ continue
116
+ if not line.endswith("\n"):
117
+ line += "\n"
118
+ lines.append(line)
119
+ if not found:
120
+ lines.append(key + "=" + val + "\n")
121
+ with open(path, "w") as f:
122
+ f.writelines(lines)
123
+ try:
124
+ os.chmod(path, 0o600)
125
+ except Exception:
126
+ pass
127
+ '
128
+
129
+ # ── config.env primitives ──────────────────────────────────────────────────
130
+ rt_config_ensure_dir() {
131
+ [ -d "$RT_CONFIG_DIR" ] || mkdir -p "$RT_CONFIG_DIR" 2>/dev/null
132
+ chmod 700 "$RT_CONFIG_DIR" 2>/dev/null || true
133
+ }
134
+
135
+ # Value of KEY from config.env only (last match wins). Empty if absent.
136
+ rt_cfg_value() {
137
+ local name="$1"
138
+ [ -f "$RT_CONFIG_FILE" ] || return 0
139
+ grep -E "^[[:space:]]*${name}=" "$RT_CONFIG_FILE" 2>/dev/null | tail -n1 | sed -E "s/^[[:space:]]*${name}=//"
140
+ }
141
+
142
+ # Value from the live environment if set, else from config.env.
143
+ rt_env_or_config() {
144
+ local name="$1" v=""
145
+ eval "v=\${$name:-}"
146
+ if [ -n "$v" ]; then printf '%s' "$v"; return 0; fi
147
+ rt_cfg_value "$name"
148
+ }
149
+
150
+ # Idempotent KEY=VALUE upsert; keeps file chmod 600.
151
+ rt_upsert() {
152
+ local key="$1" val="$2"
153
+ rt_config_ensure_dir
154
+ if command -v python3 >/dev/null 2>&1; then
155
+ python3 -c "$RT_UPSERT_PY" "$RT_CONFIG_FILE" "$key" "$val"
156
+ else
157
+ local tmp; tmp="$(mktemp)"
158
+ [ -f "$RT_CONFIG_FILE" ] && grep -v -E "^[[:space:]]*${key}=" "$RT_CONFIG_FILE" > "$tmp" 2>/dev/null
159
+ printf '%s=%s\n' "$key" "$val" >> "$tmp"
160
+ mv "$tmp" "$RT_CONFIG_FILE"
161
+ fi
162
+ chmod 600 "$RT_CONFIG_FILE" 2>/dev/null || true
163
+ }
164
+
165
+ rt_remove_key() {
166
+ local key="$1" tmp
167
+ [ -f "$RT_CONFIG_FILE" ] || return 0
168
+ tmp="$(mktemp)"
169
+ grep -v -E "^[[:space:]]*${key}=" "$RT_CONFIG_FILE" > "$tmp" 2>/dev/null || true
170
+ mv "$tmp" "$RT_CONFIG_FILE"
171
+ chmod 600 "$RT_CONFIG_FILE" 2>/dev/null || true
172
+ }
173
+
174
+ # Mask a secret value for display: keep at most first 2 + last 2 chars.
175
+ mask_value() {
176
+ local v="$1"
177
+ local n=${#v}
178
+ if [ "$n" -le 6 ]; then printf '%s' '******'
179
+ else printf '%s********%s' "${v:0:2}" "${v:$((n-2))}"; fi
180
+ }
181
+
182
+ rt_read_secret() {
183
+ local prompt="$1" v
184
+ if command -v python3 >/dev/null 2>&1; then
185
+ python3 -c "$RT_SECRET_PY" "$prompt"
186
+ return $?
187
+ fi
188
+ printf '%s' "$prompt" >&2
189
+ read -rs v 2>/dev/null
190
+ printf '\n' >&2
191
+ printf '%s' "$v"
192
+ }
193
+
194
+ # ── presence checks ────────────────────────────────────────────────────────
195
+ head_key_present() { # keyed heads only
196
+ local h="$1" v
197
+ v="$(rt_env_or_config "$(key_for_head "$h")")"
198
+ if [ -z "$v" ] && [ "$h" = gemini ]; then v="$(rt_env_or_config GOOGLE_API_KEY)"; fi
199
+ [ -n "$v" ]
200
+ }
201
+
202
+ rt_codex_logged_in() {
203
+ command -v codex >/dev/null 2>&1 || return 1
204
+ local h="${CODEX_HOME:-$HOME/.codex}"
205
+ [ -f "$h/auth.json" ] && return 0
206
+ return 1
207
+ }
208
+
209
+ # ════════════════════════════════════════════════════════════════════════════
210
+ # auth
211
+ # ════════════════════════════════════════════════════════════════════════════
212
+ rt_print_provider_status() {
213
+ local h mark src
214
+ echo "Providers (✓ = configured, ✗ = missing):"
215
+ echo
216
+ for h in $KEYED_HEADS; do
217
+ src="$(key_for_head "$h")"
218
+ if head_key_present "$h"; then mark="✓"; else mark="✗"; fi
219
+ printf ' %s %-8s %s\n' "$mark" "$h" "$src"
220
+ done
221
+ if rt_codex_logged_in; then
222
+ printf ' %s %-8s %s\n' "✓" "codex" "local codex CLI (logged in)"
223
+ elif command -v codex >/dev/null 2>&1; then
224
+ printf ' %s %-8s %s\n' "✗" "codex" "local codex CLI found — run 'codex login'"
225
+ else
226
+ printf ' %s %-8s %s\n' "✗" "codex" "local codex CLI not installed"
227
+ fi
228
+ }
229
+
230
+ rt_cmd_auth() {
231
+ local provider="${1:-}"
232
+ rt_print_provider_status
233
+ if [ -z "$provider" ]; then
234
+ echo
235
+ printf 'Which provider to configure? [%s]: ' "$(echo "$KEYED_HEADS" | tr ' ' '/')"
236
+ read -r provider 2>/dev/null || provider=""
237
+ fi
238
+ provider="$(printf '%s' "$provider" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
239
+ [ -z "$provider" ] && { echo "No provider selected — nothing changed." >&2; return 1; }
240
+
241
+ if [ "$provider" = codex ]; then
242
+ if rt_codex_logged_in; then
243
+ echo "codex is logged in via the local codex CLI — nothing to store here."
244
+ elif command -v codex >/dev/null 2>&1; then
245
+ echo "codex CLI is installed but not logged in. Run: codex login"
246
+ else
247
+ echo "codex CLI not installed. Install it and run 'codex login' to enable the codex head." >&2
248
+ fi
249
+ return 0
250
+ fi
251
+
252
+ local keyname; keyname="$(key_for_head "$provider")"
253
+ if [ -z "$keyname" ]; then
254
+ echo "Unknown provider: $provider (expected one of: $KEYED_HEADS codex)" >&2
255
+ return 1
256
+ fi
257
+
258
+ rt_config_ensure_dir
259
+ local secret; secret="$(rt_read_secret "Paste $provider key ($keyname): ")"
260
+ secret="$(printf '%s' "$secret" | tr -d '\r\n')"
261
+ [ -z "$secret" ] && { echo "No key entered — nothing changed." >&2; return 1; }
262
+ rt_upsert "$keyname" "$secret"
263
+ echo "✓ Saved $keyname → $RT_CONFIG_FILE (chmod 600)."
264
+ }
265
+
266
+ # ════════════════════════════════════════════════════════════════════════════
267
+ # models
268
+ # ════════════════════════════════════════════════════════════════════════════
269
+ rt_cmd_models() {
270
+ rt_config_ensure_dir
271
+ echo "Per-head model id (blank = keep, '-' = reset to engine default):"
272
+ echo
273
+ local h H var cur new
274
+ for h in $ALL_HEADS; do
275
+ [ "$h" = codex ] && continue # codex uses the local codex CLI's own default model
276
+ H="$(printf '%s' "$h" | tr '[:lower:]' '[:upper:]')"
277
+ var="ROUNDTABLE_${H}_MODEL"
278
+ cur="$(rt_env_or_config "$var")"
279
+ [ -z "$cur" ] && cur="(engine default)"
280
+ printf ' %-8s [%s]: ' "$h" "$cur"
281
+ read -r new 2>/dev/null || new=""
282
+ [ -z "$new" ] && continue
283
+ if [ "$new" = "-" ]; then
284
+ rt_remove_key "$var"; echo " → $var reset to engine default"
285
+ else
286
+ rt_upsert "$var" "$new"; echo " → $var=$new"
287
+ fi
288
+ done
289
+ }
290
+
291
+ # ════════════════════════════════════════════════════════════════════════════
292
+ # heads (default-enabled set)
293
+ # ════════════════════════════════════════════════════════════════════════════
294
+ rt_cmd_heads() {
295
+ rt_config_ensure_dir
296
+ local cur; cur="$(rt_env_or_config ROUNDTABLE_HEADS)"
297
+ [ -z "$cur" ] && cur="(engine default: all)"
298
+ echo "Available heads: $ALL_HEADS"
299
+ echo "Current default: $cur"
300
+ echo
301
+ printf 'Comma-separated heads to enable by default (blank = keep, "-" = clear): '
302
+ local new; read -r new 2>/dev/null || new=""
303
+ [ -z "$new" ] && { echo "Unchanged."; return 0; }
304
+ if [ "$new" = "-" ]; then rt_remove_key ROUNDTABLE_HEADS; echo "Cleared default heads."; return 0; fi
305
+ new="$(printf '%s' "$new" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')"
306
+ local h k ok bad="" list
307
+ list="$(printf '%s' "$new" | tr ',' ' ')"
308
+ for h in $list; do
309
+ ok=0
310
+ for k in $ALL_HEADS; do [ "$h" = "$k" ] && ok=1; done
311
+ [ "$ok" -eq 0 ] && bad="$bad $h"
312
+ done
313
+ [ -n "$bad" ] && echo "WARN: unknown head(s):$bad (kept; the engine skips unknowns)" >&2
314
+ rt_upsert ROUNDTABLE_HEADS "$new"
315
+ echo "Set ROUNDTABLE_HEADS=$new"
316
+ }
317
+
318
+ # ════════════════════════════════════════════════════════════════════════════
319
+ # doctor — per-head configured? + fast (<=5s) reachability. Read-only.
320
+ # ════════════════════════════════════════════════════════════════════════════
321
+ rt_classify_http() {
322
+ case "$1" in
323
+ 2*) printf 'ok (%s)' "$1" ;;
324
+ 401|403) printf 'key rejected (%s)' "$1" ;;
325
+ 000|"") printf 'unreachable' ;;
326
+ *) printf 'reachable (%s)' "$1" ;;
327
+ esac
328
+ }
329
+
330
+ head_ping() {
331
+ local h="$1" key="$2" code
332
+ command -v curl >/dev/null 2>&1 || { printf 'curl missing'; return; }
333
+ case "$h" in
334
+ claude)
335
+ code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
336
+ -H "x-api-key: $key" -H "anthropic-version: 2023-06-01" \
337
+ https://api.anthropic.com/v1/models 2>/dev/null)" ;;
338
+ openai)
339
+ code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
340
+ -H "Authorization: Bearer $key" https://api.openai.com/v1/models 2>/dev/null)" ;;
341
+ grok)
342
+ code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
343
+ -H "Authorization: Bearer $key" https://api.x.ai/v1/models 2>/dev/null)" ;;
344
+ glm)
345
+ code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
346
+ -H "Authorization: Bearer $key" https://api.z.ai/api/paas/v4/models 2>/dev/null)" ;;
347
+ minimax)
348
+ code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
349
+ -H "Authorization: Bearer $key" https://api.minimax.io/v1/models 2>/dev/null)" ;;
350
+ gemini)
351
+ code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
352
+ "https://generativelanguage.googleapis.com/v1beta/models?key=$key" 2>/dev/null)" ;;
353
+ *) printf 'n/a'; return ;;
354
+ esac
355
+ rt_classify_http "$code"
356
+ }
357
+
358
+ rt_doctor_row() {
359
+ local h="$1" src conf reach keyval
360
+ if [ "$h" = codex ]; then
361
+ src="codex CLI login"
362
+ if rt_codex_logged_in; then conf="✓"; reach="logged in"
363
+ elif command -v codex >/dev/null 2>&1; then conf="✗"; reach="CLI found, not logged in"
364
+ else conf="✗"; reach="CLI not found"; fi
365
+ printf ' %-9s %-22s %-11s %s\n' "$h" "$src" "$conf" "$reach"
366
+ return
367
+ fi
368
+ src="$(key_for_head "$h")"
369
+ keyval="$(rt_env_or_config "$src")"
370
+ if [ -z "$keyval" ] && [ "$h" = gemini ]; then
371
+ keyval="$(rt_env_or_config GOOGLE_API_KEY)"; [ -n "$keyval" ] && src="GOOGLE_API_KEY"
372
+ fi
373
+ if [ -n "$keyval" ]; then
374
+ conf="✓"; reach="$(head_ping "$h" "$keyval")"
375
+ else
376
+ conf="✗"
377
+ if [ "$h" = grok ] && command -v grok >/dev/null 2>&1; then reach="grok CLI present (no key)"
378
+ else reach="— (no key)"; fi
379
+ fi
380
+ printf ' %-9s %-22s %-11s %s\n' "$h" "$src" "$conf" "$reach"
381
+ }
382
+
383
+ rt_cmd_doctor() {
384
+ echo "Roundtable doctor"
385
+ echo "Config: $RT_CONFIG_FILE"
386
+ echo
387
+ printf ' %-9s %-22s %-11s %s\n' "HEAD" "AUTH SOURCE" "CONFIGURED" "REACHABILITY"
388
+ printf ' %-9s %-22s %-11s %s\n' "----" "-----------" "----------" "------------"
389
+ local h
390
+ for h in $ALL_HEADS; do rt_doctor_row "$h"; done
391
+ echo
392
+ echo "(reachability is a ≤5s ping; read-only, nothing is billed)"
393
+ return 0
394
+ }
395
+
396
+ # ════════════════════════════════════════════════════════════════════════════
397
+ # config (show masked / edit)
398
+ # ════════════════════════════════════════════════════════════════════════════
399
+ rt_print_config_line() {
400
+ local line="$1" key val
401
+ case "$line" in
402
+ ''|\#*) printf '%s\n' "$line"; return ;;
403
+ esac
404
+ case "$line" in
405
+ *=*) key="${line%%=*}"; val="${line#*=}" ;;
406
+ *) printf '%s\n' "$line"; return ;;
407
+ esac
408
+ key="$(printf '%s' "$key" | tr -d '[:space:]')"
409
+ case "$key" in
410
+ *_API_KEY|*_KEY|*_TOKEN|*_SECRET|*_PASSWORD)
411
+ printf '%s=%s\n' "$key" "$(mask_value "$val")" ;;
412
+ *)
413
+ printf '%s=%s\n' "$key" "$val" ;;
414
+ esac
415
+ }
416
+
417
+ rt_cmd_config() {
418
+ local sub="${1:-}"
419
+ if [ "$sub" = edit ]; then
420
+ rt_config_ensure_dir
421
+ if [ ! -f "$RT_CONFIG_FILE" ]; then : > "$RT_CONFIG_FILE"; chmod 600 "$RT_CONFIG_FILE" 2>/dev/null; fi
422
+ "${EDITOR:-vi}" "$RT_CONFIG_FILE"
423
+ return $?
424
+ fi
425
+ echo "Config: $RT_CONFIG_FILE"
426
+ if [ ! -f "$RT_CONFIG_FILE" ]; then
427
+ echo "(not created yet — run 'roundtable auth' to add a key)"
428
+ return 0
429
+ fi
430
+ echo
431
+ local line
432
+ while IFS= read -r line || [ -n "$line" ]; do
433
+ rt_print_config_line "$line"
434
+ done < "$RT_CONFIG_FILE"
435
+ }
436
+
437
+ # ════════════════════════════════════════════════════════════════════════════
438
+ # version / update
439
+ # ════════════════════════════════════════════════════════════════════════════
440
+ rt_cmd_version() {
441
+ local v="0.1.1"
442
+ [ -f "$RT_REPODIR/VERSION" ] && v="$(head -n1 "$RT_REPODIR/VERSION" 2>/dev/null | tr -d '[:space:]')"
443
+ [ -z "$v" ] && v="0.1.1"
444
+ echo "roundtable $v"
445
+ }
446
+
447
+ rt_cmd_update() {
448
+ # Point each install method at its own upgrade path.
449
+ case "$RT_REPODIR" in
450
+ */.claude/plugins/*|*/.claude-*/plugins/*)
451
+ echo "Roundtable came from the Claude Code plugin. Update it inside Claude Code:"
452
+ echo
453
+ echo " /plugin marketplace update frontier-infra"
454
+ return 0 ;;
455
+ */site-packages/*)
456
+ echo "Roundtable came from a Python package. Update it with the tool you used:"
457
+ echo
458
+ echo " uv tool upgrade roundtable-sh"
459
+ echo " pipx upgrade roundtable-sh"
460
+ echo " pip install -U roundtable-sh"
461
+ return 0 ;;
462
+ esac
463
+ local cmd="curl -fsSL $RT_INSTALL_URL | bash"
464
+ echo "Update Roundtable by re-running the installer:"
465
+ echo
466
+ echo " $cmd"
467
+ echo
468
+ printf 'Run it now? [y/N]: '
469
+ local ans; read -r ans 2>/dev/null || ans=""
470
+ case "$ans" in
471
+ y|Y|yes|YES) exec bash -c "$cmd" ;;
472
+ *) echo "Skipped." ;;
473
+ esac
474
+ }
475
+
476
+ # ── dispatch (only when executed directly, not when sourced) ────────────────
477
+ rt_main() {
478
+ local sub="${1:-}"
479
+ [ $# -gt 0 ] && shift
480
+ case "$sub" in
481
+ auth) rt_cmd_auth "$@" ;;
482
+ models) rt_cmd_models "$@" ;;
483
+ heads) rt_cmd_heads "$@" ;;
484
+ doctor) rt_cmd_doctor "$@" ;;
485
+ config) rt_cmd_config "$@" ;;
486
+ version) rt_cmd_version "$@" ;;
487
+ update) rt_cmd_update "$@" ;;
488
+ *) echo "config.sh: unknown subcommand '$sub' (auth|models|heads|doctor|config|version|update)" >&2; return 2 ;;
489
+ esac
490
+ }
491
+
492
+ if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
493
+ rt_main "$@"
494
+ fi