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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ Ask from the terminal, answer on your phone.

A coding agent calls nudge's `ask` tool over MCP, or runs `nudge ask --option Yes --option No "Merge the pull request?"` in its shell. Your phone shows a notification; you tap an option or type a reply; the answer goes back to the agent, and the agent carries on.

nudge is being built step by step. The plan is in [`docs/plan.md`](docs/plan.md), and the rules for working on it are in [`AGENTS.md`](AGENTS.md). [`docs/setup.md`](docs/setup.md) says how to install it and run it on a Mac.
nudge is being built step by step. The plan is in [`docs/plan.md`](docs/plan.md), and the rules for working on it are in [`AGENTS.md`](AGENTS.md). [`docs/setup.md`](docs/setup.md) says how to set up a machine and the phone.
4 changes: 3 additions & 1 deletion docs/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ This document is the source of truth for scope and the work breakdown. How we wo
| `server-setting` | A `server` key in the settings file, read by `nudge ask` and `nudge mcp` as `NUDGE_SERVER` is (decision 36). `nudge setup` doesn't write it yet; the user adds it to the settings file by hand | Done |
| `image-workflow` | `.gitea/workflows/image.yaml`: builds with BuildKit and pushes `git.tail9e9088.ts.net/codedogapp/nudge` (`:main` and `:sha-<7>` for main, `:vX.Y.Z` for a tag) from the Gitea mirror. Needs the mirror with Actions on (the user, in Gitea); untested until then | Todo |
| `k8s-manifests` | `cluster/nudge/` in the homelab repo (plain YAML, Flux): Deployment, volume, Service, Ingress, and a doc there (decision 36). `codedogapp/homelab#7`, for the user's review; the namespace and the Secret `nudge` already exist in the cluster | In review |
| `cutover` | Tag v0.4.0, deploy, point the Macs and the iPhone at the cluster; after the user confirms it works, stop the Mac's `brew services` nudge and `tailscale serve` | Todo |
| `setup-client` | `nudge setup SERVER` becomes the client setup: it writes the `server` setting, writes the skill from the binary, and adds both to Copilot CLI (decision 37) | Done |
| `cutover` | The cluster serves the phone and the agents (checked 2026-10-27). The Mac's brew service, `tailscale serve`, formula and tap are retired; the binary comes from `go install`. Left for the user: delete the GitHub repo `codedogapp/homebrew-tap` (`gh auth refresh -s delete_repo`, then `gh repo delete`) | In review |
| `multi-device` | The steps above, together: the user's end check that a question from a second machine reaches the iPhone (decisions 32, 34 and 35) | Todo |
| `swift-spike` | A spike on a native iOS app (decision 32), findings in a docs PR | Done (#39): [swift-spike.md](swift-spike.md), desk research only |

Expand Down Expand Up @@ -167,6 +168,7 @@ sequenceDiagram
34. **Multi-device**: the user's choice (2026-10-25): one nudge server, on the personal Mac, and one page with every question labelled by its machine; the work laptop is on the same tailnet and runs only `nudge ask` and `nudge mcp`, with no server. Proposal, for the user's review: the phone door gets an agent API under `/agent/`, with the endpoints of the agent door, and the same lock (the Tailscale login), so a laptop on the tailnet under the same login can reach it through `tailscale serve`. `nudge ask` and `nudge mcp` then read `NUDGE_SERVER` (an https URL) in place of the socket, and send the machine's name, which the page shows next to each question and session. It breaks "Two doors, two locks" (AGENTS.md), and any process that holds the login can then ask or answer; so it stays unbuilt until the user accepts it (§6 question 7).
35. **Multi-device, where the server runs**: the user's choices (2026-10-26): they accept agents on other machines reaching the server through the tailnet door, with the same Tailscale login on every machine (AGENTS.md's two-doors rule is amended). The server will run in an always-on local Kubernetes cluster on another laptop, and the tailnet will reach the whole cluster, so the Tailscale Kubernetes operator (or a sidecar) can publish it and add the login headers. Consequences, my design for the user's review: the VAPID key and the settings come from a file or the environment where there is no Keychain (a Kubernetes Secret), the subscriptions need a small volume or devices subscribe again after a restart, and the image is built from this repo. The Mac's `brew services` install stays for a single-machine setup. The agent API lies under `/agent/` on the phone door and is no different for a local agent that chooses `NUDGE_SERVER`.
36. **Hosting in the homelab** (the user's choices, 2026-10-27): the k3s homelab is ready (single node, `linux/amd64`, Flux from GitHub, Gitea with a registry and BuildKit, the Tailscale operator). Plan, my design for the user's review: the image is built by a Gitea workflow from the mirror of this repo and lives in Gitea's registry; the manifests are plain YAML in the homelab repo's `cluster/nudge/` (that repo's convention), written by me and reviewed by the user; an Ingress of class `tailscale` publishes the page on `https://nudge.tail9e9088.ts.net`, so `NUDGE_HOST` becomes that name and devices subscribe again at the new address; one replica with the `Recreate` strategy, because the server keeps questions in memory; a small volume holds the VAPID key and the subscriptions (`NUDGE_VAPID_KEY_FILE`, `NUDGE_SUBSCRIPTIONS`); client machines get the server from a `server` key in their settings file, which `nudge ask` and `nudge mcp` read. After the user confirms the cluster works, the Mac's service and `tailscale serve` are retired.
37. **Homebrew retired** (the user's choices, 2026-10-27): with the server in the cluster, nothing runs on a Mac but `nudge mcp` and `nudge ask`, so the tap, the formula and `brew services` go, and clients install with `go install github.com/codedogapp/nudge@latest` (the user chose it over release binaries and over an HTTP MCP endpoint). `nudge setup SERVER` replaces the Mac setup of decision 33: no `tailscale serve`, no settings from `tailscale status`; the skill is embedded in the binary, since no formula installs its folder. Decisions 33 and the Homebrew facts and risk 11 are history. The Mac's Keychain VAPID key and the local subscriptions file were deleted; the cluster has its own key, so each phone subscribed again.

## 5. Risks

Expand Down
60 changes: 15 additions & 45 deletions docs/setup.md
Original file line number Diff line number Diff line change
@@ -1,36 +1,26 @@
# Setup

How to install nudge on a Mac with Homebrew, what it needs before `nudge serve` can run (its settings, and `tailscale serve` to publish the phone door on the tailnet), and how to run it at login. [The plan](plan.md) says why (§3, decisions 7, 8, 23, 24 and 26 to 28). The last two sections add nudge to an agent, and give the agent the skill that sends its questions to the phone.
nudge's server runs in the homelab cluster, at `https://nudge.<tailnet>.ts.net` ([the homelab's doc 22](https://github.com/codedogapp/homelab/blob/main/docs/22-nudge.md); [the plan](plan.md), decision 36). This page says how to set up a machine and the phone to use it, and what `nudge serve` needs wherever it runs. [The plan](plan.md) says why (§3, decisions 7, 8, 23, 24 and 26 to 28).

## Install

Homebrew installs nudge from codedogapp's tap, [`codedogapp/homebrew-tap`](https://github.com/codedogapp/homebrew-tap):
A machine that only asks needs the binary for `nudge mcp` and `nudge ask`. With Go installed:

```sh
brew install codedogapp/tap/nudge
go install github.com/codedogapp/nudge@latest
```

The formula builds nudge from its tag's source on the Mac, so Homebrew installs Go for the build, or upgrades it.

Give every `brew` command the formula's full name, `codedogapp/tap/nudge`. Homebrew's own cask `nudge` is another app, and a bare `nudge` in `brew install`, `brew upgrade` or `brew info` means that cask. The full name in `brew install` also trusts the formula: Homebrew loads formulae from taps other than its own only once they're trusted ([the plan](plan.md), fact 14).

`nudge` is then on the `PATH`. Homebrew also links it at `$(brew --prefix codedogapp/tap/nudge)/bin/nudge`, a path that stays the same across upgrades; agents start it from there ([Agents](#agents)). To take a new version:

```sh
brew upgrade codedogapp/tap/nudge
```

Then restart the service, which still runs the old one ([Run at login](#run-at-login)).
The binary lands in `$(go env GOBIN)`, else `~/go/bin`. Run the same command to update it.

## Quick setup

On a new Mac, install nudge ([Install](#install)) and run:
Run, with the server's address:

```sh
nudge setup
nudge setup https://nudge.<tailnet>.ts.net
```

It does the steps of [Settings](#settings), [Tailscale](#tailscale) and [Agents](#agents) in one go, and prints a line for each ([the plan](plan.md), decision 33): it writes the settings file from `tailscale status --json`, unless the file exists; it publishes the phone door with `tailscale serve`; and it adds the MCP server and the skill to Copilot CLI, leaving an MCP server that is already there. It is safe to run again. It doesn't start or restart the service: do that with [Run at login](#run-at-login), then do [The Home Screen](#the-home-screen) and [Notifications](#notifications) on the phone. The sections named above say what each step does, and how to do it by hand.
It does the steps of [Settings](#settings) and [Agents](#agents) in one go, and prints a line for each ([the plan](plan.md), decision 37): it writes the settings file with the `server`, unless the file exists; it writes the skill into the config folder; and it adds the MCP server, which runs this binary, and the skill to Copilot CLI, leaving an MCP server that is already there. It is safe to run again, and refreshes the skill. Then do [The Home Screen](#the-home-screen) and [Notifications](#notifications) on the phone.

## Settings

Expand All @@ -47,7 +37,7 @@ It does the steps of [Settings](#settings), [Tailscale](#tailscale) and [Agents]
| `NUDGE_SERVER` | `server` | For `nudge ask` and `nudge mcp` on another machine: the https URL of the machine's `nudge serve` on the tailnet, such as `https://machine.tailnet.ts.net:8443`. When the variable or the key is set (the variable wins), they use it in place of the socket, and the Tailscale login of the machine must be the one that `nudge serve` lets in ([the plan](plan.md), decision 35) | The socket |
| `NUDGE_SUBSCRIPTIONS` | `subscriptions` | The file that keeps the devices' push subscriptions. In the settings file, an absolute path | `~/Library/Application Support/nudge/subscriptions.json` |

The settings file suits `brew services`, which starts `nudge serve` without the shell's variables ([Run at login](#run-at-login)). It holds a JSON object:
The settings file suits a service, which starts `nudge serve` without the shell's variables. On a client machine it holds the `server` alone; where `nudge serve` runs outside the cluster, it holds a JSON object:

```json
{
Expand Down Expand Up @@ -107,26 +97,6 @@ Answer it on the page: tap an option, send a reply, or type a reply and then tap

In the checkout, `mise run serve` and `go run . ask` do the same from the source.

## Run at login

Once nudge is installed ([Install](#install)) and the settings file holds its settings ([Settings](#settings)), start the service:

```sh
brew services start codedogapp/tap/nudge
```

It writes the launchd agent `~/Library/LaunchAgents/sh.brew.nudge.plist` and loads it. launchd starts `nudge serve` at once and at each login, and starts it again whenever it stops. It starts it with none of the shell's variables, so `nudge serve` takes its settings from the settings file. `tailscale serve` must stay on ([Tailscale](#tailscale)).

The service's log is `$(brew --prefix)/var/log/nudge.log`, which gets the refused requests and the errors, and which nothing rotates. When `nudge serve` can't start, launchd tries again every 10 seconds, and the log says why.

The service keeps the binary and the settings it started with: restart it after `brew upgrade` or a settings change. A `nudge serve` in a shell can't run next to the service, which holds the port and the socket. To restart the service, to see it, and to stop it and keep it from starting at login:

```sh
brew services restart codedogapp/tap/nudge
brew services info codedogapp/tap/nudge
brew services stop codedogapp/tap/nudge
```

## The VAPID key

`nudge serve` signs its Web Pushes with a VAPID key. On its first start, it makes one and adds it to the login Keychain as service `nudge`, account `vapid-private-key`; later starts read it from there ([the plan](plan.md), decision 11). To print it:
Expand Down Expand Up @@ -173,30 +143,30 @@ Each visit sends the device's subscription to `nudge serve` again ([the plan](pl

## Agents

An agent asks through `nudge mcp`, an MCP server with two tools, `ask`, which waits for your answer ([the plan](plan.md), decision 17), and `report`, which sets a note on the session that the page shows ([the plan](plan.md), decision 31). The agent starts the server itself, from the binary that Homebrew installed ([Install](#install)). Add it to Copilot CLI, with a timeout of a day:
An agent asks through `nudge mcp`, an MCP server with two tools, `ask`, which waits for your answer ([the plan](plan.md), decision 17), and `report`, which sets a note on the session that the page shows ([the plan](plan.md), decision 31). The agent starts the server itself, from the binary that `go install` made ([Install](#install)). `nudge setup` does this; by hand, add it to Copilot CLI, with a timeout of a day:

```sh
copilot mcp add --timeout 86400000 nudge -- "$(brew --prefix codedogapp/tap/nudge)/bin/nudge" mcp
copilot mcp add --timeout 86400000 nudge -- "$(go env GOPATH)/bin/nudge" mcp
```

Keep the name `nudge`: the agent sees the tool as `nudge-ask`, and the skill calls it that and approves it as `nudge(ask)`; the same goes for `nudge-report` and `nudge(report)` ([The skill](#the-skill)).

Copilot CLI ends a tool call that runs longer than the timeout, and the question goes away with it. Without `--timeout`, Copilot CLI 1.0.102 ended a question after three minutes (risk 9). `copilot mcp add` keeps the server in `~/.copilot/mcp-config.json`, and `copilot mcp get nudge` shows it.

Copilot CLI starts `nudge mcp` in the session's folder, so a question's project is that folder's name. `nudge mcp` reaches `nudge serve` through the same socket as `nudge ask`: when `NUDGE_SOCKET` isn't the default, add `--env NUDGE_SOCKET=<path>` before the `--`.
Copilot CLI starts `nudge mcp` in the session's folder, so a question's project is that folder's name. `nudge mcp` reaches `nudge serve` the same way as `nudge ask`: through `NUDGE_SERVER` or the settings file's `server`, else through the socket.

Copilot CLI can ask at the Mac for permission before the agent uses the tool, which defeats the point when you're away. The skill lets the agent ask without that prompt once it's loaded ([The skill](#the-skill)). Without the skill, start Copilot CLI with `--allow-tool 'nudge(ask)'`.

## The skill

The `nudge` skill, `skills/nudge/SKILL.md` in the repo, tells a Copilot CLI session to ask every question through nudge, never with `ask_user` or in the chat, to report its progress with `nudge-report`, and to run `nudge ask` when the MCP server isn't there ([the plan](plan.md), decision 23). A session loads it only when you ask for it: before you step away, start a prompt with `/nudge`, as in `/nudge Fix the failing test`. A prompt that asks the agent to ask you on your phone may load it too, but that's up to the model. It holds until you tell the agent you're back.
The `nudge` skill, `internal/setup/skills/nudge/SKILL.md` in the repo, tells a Copilot CLI session to ask every question through nudge, never with `ask_user` or in the chat, to report its progress with `nudge-report`, and to run `nudge ask` when the MCP server isn't there ([the plan](plan.md), decision 23). A session loads it only when you ask for it: before you step away, start a prompt with `/nudge`, as in `/nudge Fix the failing test`. A prompt that asks the agent to ask you on your phone may load it too, but that's up to the model. It holds until you tell the agent you're back.

Homebrew installs the skill with nudge. Register its folder with Copilot CLI:
`nudge setup` writes the skill into `~/Library/Application Support/nudge/skills` and registers that folder with Copilot CLI. By hand, copy `internal/setup/skills/nudge` there and run:

```sh
copilot skill add "$(brew --prefix codedogapp/tap/nudge)/share/nudge/skills"
copilot skill add "$HOME/Library/Application Support/nudge/skills"
```

`copilot skill list` then shows `nudge`, and `/skills info nudge` in a session shows its details. A session that is already running finds it after `/skills reload`. Copilot CLI reads the skill from the folder each time, through a path that an upgrade keeps, so a new version's skill reaches the next session ([the plan](plan.md), fact 13).
`copilot skill list` then shows `nudge`, and `/skills info nudge` in a session shows its details. A session that is already running finds it after `/skills reload`. Copilot CLI reads the skill from the folder each time, and `nudge setup` refreshes it ([the plan](plan.md), fact 13).

Once a session loads the skill, the agent may use the `ask` tool and run `nudge` in the shell without asking you for permission first: the skill's `allowed-tools` approves `nudge(ask)`, `nudge(report)` and `shell(nudge:*)` for the rest of the session.
24 changes: 24 additions & 0 deletions internal/cli/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package cli_test

import (
"os"
"testing"
)

// TestMain gives the tests an empty home, so that the user's own settings file never changes
// what they do.
func TestMain(m *testing.M) {
home, err := os.MkdirTemp("", "nudge-cli-test")
if err != nil {
panic(err)
}

_ = os.Setenv("HOME", home)
_ = os.Setenv("XDG_CONFIG_HOME", home)

code := m.Run()

_ = os.RemoveAll(home)

os.Exit(code)
}
6 changes: 3 additions & 3 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
const usage = `usage: nudge serve
nudge ask [--option TEXT]... [--details TEXT] QUESTION
nudge mcp
nudge setup`
nudge setup SERVER`

// The exit codes: done, an error, and a usage error.
const (
Expand Down Expand Up @@ -42,11 +42,11 @@ func (p Program) Run(ctx context.Context, args []string, stdout, stderr io.Write
case "mcp":
return p.serveMCP(ctx, args[1:], &mcp.StdioTransport{}, stderr)
case "setup":
if len(args) != 1 {
if len(args) != 2 {
return misused(stderr)
}

return runSetup(ctx, setup.Exec, stdout, stderr)
return setupJob{run: setup.Exec, server: args[1]}.runSetup(ctx, stdout, stderr)
default:
return misused(stderr)
}
Expand Down
3 changes: 2 additions & 1 deletion internal/cli/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ func TestRunPrintsTheUsage(t *testing.T) {
{"ask with an unknown flag", []string{"ask", "--colour", "red", "Merge?"}},
{"ask for help", []string{"ask", "--help"}},
{"mcp with an argument", []string{"mcp", "now"}},
{"setup with an argument", []string{"setup", "now"}},
{"setup with no server", []string{"setup"}},
{"setup with two", []string{"setup", "a", "b"}},
}

for _, test := range tests {
Expand Down
Loading
Loading