installer: attended curl|sh flow prompts on the terminal and offers model/runtime steps

- A bare terminal invocation reads answers from the real terminal (/dev/tty
  when stdin is the curl pipe); piped data is never used as an answer and
  nothing prompts without a controlling terminal.
- After the install choices it asks y/n separately for the speech model
  download and the pip runtime install, runs the chosen steps in order and
  stops at the first failure; equivalent flag commands are printed.
- Opens with a one-line note that the user can Ctrl+C and read the script.
- Drops the OpenVR autolaunch and --without-model questions from attended
  mode; the KDE desktop entry is the launch path. Flags are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
baketnkandClaude Opus 5.5 committed 2026-09-25 10:59:04 -04:00
1 parent e5b998eb6c
commit e6cb07e8b8
5 files changed
+206 -50

No files matched your search

+7 -4
View File
@@ -20,8 +20,9 @@ a separately provisioned compatible CPU Python environment and pinned weights.
## OpenVR identity
`local.frameyap.overlay` is a string OpenVR application key, **not** a Steam
store AppID. The installer creates a manifest/desktop launcher user-locally but
does not register/launch the app by default. Explicit `frameyap --register
store AppID. The installer creates a manifest and a desktop entry user-locally; the
desktop entry (read by KDE's menu) is the intended launch path, and the app
is not registered with SteamVR or launched by default. Explicit `frameyap --register
/absolute/manifest/path` uses the OpenVR registration API; registration alone
did not reveal a launcher in the first checked dashboard menu. On one Frame the
user opened the panel from the **Non-Steam** section and quit; shortcut discovery
@@ -57,8 +58,10 @@ already be available for registration; never start/restart it for installation.
authorized runtime/model can be set in `paths.conf`.
- **Noninteractive**: supply flags and `--yes` for network/model consent.
`--print-plan` is read-only; `--json` provides structured results/errors and
progress events for a model download. No prompt reads stdin in a pipe. An
empty TTY invocation offers a local menu and prints equivalent flags.
progress events for a model download. No prompt reads answers from a pipe.
A bare terminal invocation, including `curl … | sh`, prompts on the real
terminal (`/dev/tty`), asks y/n separately for the model and runtime
downloads, and prints the equivalent flag commands.
Installation is user-local under XDG data/config paths with a managed launcher,
retained rollback, SHA-256/path validation, foreign-file refusal and a lock
+13 -5
View File
@@ -86,7 +86,7 @@ sh install.sh --archive /path/to/frameyap-VERSION-linux-aarch64.tar.gz \
After an actual vetted release exists, use a real numeric version (for example,
`sh install.sh --mode binary --version 0.1.202609241627 --yes`); the installer
will retrieve tag `v0.1.202609241627` and its versioned checksum; no `latest` or moving-branch
lookup. A pipe invocation is supported, never prompts on stdin, and must also
lookup. A pipe invocation is supported, never reads answers from the pipe, and must also
pin a real published tag. **There is no functional public download command yet.**
`--without-model` omits any model files in the selected archive, never deletes
@@ -139,9 +139,17 @@ compiler, CMake, SDK, SDL3, Wayland/scanner, libxcb, FreeType, Vulkan developmen
files and producer-supplied
licenses; it still does not install Python ASR packages. `--print-plan` performs
a read-only plan, `--json` gives machine-readable results/errors (model installs
also stream file events), and `--yes` authorizes network downloads. Bare TTY
invocation can guide choices and prints equivalent flags; non-TTY runs require
explicit arguments and never prompt. `--autolaunch`/`--no-autolaunch` are explicit
also stream file events), and `--yes` authorizes network downloads.
A bare invocation with a terminal on stdout (including `curl … | sh`) runs
the attended flow: it first notes that the user can press Ctrl+C and read the
script, asks for the install choices, then separately asks y/n before the
speech-model download (about 180 MB) and the pip runtime install, and runs the
chosen steps in order, stopping at the first failure. Answers come from the real
terminal (stdin if it is one, else `/dev/tty`); piped data is never read as an
answer, and with no controlling terminal nothing prompts. It prints the
equivalent flag commands for automation. Attended mode does not offer OpenVR
registration: the desktop entry is the launch path.
Runs with flags, or without a terminal, never prompt. `--autolaunch`/`--no-autolaunch` are explicit
OpenVR registration choices, off by default; do not pass either during an inert
install if a running SteamVR session must remain untouched.
@@ -188,7 +196,7 @@ Install root: `$XDG_DATA_HOME/frameyap` (default `~/.local/share/frameyap`);
launcher: `~/.local/bin/frameyap`; desktop entry:
`$XDG_DATA_HOME/applications/frameyap.desktop` (default
`~/.local/share/applications/frameyap.desktop`). The desktop entry points to the
user-local launcher; the installer never edits Steam's library or registers a
user-local launcher and is the intended launch path (KDE's menu reads it); the installer never edits Steam's library or registers a
Steam shortcut. Install/upgrade creates or checks `$XDG_CONFIG_HOME/frameyap/config.json`
(default `~/.config/frameyap/config.json`), filling missing properties or repairing
invalid JSON/values while preserving valid customization. Before each repair it
+57 -20
View File
@@ -1105,8 +1105,21 @@ def main(argv=None):
print("OpenVR autolaunch " + ("enabled" if args.autolaunch else "disabled") + " by explicit request")
def ask_yes_no(question):
while True:
answer = input(question + " [y/n]: ").strip().lower()
if answer in ("y", "yes"):
return True
if answer in ("n", "no"):
return False
print("Please answer y or n.")
def interactive_options():
"""Only an empty, actual terminal invocation offers a guided local choice."""
"""Only an empty, actual terminal invocation offers a guided local choice.
Returns one argv per step; each step is an ordinary flag-driven operation."""
print("If you piped this script without reading it, you can press Ctrl+C now and read it first.")
print("FrameYap installer: choose binary (verified archive) or source (local build).")
mode = input("Mode [binary/source]: ").strip().lower()
if mode not in ("binary", "source"):
@@ -1119,44 +1132,68 @@ def interactive_options():
if archive:
chosen += ["--archive", archive, "--sha256", input("SHA-256 (64 hex digits): ").strip()]
else:
if input("Download verified release v" + version + "? [yes/no]: ").strip().lower() != "yes":
if not ask_yes_no("Download verified release v" + version + "?"):
raise UsageError("download not approved; no installation started")
chosen.append("--yes")
else:
for flag in ("source", "openvr-root", "openvr-library", "openvr-license", "sdl-library", "sdl-license"):
chosen += ["--" + flag, input(flag + " local path: ").strip()]
if input("Omit archive model files? [yes/no]: ").strip().lower() == "yes":
chosen.append("--without-model")
if input("Enable OpenVR autolaunch? [yes/no]: ").strip().lower() == "yes":
chosen.append("--autolaunch")
print("Equivalent flags: " + " ".join(shlex.quote(item) for item in chosen))
return chosen
steps = [chosen]
# Each download is described and separately approved; nothing is fetched on a default.
if ask_yes_no("Download the Parakeet Redux speech model (about 180 MB, CC-BY-4.0) from Hugging Face?"):
steps.append(["--install-model", "--backend", "redux", "--yes"])
if ask_yes_no("Install the CPU Python runtime with pip (" + RUNTIME_DOWNLOAD + ")?"):
steps.append(["--install-runtime", "--yes"])
print("Equivalent commands:")
for step in steps:
print(" sh install.sh " + " ".join(shlex.quote(item) for item in step))
return steps
def attended_input():
"""The real terminal for prompts, or None. Under `curl | sh` stdin (and the
saved fd 3) is the pipe, so the controlling terminal is opened directly;
piped data is never read as answers."""
if sys.stdin.isatty():
return sys.stdin
try:
if os.isatty(3):
return os.fdopen(3, "r", closefd=False)
except OSError:
pass # Direct Python entry point, no saved shell descriptor.
try:
return open("/dev/tty", "r")
except OSError:
return None # no controlling terminal: never prompt
def cli(argv=None):
argv = sys.argv[1:] if argv is None else argv
structured = "--json" in argv
steps = [argv]
if not argv and sys.stdout.isatty():
# With `sh install.sh`, fd 0 is the embedded Python code, not the
# invoking terminal. fd 3 retains original stdin (including a pipe).
attended = sys.stdin
if not attended.isatty():
try:
if os.isatty(3):
attended = os.fdopen(3, "r", closefd=False)
except OSError:
pass # Direct Python entry point, no saved shell descriptor.
if attended.isatty():
attended = attended_input()
if attended is not None:
try:
original_stdin = sys.stdin
try:
sys.stdin = attended
argv = interactive_options()
steps = interactive_options()
finally:
sys.stdin = original_stdin
except (ValueError, EOFError) as exc:
print(f"frameyap installer: {exc}", file=sys.stderr)
except (ValueError, EOFError, KeyboardInterrupt) as exc:
print(f"frameyap installer: {exc or 'cancelled'}", file=sys.stderr)
return 2
for step in steps:
code = run_step(step)
if code:
return code
return 0
def run_step(argv):
structured = "--json" in argv
try:
if structured and "--print-plan" in argv:
main(argv) # already emits one JSON plan
+57 -20
View File
@@ -1085,8 +1085,21 @@ def main(argv=None):
print("OpenVR autolaunch " + ("enabled" if args.autolaunch else "disabled") + " by explicit request")
def ask_yes_no(question):
while True:
answer = input(question + " [y/n]: ").strip().lower()
if answer in ("y", "yes"):
return True
if answer in ("n", "no"):
return False
print("Please answer y or n.")
def interactive_options():
"""Only an empty, actual terminal invocation offers a guided local choice."""
"""Only an empty, actual terminal invocation offers a guided local choice.
Returns one argv per step; each step is an ordinary flag-driven operation."""
print("If you piped this script without reading it, you can press Ctrl+C now and read it first.")
print("FrameYap installer: choose binary (verified archive) or source (local build).")
mode = input("Mode [binary/source]: ").strip().lower()
if mode not in ("binary", "source"):
@@ -1099,44 +1112,68 @@ def interactive_options():
if archive:
chosen += ["--archive", archive, "--sha256", input("SHA-256 (64 hex digits): ").strip()]
else:
if input("Download verified release v" + version + "? [yes/no]: ").strip().lower() != "yes":
if not ask_yes_no("Download verified release v" + version + "?"):
raise UsageError("download not approved; no installation started")
chosen.append("--yes")
else:
for flag in ("source", "openvr-root", "openvr-library", "openvr-license", "sdl-library", "sdl-license"):
chosen += ["--" + flag, input(flag + " local path: ").strip()]
if input("Omit archive model files? [yes/no]: ").strip().lower() == "yes":
chosen.append("--without-model")
if input("Enable OpenVR autolaunch? [yes/no]: ").strip().lower() == "yes":
chosen.append("--autolaunch")
print("Equivalent flags: " + " ".join(shlex.quote(item) for item in chosen))
return chosen
steps = [chosen]
# Each download is described and separately approved; nothing is fetched on a default.
if ask_yes_no("Download the Parakeet Redux speech model (about 180 MB, CC-BY-4.0) from Hugging Face?"):
steps.append(["--install-model", "--backend", "redux", "--yes"])
if ask_yes_no("Install the CPU Python runtime with pip (" + RUNTIME_DOWNLOAD + ")?"):
steps.append(["--install-runtime", "--yes"])
print("Equivalent commands:")
for step in steps:
print(" sh install.sh " + " ".join(shlex.quote(item) for item in step))
return steps
def attended_input():
"""The real terminal for prompts, or None. Under `curl | sh` stdin (and the
saved fd 3) is the pipe, so the controlling terminal is opened directly;
piped data is never read as answers."""
if sys.stdin.isatty():
return sys.stdin
try:
if os.isatty(3):
return os.fdopen(3, "r", closefd=False)
except OSError:
pass # Direct Python entry point, no saved shell descriptor.
try:
return open("/dev/tty", "r")
except OSError:
return None # no controlling terminal: never prompt
def cli(argv=None):
argv = sys.argv[1:] if argv is None else argv
structured = "--json" in argv
steps = [argv]
if not argv and sys.stdout.isatty():
# With `sh install.sh`, fd 0 is the embedded Python code, not the
# invoking terminal. fd 3 retains original stdin (including a pipe).
attended = sys.stdin
if not attended.isatty():
try:
if os.isatty(3):
attended = os.fdopen(3, "r", closefd=False)
except OSError:
pass # Direct Python entry point, no saved shell descriptor.
if attended.isatty():
attended = attended_input()
if attended is not None:
try:
original_stdin = sys.stdin
try:
sys.stdin = attended
argv = interactive_options()
steps = interactive_options()
finally:
sys.stdin = original_stdin
except (ValueError, EOFError) as exc:
print(f"frameyap installer: {exc}", file=sys.stderr)
except (ValueError, EOFError, KeyboardInterrupt) as exc:
print(f"frameyap installer: {exc or 'cancelled'}", file=sys.stderr)
return 2
for step in steps:
code = run_step(step)
if code:
return code
return 0
def run_step(argv):
structured = "--json" in argv
try:
if structured and "--print-plan" in argv:
main(argv) # already emits one JSON plan
+72 -1
View File
@@ -745,8 +745,9 @@ class InstallTests(unittest.TestCase):
def test_piped_input_with_tty_output_does_not_prompt(self):
master, slave = pty.openpty()
try:
# A new session has no controlling terminal: nothing to prompt on.
child = subprocess.Popen(["sh", str(REPO / "install.sh")], stdin=subprocess.PIPE,
stdout=slave, stderr=slave,
stdout=slave, stderr=slave, start_new_session=True,
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"})
os.close(slave)
slave = -1
@@ -773,6 +774,76 @@ class InstallTests(unittest.TestCase):
if slave >= 0:
os.close(slave)
def test_curl_pipe_prompts_on_controlling_terminal_not_the_pipe(self):
import fcntl as fcntl_module
import termios
master, slave = pty.openpty()
try:
def controlling_terminal():
fcntl_module.ioctl(1, termios.TIOCSCTTY, 0)
# `curl | sh`: stdin is a pipe; the pty is stdout and the session's terminal.
child = subprocess.Popen(["sh", str(REPO / "install.sh")], stdin=subprocess.PIPE,
stdout=slave, stderr=slave, start_new_session=True,
preexec_fn=controlling_terminal,
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"})
os.close(slave)
slave = -1
output = bytearray()
try:
child.stdin.write(b"source\n") # piped data must never answer a prompt
child.stdin.close()
wrote = False
while child.poll() is None:
ready, _, _ = select.select([master], [], [], 8)
self.assertTrue(ready, "installer did not prompt on the terminal")
try:
output.extend(os.read(master, 8192))
except OSError:
break
if not wrote and b"Mode [binary/source]:" in output:
os.write(master, b"not-a-mode\n")
wrote = True
self.assertEqual(child.wait(timeout=8), 2)
self.assertIn(b"read it first", output)
self.assertIn(b"choose binary or source", output)
self.assertFalse((self.data / "frameyap").exists())
finally:
if child.poll() is None:
child.kill()
child.wait(timeout=8)
finally:
os.close(master)
if slave >= 0:
os.close(slave)
def test_attended_steps_ask_for_model_and_runtime_separately(self):
answers = iter(["binary", "0.1.202609241530", "/tmp/a.tar.gz", "a" * 64, "maybe", "y", "n"])
with patch("builtins.input", lambda prompt="": next(answers)), \
contextlib.redirect_stdout(io.StringIO()) as shown:
steps = installer.interactive_options()
self.assertEqual(steps, [["--mode", "binary", "--version", "0.1.202609241530",
"--archive", "/tmp/a.tar.gz", "--sha256", "a" * 64],
["--install-model", "--backend", "redux", "--yes"]])
self.assertIn("Please answer y or n.", shown.getvalue())
self.assertIn("sh install.sh --install-model --backend redux --yes", shown.getvalue())
answers = iter(["binary", "0.1.202609241530", "", "n"])
with patch("builtins.input", lambda prompt="": next(answers)), contextlib.redirect_stdout(io.StringIO()):
with self.assertRaisesRegex(ValueError, "download not approved"):
installer.interactive_options()
answers = iter(["binary", "0.1.202609241530", "", "yes", "n", "y"])
with patch("builtins.input", lambda prompt="": next(answers)), contextlib.redirect_stdout(io.StringIO()):
steps = installer.interactive_options()
self.assertEqual(steps[0][-1], "--yes")
self.assertEqual(steps[1:], [["--install-runtime", "--yes"]])
# Steps run in order and stop at the first failure.
calls = []
with patch.object(installer, "attended_input", return_value=sys.stdin), \
patch.object(installer, "interactive_options", return_value=[["a"], ["b"], ["c"]]), \
patch.object(installer, "run_step", side_effect=lambda argv: calls.append(argv) or (1 if argv == ["b"] else 0)), \
patch.object(installer.sys.stdout, "isatty", return_value=True):
self.assertEqual(installer.cli([]), 1)
self.assertEqual(calls, [["a"], ["b"]])
def test_attended_shell_wrapper_reads_the_actual_tty(self):
master, slave = pty.openpty()
try: