From 3935e3ee952ffae1df37d8c9ff057b9d4944aa7f Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:13:18 +0200 Subject: [PATCH] hawk setup (guided, no config edits) and one-line installers - hawk setup: private ntfy topic + test message, llama-server detected with its port and a start script written from its exact command line, project folder, watchdog and plugin, start; --yes takes every default - install.ps1 / install.sh: pipx (existing command first, else installed), Hawk, then hawk setup; tested end to end on Windows in an isolated pipx home - README quick start leads with the installers Co-Authored-By: Claude Opus 5.5 --- README.md | 39 +++++--- install.ps1 | 51 ++++++++++ install.sh | 34 +++++++ opencode_hawk/cli.py | 11 ++- opencode_hawk/setup_wizard.py | 176 ++++++++++++++++++++++++++++++++++ tests/selftest_coordinator.py | 29 ++++++ 6 files changed, 324 insertions(+), 16 deletions(-) create mode 100644 install.ps1 create mode 100644 install.sh create mode 100644 opencode_hawk/setup_wizard.py diff --git a/README.md b/README.md index 923751d..a07cdcf 100644 --- a/README.md +++ b/README.md @@ -79,31 +79,42 @@ Hawk keeps a run going without blindly spamming "continue". ## Quick start -Install with [pipx](https://pipx.pypa.io) (Windows and Linux): +**Windows** (PowerShell): + +```powershell +irm https://raw.githubusercontent.com/ChakraFusion/opencode-hawk/main/install.ps1 | iex +``` + +**Linux:** ```sh -pipx install git+https://github.com/ChakraFusion/opencode-hawk.git -hawk init +curl -fsSL https://raw.githubusercontent.com/ChakraFusion/opencode-hawk/main/install.sh | sh ``` -`hawk init` creates `config.json` and generates a **private ntfy topic** for this -install (`hawk-` followed by 12 random digits). Then: +The installer sets up [pipx](https://pipx.pypa.io) if needed, installs Hawk and starts +the guided `hawk setup`, so no config file needs editing by hand: + +1. **Phone alerts:** a private ntfy topic is generated; subscribe to it in the ntfy + app (treat the name like a password) and get a test message. +2. **llama-server:** a running server is detected with its port; if no start script + is set, one is written from the running server's exact command line, so Hawk can + start the server again if it ever stops. +3. **Project:** the folder your agent works in. +4. **Failsafe:** the watchdog (`hawk install-watchdog`) keeps Hawk and llama-server + running; optionally the OpenCode restart plugin. +5. **Start:** Hawk starts with its dashboard on `http://127.0.0.1:8765`. -1. Open the `config.json` it printed and set `project_dir` to the repository your - agent works in, and `llama_bat_path` to the script that starts your - llama-server (without it Hawk cannot start a dead server again; it warns at - startup). -2. Subscribe to the printed topic in the ntfy app. Treat the topic name like a - password: anyone who knows it can read and send messages on it. -3. Start Hawk with `hawk run`: the monitor and the dashboard, with the dashboard - opened in your browser. Ctrl+C stops both. +Run `hawk setup` again any time to change these. Update with +`pipx upgrade opencode-hawk`. -Update with `pipx upgrade opencode-hawk`. +Manual install: `pipx install git+https://github.com/ChakraFusion/opencode-hawk.git`, +then `hawk setup` (or `hawk init` and edit `config.json` yourself). ### Commands | Command | What it does | |---|---| +| `hawk setup` | Guided setup: phone alerts, llama-server and its start script, project, watchdog, start (`--yes` takes the defaults) | | `hawk init` | Create `config.json` and the ntfy topic (keeps an existing config) | | `hawk run` | Monitor + dashboard (`--interval N` minutes, `--port P`, `--no-open`) | | `hawk monitor` | The poll loop only (`--interval`, `--dry-run`, `--session-id`) | diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..72f8df3 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,51 @@ +# Hawk installer for Windows. +# irm https://raw.githubusercontent.com/ChakraFusion/opencode-hawk/main/install.ps1 | iex +# Installs pipx if needed, installs (or upgrades) Hawk with it, then runs the guided `hawk setup`. +# Options when run as a file: -Source -Yes (setup takes every default) -SkipSetup +param( + [string]$Source = "https://github.com/ChakraFusion/opencode-hawk/archive/refs/heads/main.zip", + [switch]$Yes, + [switch]$SkipSetup +) +# Native tools write notes to stderr; Windows PowerShell would turn that into a fatal error under "Stop". +# Every step checks its exit code instead. +$ErrorActionPreference = "Continue" + +# pipx: an existing `pipx` command (pipx, scoop, ...) first, else Python's module (installed when missing). +$pipx = $null +if (Get-Command pipx -ErrorAction SilentlyContinue) { + $pipx = @((Get-Command pipx).Source) +} else { + $pyExe = $null; $pyArgs = @() + foreach ($cand in @(@("python"), @("py", "-3"))) { + $exe = Get-Command $cand[0] -ErrorAction SilentlyContinue + if (-not $exe) { continue } + $flags = @($cand | Select-Object -Skip 1) + $ok = & $exe.Source @flags -c "import sys; print(int(sys.version_info >= (3, 10)))" 2>$null + if ($ok -eq "1") { $pyExe = $exe.Source; $pyArgs = $flags; break } + } + if (-not $pyExe) { + Write-Host "Hawk needs Python 3.10 or newer. Install it (python.org or: winget install Python.Python.3.12), then run this again." -ForegroundColor Yellow + return + } + & $pyExe @pyArgs -m pipx --version 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Host "Installing pipx ..." + & $pyExe @pyArgs -m pip install --user --quiet pipx + if ($LASTEXITCODE -ne 0) { Write-Host "Could not install pipx." -ForegroundColor Red; return } + & $pyExe @pyArgs -m pipx ensurepath 2>&1 | Out-Null + } + $pipx = @($pyExe) + $pyArgs + @("-m", "pipx") +} +$pipxExe = $pipx[0]; $pipxArgs = @($pipx | Select-Object -Skip 1) + +Write-Host "Installing Hawk from $Source ..." +& $pipxExe @pipxArgs install --force $Source +if ($LASTEXITCODE -ne 0) { Write-Host "Install failed (see above)." -ForegroundColor Red; return } + +$bin = (& $pipxExe @pipxArgs environment --value PIPX_BIN_DIR 2>$null | Select-Object -Last 1).Trim() +$hawk = Join-Path $bin "hawk.exe" +Write-Host "Hawk installed: $hawk" +if ($SkipSetup) { return } +if ($Yes) { & $hawk setup --yes } else { & $hawk setup } +Write-Host "Open a new terminal to use the hawk command ($bin is on your PATH)." diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..a09bd07 --- /dev/null +++ b/install.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Hawk installer for Linux (and other Unix-like systems). +# curl -fsSL https://raw.githubusercontent.com/ChakraFusion/opencode-hawk/main/install.sh | sh +# Installs pipx if needed, installs (or upgrades) Hawk with it, then runs the guided `hawk setup`. +# Environment: HAWK_SOURCE= HAWK_YES=1 (setup takes every default) HAWK_SKIP_SETUP=1 +set -eu +SOURCE="${HAWK_SOURCE:-https://github.com/ChakraFusion/opencode-hawk/archive/refs/heads/main.zip}" + +PY="" +for c in python3 python; do + if command -v "$c" >/dev/null 2>&1 && "$c" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)'; then + PY="$c"; break + fi +done +if [ -z "$PY" ]; then + echo "Hawk needs Python 3.10 or newer; install it with your package manager, then run this again." >&2 + exit 1 +fi + +if ! "$PY" -m pipx --version >/dev/null 2>&1; then + echo "Installing pipx ..." + "$PY" -m pip install --user --quiet pipx || { + echo "pip could not install pipx; install it with your package manager (e.g. apt install pipx)." >&2; exit 1; } + "$PY" -m pipx ensurepath >/dev/null +fi + +echo "Installing Hawk from $SOURCE ..." +"$PY" -m pipx install --force "$SOURCE" +BIN="$("$PY" -m pipx environment --value PIPX_BIN_DIR)" +echo "Hawk installed: $BIN/hawk" +[ "${HAWK_SKIP_SETUP:-}" = "1" ] && exit 0 +# The setup asks questions: read them from the terminal even when this script came through a pipe. +if [ "${HAWK_YES:-}" = "1" ]; then "$BIN/hawk" setup --yes; else "$BIN/hawk" setup int: topic = coordinator.ensure_ntfy_topic(cfg) or cfg["notify"]["ntfy"]["topic"] print(f"ntfy topic for this install: {topic}") print(" subscribe to it in the ntfy app to get phone alerts (treat it like a password)") - print(f"next: set project_dir and llama_bat_path (the script that starts llama-server) in {cfg_path},") - print(" then run `hawk run`") + print("next: run `hawk setup` (guided: llama-server, project, watchdog), or edit the config by hand") return 0 +def cmd_setup(args) -> int: + from . import setup_wizard + return setup_wizard.run(assume_yes=args.yes) + + def cmd_where(_args) -> int: from . import coordinator home = coordinator.home_dir() @@ -223,6 +227,9 @@ def build_parser() -> argparse.ArgumentParser: ap.add_argument("--version", action="version", version=f"hawk {__version__}") sub = ap.add_subparsers(dest="command", required=True) + p = sub.add_parser("setup", help="guided setup: config, phone alerts, llama-server, watchdog, start") + p.add_argument("--yes", action="store_true", help="take every default without asking") + p.set_defaults(func=cmd_setup) sub.add_parser("init", help="create config.json and a private ntfy topic").set_defaults(func=cmd_init) sub.add_parser("where", help="show the folders Hawk uses").set_defaults(func=cmd_where) diff --git a/opencode_hawk/setup_wizard.py b/opencode_hawk/setup_wizard.py new file mode 100644 index 0000000..1f27aaa --- /dev/null +++ b/opencode_hawk/setup_wizard.py @@ -0,0 +1,176 @@ +"""`hawk setup`: a guided setup, so nobody has to edit config.json by hand. + +Steps: config + private ntfy topic, llama-server (detected; a start script is written from the running server's +exact command line when none is set), project folder, the failsafe watchdog, the optional OpenCode plugin, and +starting Hawk. Every question has a default; `--yes` takes all of them. +""" +from __future__ import annotations + +import os +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + + +def _say(msg: str = "") -> None: + print(msg, flush=True) + + +class Asker: + """Questions with defaults. With assume_yes, every default is taken; answers can be scripted (tests).""" + + def __init__(self, assume_yes=False, answers=None): + self.assume_yes = assume_yes + self.answers = list(answers or []) + + def text(self, question: str, default: str = "") -> str: + if self.answers: + a = self.answers.pop(0) + elif self.assume_yes: + a = "" + else: + a = input("%s%s: " % (question, " [%s]" % default if default else "")).strip() + return a or default + + def yes(self, question: str, default: bool = True) -> bool: + a = self.text("%s (%s)" % (question, "Y/n" if default else "y/N"), "").lower() + return default if not a else a.startswith(("y", "j")) + + +def _raw_config(home: Path) -> dict: + from . import coordinator + return coordinator.load_json(home / "config.json", {}) or {} + + +def _save_config(home: Path, cfg: dict) -> None: + from . import coordinator + coordinator.save_json(home / "config.json", cfg) + + +def find_llama(port_hint: int = 1234): + """A running llama-server: (pid, spec, port). Prefers the one on port_hint; else the only one running.""" + try: + import psutil + except Exception: + return None + from . import llama_restart + procs = [p for p in psutil.process_iter(["pid", "name"]) if "llama-server" in (p.info.get("name") or "").lower()] + found = [] + for p in procs: + spec = llama_restart._process_spec(p.info["pid"]) + if not spec: + continue + argv = spec["argv"] + port = 8080 # llama-server's own default + for i, a in enumerate(argv[:-1]): + if a == "--port": + try: + port = int(argv[i + 1]) + except ValueError: + pass + found.append((p.info["pid"], spec, port)) + for f in found: + if f[2] == port_hint: + return f + return found[0] if len(found) == 1 else None + + +def write_start_script(home: Path, spec: dict) -> Path: + """A start script that reproduces the running server: same folder, same command line.""" + cwd = spec.get("cwd") or str(Path(spec["argv"][0]).parent) + if os.name == "nt": + path = home / "start-llama.bat" + body = "@echo off\r\nrem Written by `hawk setup` from the running llama-server's command line.\r\n" \ + 'cd /d "%s"\r\n%s\r\n' % (cwd, subprocess.list2cmdline(spec["argv"])) + path.write_text(body, encoding="utf-8") + else: + path = home / "start-llama.sh" + body = "#!/bin/sh\n# Written by `hawk setup` from the running llama-server's command line.\ncd %s\nexec %s\n" % ( + shlex.quote(cwd), shlex.join(spec["argv"])) + path.write_text(body, encoding="utf-8") + path.chmod(0o755) + return path + + +def run(assume_yes=False, answers=None, install_tasks=True, start=True) -> int: + from . import coordinator + from .cli import PACKAGE_DIR + ask = Asker(assume_yes, answers) + home = coordinator.home_dir() + home.mkdir(parents=True, exist_ok=True) + cfg_path = home / "config.json" + _say("Hawk setup (Enter accepts the [default])") + _say("config: %s" % cfg_path) + if not cfg_path.exists(): + shutil.copyfile(PACKAGE_DIR / "config.example.json", cfg_path) + cfg = _raw_config(home) + + # 1. ntfy + topic = coordinator.ensure_ntfy_topic(cfg, persist=False) or cfg["notify"]["ntfy"]["topic"] + server = (cfg.get("notify", {}).get("ntfy", {}).get("server") or "https://ntfy.sh").rstrip("/") + _save_config(home, cfg) + _say("\n1. Phone alerts (ntfy)") + _say(" Your private topic: %s (treat it like a password)" % topic) + _say(" Subscribe in the ntfy app, or open %s/%s" % (server, topic)) + + # 2. llama-server + _say("\n2. llama-server") + port_hint = int(cfg.get("llama_api_port") or 1234) + live = find_llama(port_hint) + if live: + pid, spec, port = live + _say(" running: pid %s on port %s (%s)" % (pid, port, Path(spec["argv"][0]).name)) + cfg["llama_api_port"] = port + else: + _say(" no running llama-server found") + bat = str(cfg.get("llama_bat_path") or "").strip() + if bat and Path(bat).exists(): + _say(" start script: %s (kept)" % bat) + elif live and ask.yes(" Write a start script from the running server's command line?", True): + bat = str(write_start_script(home, live[1])) + _say(" start script written: %s" % bat) + else: + while True: + bat = ask.text(" Path to the script that starts your llama-server (Enter = skip)", "") + if not bat or Path(bat).exists(): + break + _say(" not found: %s" % bat) + if assume_yes or answers is not None and not ask.answers: + bat = "" + break + if not bat: + _say(" skipped: Hawk cannot start a dead llama-server until llama_bat_path is set (rerun hawk setup)") + cfg["llama_bat_path"] = bat + if not live: + port = ask.text(" llama-server port", str(port_hint)) + cfg["llama_api_port"] = int(port) if str(port).isdigit() else port_hint + + # 3. project folder + _say("\n3. Project") + current = str(cfg.get("project_dir") or "") + current = current if current and Path(current).is_dir() else os.getcwd() # the example's placeholder is no folder + cfg["project_dir"] = ask.text(" Folder your agent works in", current) + _save_config(home, cfg) + + # 4. failsafe + plugin + _say("\n4. Failsafe and plugin") + from . import cli + if install_tasks and ask.yes(" Install the watchdog (keeps Hawk and llama-server running)?", True): + cli.cmd_install_watchdog(type("A", (), {"remove": False, "every": 2})()) + if install_tasks and ask.yes(" Install the OpenCode llama-restart plugin (restarts only if you enable it)?", False): + cli.cmd_install_plugin(type("A", (), {"remove": False, "dir": None})()) + + # 5. test + start + _say("\n5. Start") + if ask.yes(" Send a test message to your phone?", True): + from . import notify + _say(" sent" if notify.send_test(coordinator.load_config()) else " could not send (check the network)") + if start and ask.yes(" Start Hawk now?", True): + cli.cmd_ensure(type("A", (), {"port": 8765})()) + _say(" dashboard: http://127.0.0.1:8765") + for p in coordinator.validate_config(coordinator.load_config()): + _say(" note: %s" % p) + _say("\nDone. Change anything later with `hawk setup` again or in the dashboard's settings.") + return 0 diff --git a/tests/selftest_coordinator.py b/tests/selftest_coordinator.py index 2e14b51..da49e89 100644 --- a/tests/selftest_coordinator.py +++ b/tests/selftest_coordinator.py @@ -740,6 +740,35 @@ def __init__(self, argv, **kw): print("%-22s expect=%-9s got=%-9s %s" % ("spawn_independent", "pass", "pass" if ok_brk else "fail", "OK" if ok_brk else "MISMATCH")) + # N0s: `hawk setup` without hand edits: a running llama-server becomes a start script + port, a fresh + # install gets a topic, the project folder defaults to the current one (not the example's placeholder) + import tempfile as _tf3 + from opencode_hawk import setup_wizard as _sw + _home3 = Path(_tf3.mkdtemp(prefix="hawk-setup-")) / "opencode-hawk" + _old_home3 = os.environ.get("COORD_HOME") + os.environ["COORD_HOME"] = str(_home3) + _saved_find = _sw.find_llama + _sw.find_llama = lambda port_hint=1234: (4242, {"argv": ["C:/llama/llama-server.exe", "-m", "model file.gguf", + "--port", "1300"], "cwd": "C:/llama"}, 1300) + try: + _sw.run(answers=["y", "", "n"], install_tasks=False, start=False) + _c3 = json.loads((_home3 / "config.json").read_text(encoding="utf-8")) + _script = Path(_c3["llama_bat_path"]) + _body = _script.read_text(encoding="utf-8") + ok_setup = (_script.exists() and _c3["llama_api_port"] == 1300 and "model file.gguf" in _body + and _c3["project_dir"] == os.getcwd() and _c3["notify"]["ntfy"]["topic"].startswith("hawk-")) + finally: + _sw.find_llama = _saved_find + if _old_home3 is None: + os.environ.pop("COORD_HOME", None) + else: + os.environ["COORD_HOME"] = _old_home3 + shutil.rmtree(_home3.parent, ignore_errors=True) + results.append(("setup_wizard", "pass", "pass" if ok_setup else "fail", ok_setup, + "hawk setup writes the start script, port, topic and project without hand edits", [])) + print("%-22s expect=%-9s got=%-9s %s" % ("setup_wizard", "pass", "pass" if ok_setup else "fail", + "OK" if ok_setup else "MISMATCH")) + # N0: `hawk init` on a fresh install creates the (missing) home folder and a private topic import tempfile as _tf from opencode_hawk import cli as _cli