diff --git a/README.md b/README.md index 0f15520..6134575 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/plan.md b/docs/plan.md index 92e3154..ea27fcc 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -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 | @@ -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 diff --git a/docs/setup.md b/docs/setup.md index c448222..e3f1552 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -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..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..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 @@ -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 { @@ -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: @@ -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=` 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. diff --git a/internal/cli/main_test.go b/internal/cli/main_test.go new file mode 100644 index 0000000..b1999d1 --- /dev/null +++ b/internal/cli/main_test.go @@ -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) +} diff --git a/internal/cli/run.go b/internal/cli/run.go index 42abd26..ca77ec6 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -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 ( @@ -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) } diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 6f3c155..9a4fbe4 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -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 { diff --git a/internal/cli/setup.go b/internal/cli/setup.go index 0732f53..74b1916 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -5,24 +5,38 @@ import ( "fmt" "io" "os" + "path/filepath" "github.com/codedogapp/nudge/internal/setup" ) -const afterSetup = `Next: restart the service if it runs (brew services restart codedogapp/tap/nudge), -open the page on the iPhone, add it to the Home Screen and turn notifications on (docs/setup.md).` +const afterSetup = `Start a Copilot session with /nudge to ask on the phone (docs/setup.md).` -// runSetup does the setup steps in order, prints what each did, and stops at the first that fails. -func runSetup(ctx context.Context, run setup.Run, stdout, stderr io.Writer) int { +// setupJob is what nudge setup does: the commands it runs, and the server it points at. +type setupJob struct { + run setup.Run + server string +} + +// runSetup points this machine at the nudge server, adds nudge to Copilot CLI, prints what each +// step did, and stops at the first that fails. +func (job setupJob) runSetup(ctx context.Context, stdout, stderr io.Writer) int { config, err := os.UserConfigDir() if err != nil { return failed(stderr, "setup", fmt.Errorf("find the config folder: %w", err)) } + bin, err := os.Executable() + if err != nil { + return failed(stderr, "setup", fmt.Errorf("find this program: %w", err)) + } + + skills := filepath.Join(config, "nudge", "skills") + steps := []func() ([]string, error){ - func() ([]string, error) { return one(setup.WriteSettings(ctx, run, settingsFile(config))) }, - func() ([]string, error) { return one(setup.Publish(ctx, run)) }, - func() ([]string, error) { return setup.AddToCopilot(ctx, run) }, + func() ([]string, error) { return one(setup.WriteSettings(settingsFile(config), job.server)) }, + func() ([]string, error) { return nil, setup.WriteSkill(skills) }, + func() ([]string, error) { return setup.AddToCopilot(ctx, job.run, bin, skills) }, } for _, step := range steps { diff --git a/internal/cli/setup_internal_test.go b/internal/cli/setup_internal_test.go index faf79f0..2c1891a 100644 --- a/internal/cli/setup_internal_test.go +++ b/internal/cli/setup_internal_test.go @@ -9,9 +9,6 @@ import ( "testing" ) -const tailnetStatus = `{"Self":{"UserID":1,"DNSName":"machine.tailnet.ts.net."}, -"User":{"1":{"LoginName":"user@example.com"}}}` - // fakeTools answers the setup steps' commands; the tool named failing fails. func fakeTools(failing string) func(context.Context, string, ...string) ([]byte, error) { return func(_ context.Context, name string, args ...string) ([]byte, error) { @@ -20,10 +17,6 @@ func fakeTools(failing string) func(context.Context, string, ...string) ([]byte, switch { case name == failing: return nil, errors.New("broken") - case line == "tailscale status --json": - return []byte(tailnetStatus), nil - case name == "brew": - return []byte("/opt/homebrew/opt/nudge\n"), nil case strings.HasPrefix(line, "copilot mcp get"): return nil, &exec.ExitError{} } @@ -38,11 +31,12 @@ func TestSetupRunsEveryStep(t *testing.T) { var stdout, stderr bytes.Buffer - if code := runSetup(t.Context(), fakeTools(""), &stdout, &stderr); code != exitOK { + job := setupJob{fakeTools(""), "https://nudge.tailnet.ts.net"} + if code := job.runSetup(t.Context(), &stdout, &stderr); code != exitOK { t.Fatalf("exit code = %d, stderr = %q", code, &stderr) } - for _, want := range []string{"settings: wrote", "tailscale: serving", "copilot: added the MCP", "Home Screen"} { + for _, want := range []string{"settings: wrote", "copilot: added the MCP", "/nudge"} { if !strings.Contains(stdout.String(), want) { t.Errorf("stdout = %q, want %q", &stdout, want) } @@ -55,17 +49,13 @@ func TestSetupStopsAtTheFirstFailure(t *testing.T) { var stdout, stderr bytes.Buffer - if code := runSetup( - t.Context(), - fakeTools("tailscale"), - &stdout, - &stderr, - ); code != exitError { + job := setupJob{fakeTools("copilot"), "https://nudge.tailnet.ts.net"} + if code := job.runSetup(t.Context(), &stdout, &stderr); code != exitError { t.Errorf("exit code = %d, want %d", code, exitError) } if !strings.Contains(stderr.String(), "nudge setup:") || - strings.Contains(stdout.String(), "copilot") { + strings.Contains(stdout.String(), "added") { t.Errorf("stdout = %q, stderr = %q", &stdout, &stderr) } } diff --git a/internal/setup/agent.go b/internal/setup/agent.go index 1183405..2ca560e 100644 --- a/internal/setup/agent.go +++ b/internal/setup/agent.go @@ -5,33 +5,20 @@ import ( "errors" "fmt" "os/exec" - "path/filepath" - "strings" ) -const ( - formula = "codedogapp/tap/nudge" - // The tool call waits for the user's answer, so its timeout is a day. - mcpTimeout = "86400000" -) - -// AddToCopilot adds nudge's MCP server and its skill to Copilot CLI, and returns a line for each -// that says what it did. Both paths lie under Homebrew's prefix for the formula, which an -// upgrade keeps. An MCP server that Copilot CLI already has stays as it is. -func AddToCopilot(ctx context.Context, run Run) ([]string, error) { - out, err := run(ctx, "brew", "--prefix", formula) - if err != nil { - return nil, fmt.Errorf("find the nudge formula: %w", err) - } - - prefix := strings.TrimSpace(string(out)) +// The tool call waits for the user's answer, so its timeout is a day. +const mcpTimeout = "86400000" - server, err := addServer(ctx, run, filepath.Join(prefix, "bin", "nudge")) +// AddToCopilot adds nudge's MCP server, which runs bin, and the skill in skills to Copilot CLI, +// and returns a line for each that says what it did. An MCP server that Copilot CLI already has +// stays as it is. +func AddToCopilot(ctx context.Context, run Run, bin, skills string) ([]string, error) { + server, err := addServer(ctx, run, bin) if err != nil { return nil, err } - skills := filepath.Join(prefix, "share", "nudge", "skills") if _, err := run(ctx, "copilot", "skill", "add", skills); err != nil { return nil, fmt.Errorf("add the skill: %w", err) } diff --git a/internal/setup/agent_test.go b/internal/setup/agent_test.go index f3dcadb..ddc0dc6 100644 --- a/internal/setup/agent_test.go +++ b/internal/setup/agent_test.go @@ -11,7 +11,7 @@ import ( "github.com/codedogapp/nudge/internal/setup" ) -// copilot is a fake brew and Copilot CLI that records each command line. +// copilot is a fake Copilot CLI that records each command line. type copilot struct { ran []string hasServer bool @@ -26,8 +26,6 @@ func (tool *copilot) run(_ context.Context, name string, args ...string) ([]byte switch { case tool.failing != "" && strings.HasPrefix(line, tool.failing): return nil, tool.failWith - case strings.HasPrefix(line, "brew"): - return []byte("/opt/homebrew/opt/nudge\n"), nil case strings.HasPrefix(line, "copilot mcp get") && !tool.hasServer: return nil, &exec.ExitError{} } @@ -38,14 +36,14 @@ func (tool *copilot) run(_ context.Context, name string, args ...string) ([]byte func TestCopilotGetsTheServerAndTheSkill(t *testing.T) { tool := &copilot{} - lines, err := setup.AddToCopilot(t.Context(), tool.run) + lines, err := setup.AddToCopilot(t.Context(), tool.run, "/bin/nudge", "/skills") if err != nil || len(lines) != 2 { t.Fatalf("got %v, %v", lines, err) } for _, want := range []string{ - "copilot mcp add --timeout 86400000 nudge -- /opt/homebrew/opt/nudge/bin/nudge mcp", - "copilot skill add /opt/homebrew/opt/nudge/share/nudge/skills", + "copilot mcp add --timeout 86400000 nudge -- /bin/nudge mcp", + "copilot skill add /skills", } { if !slices.Contains(tool.ran, want) { t.Errorf("ran %q, want %q among them", tool.ran, want) @@ -56,7 +54,7 @@ func TestCopilotGetsTheServerAndTheSkill(t *testing.T) { func TestAnExistingServerStays(t *testing.T) { tool := &copilot{hasServer: true} - lines, err := setup.AddToCopilot(t.Context(), tool.run) + lines, err := setup.AddToCopilot(t.Context(), tool.run, "/bin/nudge", "/skills") if err != nil || !strings.Contains(lines[0], "kept") { t.Fatalf("got %v, %v", lines, err) } @@ -71,7 +69,6 @@ func TestAnExistingServerStays(t *testing.T) { func TestEachFailureIsAnError(t *testing.T) { boom := errors.New("boom") tests := map[string]*copilot{ - "no brew": {failing: "brew", failWith: boom}, "no copilot": {failing: "copilot mcp get", failWith: boom}, "the add fails": {failing: "copilot mcp add", failWith: boom}, "the skill fails": {failing: "copilot skill add", failWith: boom}, @@ -79,7 +76,15 @@ func TestEachFailureIsAnError(t *testing.T) { for name, broken := range tests { t.Run(name, func(t *testing.T) { - if _, err := setup.AddToCopilot(t.Context(), broken.run); !errors.Is(err, boom) { + if _, err := setup.AddToCopilot( + t.Context(), + broken.run, + "/bin/nudge", + "/skills", + ); !errors.Is( + err, + boom, + ) { t.Errorf("err = %v, want it to wrap boom", err) } }) diff --git a/internal/setup/publish.go b/internal/setup/publish.go deleted file mode 100644 index 9591574..0000000 --- a/internal/setup/publish.go +++ /dev/null @@ -1,16 +0,0 @@ -package setup - -import ( - "context" - "fmt" -) - -// Publish has Tailscale serve the phone door, nudge serve's default address, on port 8443 of the -// tailnet name, and returns a line that says so. Tailscale takes the same command a second time. -func Publish(ctx context.Context, run Run) (string, error) { - if _, err := run(ctx, "tailscale", "serve", "--bg", "--https=8443", "8700"); err != nil { - return "", fmt.Errorf("publish the phone door: %w", err) - } - - return "tailscale: serving the phone door on port 8443", nil -} diff --git a/internal/setup/publish_test.go b/internal/setup/publish_test.go deleted file mode 100644 index 85b17ec..0000000 --- a/internal/setup/publish_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package setup_test - -import ( - "context" - "errors" - "slices" - "testing" - - "github.com/codedogapp/nudge/internal/setup" -) - -func TestPublishServesThePhoneDoor(t *testing.T) { - var got []string - - run := func(_ context.Context, name string, args ...string) ([]byte, error) { - got = append([]string{name}, args...) - return nil, nil - } - - if _, err := setup.Publish(t.Context(), run); err != nil { - t.Fatal(err) - } - - want := []string{"tailscale", "serve", "--bg", "--https=8443", "8700"} - if !slices.Equal(got, want) { - t.Errorf("ran %v, want %v", got, want) - } -} - -func TestPublishFailsWithTheToolsError(t *testing.T) { - _, err := setup.Publish(t.Context(), answering("", errors.New("denied"))) - if err == nil { - t.Error("got no error") - } -} diff --git a/internal/setup/run.go b/internal/setup/run.go index a481cc6..f342ca8 100644 --- a/internal/setup/run.go +++ b/internal/setup/run.go @@ -1,5 +1,5 @@ -// Package setup does the one-time work that nudge needs on a new Mac: its settings file, the -// tailnet's publication of the phone door, and the agent's entries for the MCP server and the skill. +// Package setup does the one-time work that nudge needs on a new machine: its settings file, and +// the agent's entries for the MCP server and the skill. package setup import ( diff --git a/internal/setup/settings.go b/internal/setup/settings.go index b69400a..09fef7e 100644 --- a/internal/setup/settings.go +++ b/internal/setup/settings.go @@ -1,84 +1,46 @@ package setup import ( - "context" - "encoding/json/jsontext" - "encoding/json/v2" "errors" "fmt" "io/fs" + "net/url" "os" "path/filepath" - "strconv" - "strings" ) -// Folder and file modes: only the user can open them, as nudge serve needs. +// Folder and file modes: only the user can open them. const ( folderMode = 0o700 fileMode = 0o600 ) -type tailscaleStatus struct { - Self struct { - UserID int64 - DNSName string - } - User map[string]struct{ LoginName string } -} - -type settings struct { - Login string `json:"login"` - Host string `json:"host"` -} - -// WriteSettings writes the settings file at path from what `tailscale status --json` says about -// this Mac, and returns a line that says what it did. A file that exists stays as it is. -func WriteSettings(ctx context.Context, run Run, path string) (string, error) { - if _, err := os.Stat(path); err == nil { - return "settings: kept " + path, nil - } else if !errors.Is(err, fs.ErrNotExist) { - return "", fmt.Errorf("check the settings file: %w", err) - } - - out, err := run(ctx, "tailscale", "status", "--json") - if err != nil { - return "", fmt.Errorf("read the tailnet's status: %w", err) - } - - found, err := parseStatus(out) - if err != nil { - return "", err - } - - data, err := json.Marshal(found, jsontext.WithIndent(" ")) - if err != nil { - return "", fmt.Errorf("encode the settings: %w", err) +// WriteSettings writes the settings file at path with the server's address, an https URL, and +// returns a line that says what it did. A file that exists stays as it is. +func WriteSettings(path, server string) (string, error) { + if parsed, err := url.Parse( + server, + ); err != nil || parsed.Scheme != "https" || + parsed.Host == "" { + return "", fmt.Errorf("the server %q isn't an https URL", server) } if err := os.MkdirAll(filepath.Dir(path), folderMode); err != nil { return "", fmt.Errorf("make the settings folder: %w", err) } - if err := os.WriteFile(path, append(data, '\n'), fileMode); err != nil { - return "", fmt.Errorf("write the settings file: %w", err) + //nolint:gosec // G304: the path is nudge's own settings file, in the user's config folder. + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, fileMode) + if errors.Is(err, fs.ErrExist) { + return "settings: kept " + path, nil } - return "settings: wrote " + path, nil -} - -func parseStatus(out []byte) (settings, error) { - var status tailscaleStatus - if err := json.Unmarshal(out, &status); err != nil { - return settings{}, fmt.Errorf("read the tailnet's status: %w", err) + if err != nil { + return "", fmt.Errorf("write the settings file: %w", err) } - login := status.User[strconv.FormatInt(status.Self.UserID, 10)].LoginName - host := strings.TrimSuffix(status.Self.DNSName, ".") - - if login == "" || host == "" { - return settings{}, errors.New("the tailnet's status has no login or host: is Tailscale up?") - } + // %q's escapes are JSON's, for a URL that url.Parse took. + _, err = fmt.Fprintf(file, "{\n \"server\": %q\n}\n", server) - return settings{Login: login, Host: host}, nil + return "settings: wrote " + path, errors.Join(err, file.Close()) } diff --git a/internal/setup/settings_test.go b/internal/setup/settings_test.go index 185d318..d0d14de 100644 --- a/internal/setup/settings_test.go +++ b/internal/setup/settings_test.go @@ -1,8 +1,6 @@ package setup_test import ( - "context" - "errors" "os" "path/filepath" "strings" @@ -11,35 +9,21 @@ import ( "github.com/codedogapp/nudge/internal/setup" ) -const status = `{"Self":{"UserID":42,"DNSName":"machine.tailnet.ts.net."}, -"User":{"42":{"LoginName":"user@example.com"},"7":{"LoginName":"other@example.com"}}}` - -func answering(out string, err error) setup.Run { - return func(context.Context, string, ...string) ([]byte, error) { return []byte(out), err } -} - -func TestSettingsComeFromTheTailnetsStatus(t *testing.T) { +func TestSettingsHoldTheServer(t *testing.T) { path := filepath.Join(t.TempDir(), "nudge", "settings.json") - line, err := setup.WriteSettings(t.Context(), answering(status, nil), path) - if err != nil { - t.Fatal(err) + line, err := setup.WriteSettings(path, "https://nudge.tailnet.ts.net") + if err != nil || !strings.HasPrefix(line, "settings: wrote ") { + t.Fatalf("got %q, %v", line, err) } data, _ := os.ReadFile(filepath.Clean(path)) - for _, want := range []string{`"login": "user@example.com"`, `"host": "machine.tailnet.ts.net"`} { - if !strings.Contains(string(data), want) { - t.Errorf("file = %s, want it to contain %s", data, want) - } - } - - if !strings.HasPrefix(line, "settings: wrote ") { - t.Errorf("line = %q", line) + if !strings.Contains(string(data), `"server": "https://nudge.tailnet.ts.net"`) { + t.Errorf("file = %s", data) } for _, p := range []string{filepath.Dir(path), path} { - info, _ := os.Stat(p) - if info.Mode().Perm()&0o077 != 0 { + if info, _ := os.Stat(p); info.Mode().Perm()&0o077 != 0 { t.Errorf("%s is open to others: %v", p, info.Mode()) } } @@ -49,61 +33,52 @@ func TestAnExistingFileStays(t *testing.T) { path := filepath.Join(t.TempDir(), "settings.json") _ = os.WriteFile(path, []byte("mine"), 0o600) - line, err := setup.WriteSettings(t.Context(), answering("", errors.New("must not run")), path) - if err != nil { - t.Fatal(err) + if line, err := setup.WriteSettings(path, "https://x.ts.net"); err != nil || + !strings.Contains(line, "kept") { + t.Fatalf("got %q, %v", line, err) } if data, _ := os.ReadFile(filepath.Clean(path)); string(data) != "mine" { - t.Errorf("file = %q, want it kept", data) - } - - if !strings.HasPrefix(line, "settings: kept ") { - t.Errorf("line = %q", line) + t.Errorf("file = %q", data) } } -func TestStatusProblemsAreErrors(t *testing.T) { - tests := map[string]setup.Run{ - "the command fails": answering("", errors.New("not installed")), - "not JSON": answering("up", nil), - "no login": answering( - `{"Self":{"UserID":1,"DNSName":"a.b.ts.net."},"User":{}}`, - nil, - ), - "no host": answering( - `{"Self":{"UserID":1},"User":{"1":{"LoginName":"a@b"}}}`, - nil, - ), +func TestTheServerMustBeAnHTTPSURL(t *testing.T) { + for _, server := range []string{"", "nudge.ts.net", "http://nudge.ts.net", "https://"} { + path := filepath.Join(t.TempDir(), "settings.json") + if _, err := setup.WriteSettings(path, server); err == nil { + t.Errorf("WriteSettings(%q) gave no error", server) + } } +} - for name, run := range tests { - t.Run(name, func(t *testing.T) { - path := filepath.Join(t.TempDir(), "settings.json") +func TestTheFolderMustBeWritable(t *testing.T) { + file := filepath.Join(t.TempDir(), "file") + _ = os.WriteFile(file, nil, 0o600) - if _, err := setup.WriteSettings(t.Context(), run, path); err == nil { - t.Error("got no error") - } + if _, err := setup.WriteSettings( + filepath.Join(file, "s.json"), + "https://x.ts.net", + ); err == nil { + t.Error("a path below a file gave no error") + } - if _, err := os.Stat(path); err == nil { - t.Error("wrote a file anyway") - } - }) + if err := setup.WriteSkill(filepath.Join(file, "skills")); err == nil { + t.Error("a skill folder below a file gave no error") } } -func TestExecReturnsOutputAndNamesFailures(t *testing.T) { - out, err := setup.Exec(t.Context(), "echo", "hi") - if err != nil || string(out) != "hi\n" { - t.Errorf("got %q, %v", out, err) - } +func TestTheSkillIsWritten(t *testing.T) { + folder := t.TempDir() - _, err = setup.Exec(t.Context(), "sh", "-c", "echo boom >&2; exit 3") - if err == nil || !strings.Contains(err.Error(), "boom") { - t.Errorf("err = %v, want the stderr", err) + for range 2 { + if err := setup.WriteSkill(folder); err != nil { + t.Fatal(err) + } } - if _, err = setup.Exec(t.Context(), "nudge-no-such-tool"); err == nil { - t.Error("got no error for a missing tool") + data, err := os.ReadFile(filepath.Clean(filepath.Join(folder, "nudge", "SKILL.md"))) + if err != nil || !strings.Contains(string(data), "name: nudge") { + t.Errorf("skill = %q, %v", data, err) } } diff --git a/internal/setup/skill.go b/internal/setup/skill.go new file mode 100644 index 0000000..d975c37 --- /dev/null +++ b/internal/setup/skill.go @@ -0,0 +1,26 @@ +package setup + +import ( + _ "embed" // For the skill. + "fmt" + "os" + "path/filepath" +) + +//go:embed skills/nudge/SKILL.md +var skill []byte + +// WriteSkill writes the nudge skill into folder/nudge, which is what Copilot CLI's skill +// directory holds, replacing an older copy. +func WriteSkill(folder string) error { + dir := filepath.Join(folder, "nudge") + if err := os.MkdirAll(dir, folderMode); err != nil { + return fmt.Errorf("make the skill folder: %w", err) + } + + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), skill, fileMode); err != nil { + return fmt.Errorf("write the skill: %w", err) + } + + return nil +} diff --git a/skills/nudge/SKILL.md b/internal/setup/skills/nudge/SKILL.md similarity index 100% rename from skills/nudge/SKILL.md rename to internal/setup/skills/nudge/SKILL.md diff --git a/main.go b/main.go index 99728f2..2f1a59c 100644 --- a/main.go +++ b/main.go @@ -11,7 +11,7 @@ import ( "github.com/codedogapp/nudge/internal/cli" ) -// version is nudge's version. Homebrew's formula sets it with -ldflags "-X main.version=v1.2.3"; +// version is nudge's version. A release build sets it with -ldflags "-X main.version=v1.2.3"; // other builds leave it empty, and nudge takes the version that Go's build info recorded. var version string