-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinstall.sh
More file actions
485 lines (452 loc) · 25 KB
/
Copy pathinstall.sh
File metadata and controls
485 lines (452 loc) · 25 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
#!/bin/sh
# testimony installer — https://github.com/REPPL/Testimony
#
# Usage (one line):
# curl -fsSL https://raw.githubusercontent.com/REPPL/Testimony/main/install.sh | sh
#
# Prefer to inspect first (recommended):
# curl -fsSLO https://raw.githubusercontent.com/REPPL/Testimony/main/install.sh
# less install.sh && sh install.sh
#
# Passing flags through a pipe:
# curl -fsSL .../install.sh | sh -s -- --yes --dir "$HOME/bin"
#
# Flags:
# -d, --dir DIR install directory (default: ~/.local/bin, or $TESTIMONY_INSTALL_DIR
# when set — no admin rights needed)
# -y, --yes non-interactive: accept each dependency's default install
# choice (ffmpeg: brew if present, otherwise the local,
# admin-free option; ASR: whisperx via uv, always — see
# dep_asr for why)
# --no-deps install the binary only; print dependency guidance and exit
# --version V install release V instead of the default
# -h, --help this text
#
# Trust model. The binary install downloads the platform tarball AND the release's
# published SHA256SUMS, and verifies the tarball against it (integrity — the bytes
# are exactly what the release published). When an AUTHENTICATED `gh` (the GitHub
# CLI) is available it ALSO runs `gh attestation verify` against the release
# workflow's SLSA build-provenance (authenticity — cryptographic proof the tarball
# was built by REPPL/Testimony's own release.yml, the strong anchor). A gh that
# cannot attempt the verification — absent, too old to know attestations, or
# unable to establish its authentication (which an unreachable API also causes
# at the auth check) — means the install proceeds on the checksum alone, with
# a note saying so. Once the verification is attempted, any failure refuses
# the install, fail closed: a rejection, and equally a verification that
# could not complete, because mid-verification failure cannot be told apart
# from an attacker blocking it. Re-run when connectivity returns.
# No per-release hash is pinned in this script: the checksums are fetched from
# the release itself and the attestation binds them to the workflow.
# Everything installs into user-owned locations by default; sudo is never invoked.
set -eu
REPO="REPPL/Testimony"
VERSION="v0.4.0"
# Pinned OpenPGP fingerprint of the evermeet.cx ffmpeg publisher key
# (Helmut K. C. Tessarek, key id 0x476C4B611A660874). The local-macOS ffmpeg
# path verifies the build's signature against THIS key only — never any key the
# signature happens to name — so an attacker-signed substitute build is refused.
EVERMEET_FPR="20F6EA3E0CFD6B4C53447A73476C4B611A660874"
INSTALL_DIR="${TESTIMONY_INSTALL_DIR:-$HOME/.local/bin}"
ASSUME_YES=0
NO_DEPS=0
say() { printf '%s\n' "$*"; }
err() { printf 'install.sh: %s\n' "$*" >&2; }
die() { err "$*"; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }
# Prompt via the terminal even when stdin is the pipe. Returns 0 for yes.
# With --yes, always yes. With no terminal at all, always the safe default (no).
ask() {
q="$1"
[ "$ASSUME_YES" = 1 ] && return 0
if [ -r /dev/tty ] && [ -w /dev/tty ]; then
printf '%s [y/N] ' "$q" > /dev/tty
IFS= read -r reply < /dev/tty || reply=""
case "$reply" in [yY]|[yY][eE][sS]) return 0 ;; *) return 1 ;; esac
fi
return 1
}
# choose "Question" "option-a" "option-b" → prints the chosen word. When both
# options are the same word there is only one choice to offer, so it is rendered
# (and matched) once: [local/skip], never [local/local/skip].
choose() {
q="$1"; a="$2"; b="$3"
if [ "$ASSUME_YES" = 1 ]; then printf '%s' "$a"; return; fi
if [ "$a" = "$b" ]; then opts="$a"; else opts="$a/$b"; fi
if [ -r /dev/tty ] && [ -w /dev/tty ]; then
printf '%s [%s/skip] ' "$q" "$opts" > /dev/tty
IFS= read -r reply < /dev/tty || reply=""
case "$reply" in
"$a") printf '%s' "$a" ;;
"$b") printf '%s' "$b" ;;
*) printf 'skip' ;;
esac
else
printf 'skip'
fi
}
fetch() { # fetch URL FILE
if have curl; then curl -fsSL -o "$2" "$1"
elif have wget; then wget -qO "$2" "$1"
else die "need curl or wget"
fi
}
sha256_of() {
if have shasum; then shasum -a 256 "$1" | awk '{print $1}'
elif have sha256sum; then sha256sum "$1" | awk '{print $1}'
else die "need shasum or sha256sum to verify the download"
fi
}
platform() {
os=$(uname -s | tr '[:upper:]' '[:lower:]')
arch=$(uname -m)
case "$os" in darwin|linux) : ;; *) die "unsupported OS: $os (build from source: go build ./cmd/testimony)" ;; esac
case "$arch" in
arm64|aarch64) arch=arm64 ;;
x86_64|amd64) arch=amd64 ;;
*) die "unsupported architecture: $arch" ;;
esac
printf '%s_%s' "$os" "$arch"
}
install_binary() {
plat=$(platform)
tarball="testimony_${VERSION}_${plat}.tar.gz"
base="https://github.com/$REPO/releases/download/$VERSION"
tmp=$(mktemp -d "${TMPDIR:-/tmp}/testimony-install.XXXXXX")
# Both traps sweep every mktemp dir the script can hold ($tmp here; $tmp2,
# $gnupg, $uvd in the dependency stage), plus $staged, the binary staged
# into INSTALL_DIR ahead of the atomic rename below (unset expansions
# vanish), so an interrupt mid-dependency or mid-install leaks nothing. A
# caught INT/TERM must also STOP the script: a trap that only cleans up
# returns control after the handler, so Ctrl+C at a dependency prompt read
# was swallowed into the safe-default answer and the run carried on.
trap 'rm -rf ${tmp:+"$tmp"} ${tmp2:+"$tmp2"} ${gnupg:+"$gnupg"} ${uvd:+"$uvd"} ${staged:+"$staged"}' EXIT
trap 'rm -rf ${tmp:+"$tmp"} ${tmp2:+"$tmp2"} ${gnupg:+"$gnupg"} ${uvd:+"$uvd"} ${staged:+"$staged"}; trap - EXIT; exit 130' INT TERM
say "Downloading $tarball ..."
# A bad --version (or a platform the release never published) surfaces from
# curl/wget as a bare 404 in the middle of "Downloading". Name the actual
# cause instead. Still fail-closed: die exits non-zero, nothing is retried.
fetch "$base/$tarball" "$tmp/$tarball" || die "could not download $tarball
Release \"$VERSION\" — or its $plat asset — was not found at
$base/$tarball
Release tags are of the form vX.Y.Z (note the leading 'v').
Releases: https://github.com/$REPO/releases"
# Integrity: verify the tarball against the release's published SHA256SUMS.
# No hash is pinned in this script — it is fetched from the release itself.
say "Downloading SHA256SUMS ..."
fetch "$base/SHA256SUMS" "$tmp/SHA256SUMS" || die "could not download SHA256SUMS from $base"
want=$(awk -v f="$tarball" '$2 == f {print $1}' "$tmp/SHA256SUMS")
[ -n "$want" ] || die "no entry for $tarball in SHA256SUMS"
got=$(sha256_of "$tmp/$tarball")
[ "$got" = "$want" ] || die "SHA-256 mismatch for $tarball
expected: $want
got: $got
Refusing to install."
say "SHA-256 verified against the release's SHA256SUMS: $got"
# Authenticity: when the GitHub CLI is available, verify the tarball's SLSA
# build-provenance attestation — cryptographic proof it was built by this
# repo's release workflow, not merely that its bytes match a fetched manifest
# (which an attacker who replaced BOTH the tarball and SHA256SUMS could forge).
# --signer-workflow binds acceptance to release.yml specifically, not any
# workflow in the repo. Without gh, proceed on the checksum with a printed note.
#
# A gh that CANNOT ATTEMPT the verification is the checksum-only case, not a
# provenance failure: `gh attestation verify` refuses to run unauthenticated
# (exit 4, before any verification), and a gh predating the attestation
# command or --signer-workflow fails the same way — treating those as
# "attestation FAILED" made a freshly `brew install`ed gh strictly worse
# than no gh at all, refusing a tarball whose checksum had just verified.
# Once the verification is attempted, every failure refuses the install,
# fail closed — a rejected attestation, and equally a verification that
# fails mid-way (network, API outage): at that point an inconclusive
# answer cannot be told apart from one an attacker is blocking. The auth
# check above draws the attempt boundary: a gh whose authentication
# cannot be established — which an unreachable API also causes — is the
# checksum-fallback case, with a note naming it. gh's own output is shown
# instead of being swallowed.
if have gh && ! gh auth status --hostname github.com >/dev/null 2>&1; then
say "NOTE: 'gh' is installed but not authenticated — installed on the checksum alone."
say " 'gh attestation verify' needs an authenticated gh; run 'gh auth login'"
say " (or set GH_TOKEN) and re-run to also verify SLSA build-provenance."
elif have gh; then
say "Verifying SLSA build-provenance attestation with gh ..."
if gh attestation verify "$tmp/$tarball" \
--repo "$REPO" \
--signer-workflow "$REPO/.github/workflows/release.yml" >"$tmp/attest.log" 2>&1; then
say "Provenance verified: built by $REPO/.github/workflows/release.yml"
elif grep -qiE 'unknown (command|flag)' "$tmp/attest.log"; then
say "NOTE: this gh cannot verify attestations (it lacks 'attestation verify"
say " --signer-workflow') — installed on the checksum alone. Update gh"
say " (https://cli.github.com) and re-run to also verify build provenance."
else
sed 's/^/ gh: /' "$tmp/attest.log" >&2
die "attestation verification FAILED for $tarball
gh could not confirm this tarball was built by $REPO's release workflow.
Refusing to install."
fi
else
say "NOTE: 'gh' (GitHub CLI) not found — installed on the checksum alone."
say " Install gh to also verify SLSA build-provenance (authenticity):"
say " https://cli.github.com — then re-run this installer."
fi
tar -xzf "$tmp/$tarball" -C "$tmp" testimony
# Prove the binary runs and is the release it claims BEFORE it replaces
# anything: a failing command substitution inside say's argument does not
# trip `set -e`, so an unrunnable binary (a wrong-platform asset)
# previously replaced the installed one and printed "Installed: ... ()"
# at exit 0. The probe runs from a staged copy inside INSTALL_DIR — the
# download's temp directory may sit on a noexec mount (a hardened /tmp,
# a noexec TMPDIR), where executing "$tmp/testimony" fails for a
# perfectly good binary — and only the verified copy is renamed onto the
# final name, so a refusal leaves any previously installed binary
# untouched. Every published release, including the pre-workflow v0.1.0
# (hand-built and hand-stamped before the release workflow existed),
# prints its own tag here; only a genuinely unstamped dev build reports
# "testimony dev" and is refused.
mkdir -p "$INSTALL_DIR"
staged="$INSTALL_DIR/.testimony.staged.$$"
install -m 0755 "$tmp/testimony" "$staged"
installed_version="$("$staged" version)" || { rm -f "$staged"; die "the release binary failed to run from $INSTALL_DIR (a wrong-platform asset, or a noexec mount?): $tarball"; }
[ "$installed_version" = "testimony $VERSION" ] || { rm -f "$staged"; die "the release binary reports \"$installed_version\", expected \"testimony $VERSION\"; refusing to install it"; }
mv -f "$staged" "$INSTALL_DIR/testimony"
say "Installed: $INSTALL_DIR/testimony ($installed_version)"
case ":$PATH:" in
*":$INSTALL_DIR:"*) : ;;
*) say ""
say "NOTE: $INSTALL_DIR is not on your PATH. Add it, e.g.:"
# The installer runs on both macOS and Linux (platform(), above), and
# neither implies a shell: a hardcoded ~/.zshrc + exec zsh, the
# installer's assumption since it was written, was wrong advice on
# any bash-default Linux box — it appended to a file bash never
# reads and then tried to exec a shell that may not even be
# installed. $SHELL is the operator's actual login shell on both
# platforms, so route on that.
case "${SHELL:-}" in
*/zsh) say " echo 'export PATH=\"$INSTALL_DIR:\$PATH\"' >> ~/.zshrc && exec zsh" ;;
*/bash) say " echo 'export PATH=\"$INSTALL_DIR:\$PATH\"' >> ~/.bashrc && exec bash" ;;
*) say " export PATH=\"$INSTALL_DIR:\$PATH\" (add this line to your shell's startup file)" ;;
esac ;;
esac
}
# --- dependencies -----------------------------------------------------------
# record's capture needs ffmpeg (so does transcribe -audio's conversion; a bare
# transcribe needs none), and transcribe needs one ASR engine (WhisperX
# preferred, whisper.cpp works too). demo/merge/report need nothing. Local
# options never require admin rights; brew needs a Homebrew install but not
# sudo on default setups.
dep_ffmpeg() {
if have ffmpeg; then say "ffmpeg: already installed ($(command -v ffmpeg))"; return; fi
say ""
say "ffmpeg is required by 'testimony record' (audio/screen capture) and by"
say "'testimony transcribe -audio' (converting an external recording)."
if have brew; then
c=$(choose "Install ffmpeg via" "brew" "local")
else
c=$(choose "Install ffmpeg (no Homebrew found)" "local" "local")
fi
case "$c" in
brew) brew install ffmpeg || err "brew install ffmpeg failed; skipping ffmpeg (later: brew install ffmpeg)" ;;
local) install_ffmpeg_local ;;
skip) say "Skipped. Later: brew install ffmpeg (or re-run this installer)" ;;
esac
}
# install_evermeet_tool NAME — fetch evermeet.cx's separately-published static
# build of NAME (ffmpeg or ffprobe: each is its own info/NAME/release download,
# the ffmpeg zip does not carry ffprobe), verify it against the pinned publisher
# key when gpg is available, and install it into $INSTALL_DIR. evermeet.cx
# publishes a GPG signature (.sig) per build; verification refuses on a bad or
# wrong-key signature. Returns non-zero after its own err message on any
# failure, leaving the caller to decide whether the loss is fatal to the
# branch. Uses $tmp2 for scratch space.
install_evermeet_tool() {
tool="$1"
say "Fetching static $tool build (evermeet.cx) ..."
fetch "https://evermeet.cx/ffmpeg/info/$tool/release" "$tmp2/$tool-info.json" \
|| { err "could not reach evermeet.cx; skipping $tool"; return 1; }
u=$(sed -n 's/.*"zip":{"url":"\([^"]*\)".*/\1/p' "$tmp2/$tool-info.json" | head -1)
[ -n "$u" ] || { err "could not parse evermeet.cx response; skipping $tool"; return 1; }
fetch "$u" "$tmp2/$tool.zip" \
|| { err "$tool download failed; skipping $tool"; return 1; }
if have gpg; then
fetch "$u.sig" "$tmp2/$tool.zip.sig" \
|| { err "could not fetch the $tool signature; refusing this unverifiable build"; return 1; }
# Import ONLY the pinned publisher key into a throwaway keyring,
# then verify against it. --auto-key-retrieve is never used: it
# would fetch whatever key the (attacker-supplied) signature
# names and accept a build signed by that key. We also assert the
# good signature's VALIDSIG carries the pinned fingerprint, so a
# signature made by any other key is rejected. Fail closed.
gnupg=$(mktemp -d "${TMPDIR:-/tmp}/testimony-gpg.XXXXXX")
status=$(GNUPGHOME="$gnupg" gpg --batch --no-auto-key-retrieve \
--keyserver hkps://keys.openpgp.org \
--recv-keys "$EVERMEET_FPR" >/dev/null 2>&1 \
&& GNUPGHOME="$gnupg" gpg --batch --no-auto-key-retrieve --status-fd 1 \
--verify "$tmp2/$tool.zip.sig" "$tmp2/$tool.zip" 2>/dev/null) || true
rm -rf "$gnupg"
if printf '%s\n' "$status" | grep -q "VALIDSIG.*$EVERMEET_FPR"; then
say "$tool GPG signature verified (pinned evermeet key $EVERMEET_FPR)."
else
err "$tool GPG signature verification FAILED (not signed by the pinned evermeet key); refusing this build."
return 1
fi
else
say "WARNING: gpg not found — installing this $tool build unverified"
say " (its signature is at $u.sig)."
fi
(cd "$tmp2" && unzip -q "$tool.zip") \
|| { err "could not unpack $tool; skipping $tool"; return 1; }
install -m 0755 "$tmp2/$tool" "$INSTALL_DIR/$tool" \
|| { err "could not install $tool into $INSTALL_DIR; skipping $tool"; return 1; }
}
install_ffmpeg_local() {
os=$(uname -s | tr '[:upper:]' '[:lower:]')
mkdir -p "$INSTALL_DIR"
tmp2=$(mktemp -d "${TMPDIR:-/tmp}/testimony-ffmpeg.XXXXXX")
case "$os" in
darwin)
# Every fetch/unpack in the helper is guarded with the
# err-skip-return convention: ffmpeg is an OPTIONAL dependency, and
# under `set -eu` an unguarded failure would abort the whole
# installer with the child's raw exit code, skipping the ASR step
# and the closing guidance (install_binary's EXIT trap still
# sweeps $tmp2, so nothing leaks — but the abort itself is still
# the wrong outcome for an optional dependency).
install_evermeet_tool ffmpeg || { rm -rf "$tmp2"; return; }
# ffprobe ships as its own evermeet download, never inside the
# ffmpeg zip. transcribe -audio uses it to derive the
# audio-to-session offset from a recording's creation_time tag;
# without it that derivation silently falls back to 0 — the gap
# the Linux branch below already closes from its shared tarball.
# Best-effort: ffmpeg itself already succeeded above, so a failure
# here only loses offset derivation, not the conversion this
# function exists to provide.
install_evermeet_tool ffprobe \
|| err "ffprobe unavailable; offset derivation for transcribe -audio will fall back to 0"
;;
linux)
arch=$(uname -m)
case "$arch" in x86_64) ja=amd64 ;; aarch64|arm64) ja=arm64 ;; *) err "no static ffmpeg for $arch"; rm -rf "$tmp2"; return ;; esac
say "Fetching static ffmpeg build (johnvansickle.com) ..."
fetch "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-${ja}-static.tar.xz" "$tmp2/ffmpeg.tar.xz" \
|| { err "ffmpeg download failed; skipping ffmpeg"; rm -rf "$tmp2"; return; }
fetch "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-${ja}-static.tar.xz.md5" "$tmp2/ffmpeg.md5" || true
if [ -s "$tmp2/ffmpeg.md5" ] && have md5sum; then
(cd "$tmp2" && sed 's| .*ffmpeg-release.*| ffmpeg.tar.xz|' ffmpeg.md5 | md5sum -c -) \
|| { err "ffmpeg md5 mismatch; skipping"; rm -rf "$tmp2"; return; }
say "ffmpeg md5 verified (upstream publishes md5 only)."
else
say "WARNING: could not verify the static ffmpeg build; installing unverified."
fi
tar -xJf "$tmp2/ffmpeg.tar.xz" -C "$tmp2" \
|| { err "could not unpack ffmpeg; skipping ffmpeg"; rm -rf "$tmp2"; return; }
install -m 0755 "$tmp2"/ffmpeg-*-static/ffmpeg "$INSTALL_DIR/ffmpeg" \
|| { err "could not install ffmpeg into $INSTALL_DIR; skipping ffmpeg"; rm -rf "$tmp2"; return; }
# The same tarball also carries ffprobe, which transcribe -audio
# uses to derive the audio-to-session offset from a recording's
# creation_time tag; install it too so that derivation does not
# silently fall back to 0 for lack of the binary. Best-effort:
# ffmpeg itself already succeeded above, so a failure here only
# loses offset derivation, not the conversion this function exists
# to provide.
install -m 0755 "$tmp2"/ffmpeg-*-static/ffprobe "$INSTALL_DIR/ffprobe" \
|| err "could not install ffprobe into $INSTALL_DIR; offset derivation for transcribe -audio will fall back to 0"
;;
esac
rm -rf "$tmp2"
say "Installed: $INSTALL_DIR/ffmpeg (user-local, no admin rights needed)"
}
dep_asr() {
if have whisperx; then say "ASR: whisperx already installed"; return; fi
if have whisper-cli; then say "ASR: whisper.cpp already installed"; return; fi
say ""
say "'testimony transcribe' needs one local ASR engine:"
say " whisperx — word-level timestamps (preferred; Python, installs user-local via uv)"
say " whisper.cpp — segment-level (Homebrew; also needs a ggml model file)"
if have brew; then
c=$(choose "Install" "whisperx" "whisper.cpp")
else
c=$(choose "Install (no Homebrew found)" "whisperx" "whisperx")
fi
case "$c" in
whisperx)
if ! have uv; then
if ask "whisperx installs via uv (user-local, no admin). Install uv first (astral.sh installer)?"; then
# Download+execute inside a private mktemp -d, not a fixed
# /tmp/uv-install.sh: a predictable, world-writable path lets
# a local attacker on a shared host pre-plant a symlink or win
# the write→exec race and run their own code as the user.
uvd=$(mktemp -d "${TMPDIR:-/tmp}/testimony-uv.XXXXXX")
fetch "https://astral.sh/uv/install.sh" "$uvd/uv-install.sh" \
|| { err "could not download the uv installer; skipping whisperx (later: uv tool install whisperx)"; rm -rf "$uvd"; return; }
sh "$uvd/uv-install.sh" \
|| { err "uv installation failed; skipping whisperx (later: uv tool install whisperx)"; rm -rf "$uvd"; return; }
rm -rf "$uvd"
# uv lands in ~/.local/bin; make it visible to this run.
PATH="$HOME/.local/bin:$PATH"; export PATH
else
say "Skipped. Later: uv tool install whisperx (or: pipx install whisperx)"
return
fi
fi
uv tool install whisperx \
|| { err "whisperx installation failed; later: uv tool install whisperx (or: pipx install whisperx)"; return; }
say "whisperx installed (user-local). First run downloads its models."
;;
whisper.cpp)
brew install whisper-cpp \
|| { err "brew install whisper-cpp failed; later: brew install whisper-cpp"; return; }
say ""
say "whisper.cpp needs a ggml model. Download once (~1.5 GB), user-local,"
say "into a directory '-model NAME' searches:"
say " mkdir -p ~/.cache/whisper.cpp && curl -fL -o ~/.cache/whisper.cpp/ggml-large-v3-turbo.bin \\"
say " https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin"
say "Then: testimony transcribe -engine whispercpp ... (-model large-v3-turbo is the default)"
;;
skip)
say "Skipped. Later: uv tool install whisperx or brew install whisper-cpp" ;;
esac
}
# The help text is embedded rather than sed-extracted from "$0": through the
# documented pipe invocation $0 is the shell's own argv[0] ("sh", or a path
# like /bin/sh whose bytes sed would happily print), not this script.
usage() {
cat <<'EOF'
testimony installer — https://github.com/REPPL/Testimony
Usage (one line):
curl -fsSL https://raw.githubusercontent.com/REPPL/Testimony/main/install.sh | sh
Passing flags through a pipe:
curl -fsSL .../install.sh | sh -s -- --yes --dir "$HOME/bin"
Flags:
-d, --dir DIR install directory (default: ~/.local/bin, or $TESTIMONY_INSTALL_DIR
when set — no admin rights needed)
-y, --yes non-interactive: accept each dependency's default install
choice (ffmpeg: brew if present, otherwise the local,
admin-free option; ASR: whisperx via uv, always)
--no-deps install the binary only; print dependency guidance and exit
--version V install release V instead of the default
-h, --help this text
EOF
}
main() {
while [ $# -gt 0 ]; do
case "$1" in
-d|--dir) [ $# -ge 2 ] && [ -n "$2" ] || die "--dir needs a value (try --help)"; INSTALL_DIR="$2"; shift 2 ;;
-y|--yes) ASSUME_YES=1; shift ;;
--no-deps) NO_DEPS=1; shift ;;
--version) [ $# -ge 2 ] && [ -n "$2" ] || die "--version needs a value (try --help)"; VERSION="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) die "unknown flag: $1 (try --help)" ;;
esac
done
say "testimony installer — release $VERSION, install dir $INSTALL_DIR"
install_binary
if [ "$NO_DEPS" = 1 ]; then
say ""
say "Dependencies skipped (--no-deps). 'testimony record' needs ffmpeg; 'testimony transcribe' needs whisperx or whisper.cpp (and ffmpeg with -audio)."
exit 0
fi
dep_ffmpeg
dep_asr
say ""
say "Done. Try: testimony demo (capture a session; speak while you click)"
}
main "$@"