diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f09fc5..d4be4c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,3 +44,23 @@ jobs: - run: npm run test:integration env: REDIS_URL: redis://localhost:6380 + + cli: + name: cli (Node ${{ matrix.node }}) + runs-on: ubuntu-latest + strategy: + matrix: + node: [20, 24] + defaults: + run: + working-directory: cli + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + cache-dependency-path: cli/package-lock.json + - run: npm ci + - run: npm run typecheck + - run: npm test diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml new file mode 100644 index 0000000..a26dcd6 --- /dev/null +++ b/.github/workflows/cli-release.yml @@ -0,0 +1,60 @@ +name: Release CLI + +on: + push: + tags: ['cli-v*'] + +jobs: + stage: + runs-on: ubuntu-latest + permissions: + # OIDC for npm trusted publishing; there is deliberately no npm token. + id-token: write + contents: read + defaults: + run: + working-directory: cli + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + cache: npm + cache-dependency-path: cli/package-lock.json + + # Trusted publishing needs npm >= 11.5.1, newer than some Node 24 images ship. + - run: npm install -g npm@latest + + - run: npm ci + + - name: The tag and the package must agree + run: | + tagged="${GITHUB_REF_NAME#cli-v}" + declared="$(node -p "require('./package.json').version")" + if [ "$tagged" != "$declared" ]; then + echo "Tag $GITHUB_REF_NAME says $tagged, package.json says $declared." >&2 + exit 1 + fi + + - name: Refuse a version that is already published + run: | + version="$(node -p "require('./package.json').version")" + if npm view "@david-sling/wave@$version" version >/dev/null 2>&1; then + echo "@david-sling/wave@$version is already on npm. Bump the version and tag again." >&2 + exit 1 + fi + + - run: npm run typecheck + - run: npm test + + - run: npm stage publish --provenance + + - name: Say what is now owed + run: | + echo "Staged. Nothing is on npm until this is approved from a machine with 2FA:" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "npm stage list @david-sling/wave" >> "$GITHUB_STEP_SUMMARY" + echo "npm stage approve " >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 5b7d2f4..3c1826b 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,12 @@ next-env.d.ts # M0 validation spike: throwaway, per docs/PLAN.md /spike/ +# Per-machine agent permissions. Ignored globally on the machine this was +# written on, which is not the same as ignored here: `npm publish` falls back +# to this file when there is no .npmignore, reads only the repository's own +# ignore rules, and packed this file once because of the difference. It holds +# whatever commands were allowed, tokens and message bodies included. +.claude/settings.local.json /cli/node_modules /cli/dist diff --git a/DESIGN.md b/DESIGN.md index 41f5816..97961a7 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -347,6 +347,26 @@ The landing page's main call to action, in the hero heading block: a 48px `.inpu - **Focus:** 2px accent outline on the visible span. - **Rail (`.segmented-rail`):** below `sm` a track with more segments than fit becomes one scrolling row instead of stacking into a block — the inner element is a flex scroller with the scrollbar hidden and `scroll-behavior: smooth`, and segments take 14px of side padding so they size to their labels. From `sm` it is `display: contents`, so the labels are the track's own grid items again and nothing about the desktop control changes. Both ends are masked to transparent over 8px, narrower than a segment's padding, so the fade lands on the gap rather than on a word and a half-visible next label reads as a row that continues. +### Add an agent dialog (`channel/add-agent-dialog.tsx`) + +- **Frame:** below `sm`, a bottom sheet: full width, flush with the bottom edge, 28px top corners and square bottom ones, padded for the safe area, capped at 92dvh, sliding up like the channel menu's sheet (`.dialog-adaptive`). From `sm` up, a `.dialog-modal` at `min(92vw, 600px)`, 28px panel radius, `shadow-lift`, capped at the viewport height less 3rem, under the browser's own cap for a modal. The header and the prompt footer are fixed and the settings between them scroll, so Copy prompt is on screen at any window height. +- **Header:** one bar, 1px `line` rule below. "Add an agent" in Funnel Display 700 at 20px, tracking -0.015em, and a 32px close control, ink-3, `panel-2` on hover. No subtitle: the room is spent on the settings and the prompt, which is what the dialog is for. +- **Prompt footer:** the prompt itself on `ground`, 11px Geist Mono in ink-2, not blurred, fading to transparent over its 120px. The agent's name is marked in `lilac-soft` wherever the prompt carries it, so editing the name visibly rewrites what is handed over. Under it, Copy prompt is a 48px ink bar spanning the footer, 10px from the frame's edges and concentric with it: its radius is the frame's less the 1px border and the 10px gap (`--frame-radius`, `--frame-inset`), 17px in the dialog. a 16px copy icon and the label at the start (the icon becomes the check once copied), and at the far end what it takes — "90 lines · for David's agent", 13px 500 at 60% — truncated before it can wrap. It does not lift on hover, since a bar that moves reads as the footer moving; its fill steps 12% toward panel instead (`.btn-bar`). Copied turns it `ok` green like every copy control. The empty channel's prompt box shares the footer, not the header. + +### Choice groups (`.choices`, `.choice-group`, `.choice`, in `channel/prompt-box.tsx`) + +The prompt box's three settings, OS, agent and method, as joined button groups side by side, in that order. Used where a choice is small, has two or three options, and belongs beside another choice rather than stacked under a label. + +- **Group:** a `fieldset` drawn as one capsule — white fill, 1px `line` border, pill radius — with a screen-reader legend and no visible caption: the capsule is what says which options belong together. Groups sit 10px apart and wrap as whole groups. +- **Option:** flush inside the capsule, 34px tall (44px on coarse pointers), 16px side padding, 14px 500 ink-3 text, split from its neighbour by a straight 1px `line` hairline. The end options carry the capsule's round corners themselves, so nothing needs overflow clipping and the tooltip is never cut off. Hover: `panel-2` fill, ink-2 text. +- **Chosen:** pressed in, not lifted — `line-2` fill, a 1px inset shadow, ink text. A lifted shadow would be clipped by its neighbours in a joined group, and ink stays reserved for the box's one primary action. +- **OS marks:** a drawn desktop icon for "Any OS", and the Simple Icons Apple, Linux and Windows glyphs (`app/components/platform-marks.tsx`), Windows in its own blue and the others in ink, all greyscale until chosen like every mark. +- **Mark option (`.choice-mark`):** 38px wide (44px on a phone), holding a 17px mark: `ClientMark` for a product, an authored icon for "any". Unchosen marks are greyscale at 55% opacity, so the chosen one is the only mark in its own colour. +- **Tooltip (`.choice-tip`):** a mark carries its name in `aria-label` and in an ink tooltip, 12px 500 panel text, 6px radius, 8px above the option. It answers hover and `:focus-visible` both, after 120ms, rising 3px as it fades in. +- **Focus:** 2px accent outline, inset 2px, so it stays inside the capsule. +- **Note:** the line under the groups lays every variant in one grid cell and shows only the current one, so switching never changes the box's height. +- **Memory:** both choices persist per device in localStorage (`useRemembered`, `channel/remembered.ts`), and every box on the page reading the same key moves together. + ### Nav veil (`nav-veil.tsx`) The blurred layer behind the nav, and the one thing on it that reacts to the page. It is its own layer rather than the header's background, because the mask that fades it out would otherwise fade the nav's own pill and wordmark with it. diff --git a/README.md b/README.md index 00ede10..aa8b688 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,16 @@ Wave is transport, not orchestration. Each agent still takes its goals from its | Antigravity CLI | approve the shell command once | | Anything with a shell | HTTP and a loop, nothing more | +## The wave CLI + +Optional. The curl prompt stays the default and needs nothing installed. If you would rather your agent use a client — fewer permission prompts, and one tool call per wait instead of one per poll — install it once on the agent's machine (Node 20 or later): + +```bash +npm i -g @david-sling/wave +``` + +`pnpm add -g`, `yarn global add` (Yarn 1) and `bun add -g` work too. Then choose your package manager in the channel's *Add an agent* box, and the prompt your agent gets uses `wave` instead of `curl`. The agent checks for it and asks you to install it if it is missing, rather than installing it itself. More in the [CLI's README](cli/README.md). + ## Self-hosting Wave is a Next.js app with Redis behind it. Any Node.js host that allows a 60-second request and any Redis 6 or later will do. diff --git a/app/agent/[topic]/route.ts b/app/agent/[topic]/route.ts index 03ff264..bc335b0 100644 --- a/app/agent/[topic]/route.ts +++ b/app/agent/[topic]/route.ts @@ -1,5 +1,6 @@ import { getConfig } from '@/lib/config' -import { AGENT_DOCS, INDEX_TOPIC, agentDocIndex, findAgentDoc, readAgentDoc } from '@/lib/agent-docs' +import { AGENT_DOCS, CURL_TOPIC, INDEX_TOPIC, agentDocIndex, findAgentDoc, readAgentDoc } from '@/lib/agent-docs' +import { curlPromptDoc } from '@/lib/join-prompt' /** * The capability docs, as the agents fetch them (PRODUCT section 7.1). @@ -17,7 +18,7 @@ export const dynamic = 'force-static' export const dynamicParams = true export function generateStaticParams() { - return [{ topic: `${INDEX_TOPIC}.md` }, ...AGENT_DOCS.map((doc) => ({ topic: `${doc.topic}.md` }))] + return [{ topic: `${INDEX_TOPIC}.md` }, { topic: `${CURL_TOPIC}.md` }, ...AGENT_DOCS.map((doc) => ({ topic: `${doc.topic}.md` }))] } /** `receipts.md` and `receipts` are the same document: an agent will try both. */ @@ -39,6 +40,7 @@ function markdown(body: string): Response { export async function GET(_request: Request, context: RouteContext<'/agent/[topic]'>): Promise { const topic = toTopic((await context.params).topic) if (topic === INDEX_TOPIC) return markdown(agentDocIndex(getConfig().host)) + if (topic === CURL_TOPIC) return markdown(curlPromptDoc(getConfig().host)) const doc = findAgentDoc(topic) if (!doc) { diff --git a/app/components/channel/add-agent-dialog.tsx b/app/components/channel/add-agent-dialog.tsx index 99687cb..027c9d7 100644 --- a/app/components/channel/add-agent-dialog.tsx +++ b/app/components/channel/add-agent-dialog.tsx @@ -20,6 +20,7 @@ export function AddAgentDialog({ channelId, channelName, invite, + mode, }: { open: boolean; onClose: () => void; @@ -27,6 +28,7 @@ export function AddAgentDialog({ channelId: string; channelName: string; invite: string; + mode?: string; }) { const dialog = useRef(null); @@ -46,24 +48,30 @@ export function AddAgentDialog({ // the content sits in a child that stops it. if (event.target === dialog.current) onClose(); }} - className="dialog-modal m-auto w-[min(92vw,560px)] rounded-[20px] border border-line bg-panel p-0 text-ink" + className="dialog-modal dialog-adaptive m-auto w-[min(92vw,600px)] overflow-hidden rounded-[28px] border border-line bg-panel p-0 text-ink [--frame-radius:28px] max-sm:mx-0 max-sm:mb-0 max-sm:mt-auto max-sm:w-full max-sm:max-w-none max-sm:rounded-b-none max-sm:border-b-0 max-sm:pb-[env(safe-area-inset-bottom)]" aria-labelledby="add-agent-heading" > -
-

- Add an agent -

- -
+ {/* Flex on a wrapper, since a display class on overrides display: none; max-h stays under the modal cap or the footer clips. */} +
+
+

+ Add an agent +

+ +
- + +
); } diff --git a/app/components/channel/channel-view.tsx b/app/components/channel/channel-view.tsx index 75af3d7..17f4367 100644 --- a/app/components/channel/channel-view.tsx +++ b/app/components/channel/channel-view.tsx @@ -435,8 +435,8 @@ export function ChannelView({ channelId, host }: { channelId: string; host: stri time, then watch them here.

-
- +
+
)} @@ -549,6 +549,7 @@ export function ChannelView({ channelId, host }: { channelId: string; host: stri channelId={channelId} channelName={channel.name} invite={invite} + mode={channel.mode} /> {error ? ( diff --git a/app/components/channel/copy-button.tsx b/app/components/channel/copy-button.tsx index 77763dc..29b7c1f 100644 --- a/app/components/channel/copy-button.tsx +++ b/app/components/channel/copy-button.tsx @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { TextMorph } from "torph/react"; -import { CheckIcon } from "../icons"; +import { CheckIcon, CopyIcon } from "../icons"; import { copyText } from "./copy-text"; /** @@ -17,10 +17,14 @@ export function CopyButton({ value, label, variant = "primary", + size = "sm", + detail, }: { value: string; label: string; variant?: "primary" | "secondary"; + size?: "sm" | "md"; + detail?: ReactNode; }) { const [state, setState] = useState<"resting" | "copied" | "failed">("resting"); const copied = state === "copied"; @@ -37,23 +41,32 @@ export function CopyButton({ return ( ); } diff --git a/app/components/channel/install-command.tsx b/app/components/channel/install-command.tsx new file mode 100644 index 0000000..9a13de3 --- /dev/null +++ b/app/components/channel/install-command.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { CheckIcon, CopyIcon } from "../icons"; +import { copyText } from "./copy-text"; + +export function InstallCommand({ command }: { command: string }) { + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), 2_000); + return () => clearTimeout(timer); + }, [copied]); + + return ( +
+ + + ${" "} + + {command} + + +
+ ); +} diff --git a/app/components/channel/prompt-box.test.tsx b/app/components/channel/prompt-box.test.tsx new file mode 100644 index 0000000..6364beb --- /dev/null +++ b/app/components/channel/prompt-box.test.tsx @@ -0,0 +1,120 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { PromptBox } from "./prompt-box"; + +const props = { + host: "https://wave.example.com", + channelId: "ZmFrZS1jaGFubmVsLWlk", + channelName: "Release 4.2", + invite: "EPMbHaa_zgNMoLNWhmLWuQyEja16cWPAwH1HuugRUTE", +}; + +describe("PromptBox", () => { + it("offers both spellings, with curl chosen", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain(">npm<"); + expect(html).not.toContain("wave CLI"); + expect(html).toContain('checked="" value="curl"'); + expect(html).toContain("BASE=https://wave.example.com/api/v1/channels/"); + expect(html).not.toContain("wave join"); + }); + + it("offers an encrypted channel the CLI alone, and no toggle to get it wrong with", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("npm i -g @david-sling/wave"); + expect(html).not.toContain('value="cli"'); + expect(html).toContain("never reaches a shell"); + }); + + it("says what each choice costs, since that is the whole difference", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("Nothing to install"); + }); +}); + +describe("the method choice", () => { + it("offers curl and the four package managers, curl chosen", () => { + const html = renderToStaticMarkup(); + const offered = [...html.matchAll(/name="prompt-variant[^"]*"(?: checked="")? value="([^"]+)"/g)].map((match) => match[1]); + + expect(offered).toEqual(["curl", "npm", "pnpm", "yarn", "bun"]); + expect(html).toContain('checked="" value="curl"'); + }); + + it("lays out every package manager's install for the person to run, hidden until chosen", () => { + const html = renderToStaticMarkup(); + + for (const command of [ + "npm i -g @david-sling/wave", + "pnpm add -g @david-sling/wave", + "yarn global add @david-sling/wave", + "bun add -g @david-sling/wave", + ]) { + expect(html).toContain(command); + } + expect(html).toContain('aria-label="Copy the install command"'); + }); + + it("offers an encrypted channel the package managers without curl, npm chosen", () => { + const html = renderToStaticMarkup(); + + expect(html).not.toContain('value="curl"'); + expect(html).toContain('checked="" value="npm"'); + expect(html).toContain("npm i -g @david-sling/wave"); + }); +}); + +describe("the OS choice", () => { + it("comes first, offers any OS, macOS, Linux and Windows, any chosen", () => { + const html = renderToStaticMarkup(); + const offered = [...html.matchAll(/name="prompt-platform[^"]*"(?: checked="")? value="([^"]+)"/g)].map((match) => match[1]); + + expect(offered).toEqual(["any", "macos", "linux", "windows"]); + expect(html).toContain('checked="" value="any"'); + expect(html.indexOf("prompt-platform")).toBeLessThan(html.indexOf("prompt-provider")); + for (const label of ["Any OS", "macOS", "Linux", "Windows"]) expect(html).toContain(`aria-label="${label}"`); + }); +}); + +describe("the agent choice", () => { + it("offers any agent, chosen, and Claude Code", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain('checked="" value="any"'); + expect(html).toContain("Any agent"); + expect(html).toContain("Claude Code"); + }); + + it("is offered on an encrypted channel too, where the method is not a choice", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain('value="claude-code"'); + expect(html).not.toContain('value="curl"'); + }); + + it("names each agent for assistive tech and in a tooltip, since the option itself is a mark", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain('aria-label="Any agent"'); + expect(html).toContain('aria-label="Claude Code"'); + expect(html).toContain('class="choice-tip"'); + }); +}); + +describe("two prompt boxes on one page", () => { + it("give their radios different group names", () => { + const html = renderToStaticMarkup( + <> + + + , + ); + for (const group of ["prompt-variant", "prompt-provider", "prompt-platform"]) { + const names = new Set([...html.matchAll(new RegExp(`name="(${group}[^"]*)"`, "g"))].map((match) => match[1])); + expect(names.size, group).toBe(2); + } + }); +}); diff --git a/app/components/channel/prompt-box.tsx b/app/components/channel/prompt-box.tsx index c991926..ef5f504 100644 --- a/app/components/channel/prompt-box.tsx +++ b/app/components/channel/prompt-box.tsx @@ -1,8 +1,54 @@ "use client"; -import { useMemo, useState } from "react"; -import { buildJoinPrompt, defaultAgentName } from "@/lib/join-prompt"; +import { useId, useMemo, useState, type ReactNode } from "react"; +import { + AGENT_PROVIDERS, + INSTALLERS, + INSTALL_COMMANDS, + PLATFORMS, + buildJoinPrompt, + defaultAgentName, + type AgentProvider, + type Installer, + type Platform, +} from "@/lib/join-prompt"; +import { ClientMark } from "../agent-marks"; +import { DesktopIcon, TerminalIcon } from "../icons"; +import { PlatformMark } from "../platform-marks"; import { CopyButton } from "./copy-button"; +import { InstallCommand } from "./install-command"; +import { useRemembered } from "./remembered"; + +const PROVIDERS = Object.keys(AGENT_PROVIDERS) as AgentProvider[]; +type Method = "curl" | Installer; +const METHODS: readonly Method[] = ["curl", ...INSTALLERS]; + +function note(method: Method, encrypted: boolean): ReactNode { + if (method === "curl") return "Nothing to install. Your agent asks permission for each kind of call it makes."; + const lead = encrypted + ? "This channel is encrypted, so the prompt uses the wave command: the key stays in the agent’s own process and never reaches a shell." + : "Fewer permission prompts, and a wait is one tool call rather than one per poll."; + return ( + <> + {lead} Run this once on the agent’s machine first (Node 20 or later): + + + ); +} + +const PLATFORM_KEYS = Object.keys(PLATFORMS) as Platform[]; + +const PLATFORM_MARK: Record = { + any: , + macos: , + linux: , + windows: , +}; + +const PROVIDER_MARK: Record = { + any: , + "claude-code": , +}; /** * The join prompt, ready to paste (PRODUCT 6.2). @@ -15,72 +61,179 @@ export function PromptBox({ channelId, channelName, invite, + mode = "standard", }: { host: string; channelId: string; channelName: string; invite: string; + mode?: string; }) { + const encrypted = mode !== "standard"; const [agentName, setAgentName] = useState(defaultAgentName("")); const [purpose, setPurpose] = useState(""); + const [chosen, setChosen] = useRemembered("wave:prompt-method", METHODS, "curl"); + const [platform, setPlatform] = useRemembered("wave:prompt-platform", PLATFORM_KEYS, "any"); + const [provider, setProvider] = useRemembered("wave:prompt-agent", PROVIDERS, "any"); + // Two prompt boxes are mounted at once, and unscoped radio names would share one group. + const group = useId(); + const offered = encrypted ? INSTALLERS : METHODS; + const method: Method = encrypted && chosen === "curl" ? "npm" : chosen; + const variant = method === "curl" ? "curl" : "cli"; + + const shownName = agentName.trim() || defaultAgentName(""); const prompt = useMemo( () => - buildJoinPrompt({ - host, - channelId, - channelName, - invite, - agentName: agentName.trim() || defaultAgentName(""), - purpose, - }), - [host, channelId, channelName, invite, agentName, purpose], + buildJoinPrompt( + { + host, + channelId, + channelName, + invite, + agentName: shownName, + purpose, + provider, + platform, + installer: method === "curl" ? undefined : method, + }, + variant, + ), + [host, channelId, channelName, invite, shownName, purpose, provider, platform, method, variant], + ); + const marked = useMemo( + () => + prompt.split(shownName).flatMap((part, index) => + index === 0 + ? [part] + : [ + + {shownName} + , + part, + ], + ), + [prompt, shownName], ); return ( -
-
- - setAgentName(event.target.value)} - maxLength={40} - autoComplete="off" - spellCheck={false} - /> -
+
+
+
+ + setAgentName(event.target.value)} + maxLength={40} + autoComplete="off" + spellCheck={false} + /> +
-
- -