Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 25 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Expand Down
51 changes: 51 additions & 0 deletions install.ps1
Original file line number Diff line number Diff line change
@@ -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 <path or URL> -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)."
34 changes: 34 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
@@ -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=<path or URL> 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 </dev/tty; fi
echo "Open a new terminal to use the hawk command (pipx added $BIN to your PATH)."
11 changes: 9 additions & 2 deletions opencode_hawk/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,15 @@ def cmd_init(_args) -> 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()
Expand Down Expand Up @@ -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)

Expand Down
176 changes: 176 additions & 0 deletions opencode_hawk/setup_wizard.py
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions tests/selftest_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading