From 7f4490d4628b31a34824f5b7f2985d4c450d7451 Mon Sep 17 00:00:00 2001 From: Ricardo Vega Date: Wed, 30 Sep 2026 06:57:37 -0500 Subject: [PATCH] Slice 8: `tyto install` and `tyto doctor`. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - install: writes ~/.local/bin/tyto pinned to the installing Node (the user's default Node 18 can't run Tyto), the `tyto` skill for Claude Code and Cursor (tracked by hash; a skill the user edited is kept), and replaces step 4 of the web-access rule once so agents check `tyto recipes` before browsing. - doctor: checks Node against the engines range, agent-browser ≥ 0.38.1, Claude Code (compiler), and tyto on PATH; lists Tyto's running agent-browser sessions; `--fix` closes them so they restart with Tyto's settings (agent-browser stores only a hash of a session's launch settings, so the timeout itself can't be read). Installed for real on the maintainer's machine: from a fresh login shell (default Node 18) `tyto doctor` is all green and the skill shows up in Claude Code. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 12 +++ docs/IMPLEMENTATION.md | 14 +++- packages/cli/src/compose.ts | 63 +++++++++++++++- packages/cli/src/doctor.ts | 69 +++++++++++++++++ packages/cli/src/index.ts | 2 + packages/cli/src/install.ts | 95 ++++++++++++++++++++++++ packages/cli/src/main.ts | 15 ++++ packages/cli/test/install.test.ts | 118 ++++++++++++++++++++++++++++++ 8 files changed, 383 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/doctor.ts create mode 100644 packages/cli/src/install.ts create mode 100644 packages/cli/test/install.test.ts diff --git a/README.md b/README.md index c49290f..684812b 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,18 @@ two things it doesn't do: | Agent rules | [AGENTS.md](./AGENTS.md) · [CLAUDE.md](./CLAUDE.md) · [`.cursor/rules`](./.cursor/rules/) | | Security | [SECURITY.md](./SECURITY.md) | +## Install + +Needs [agent-browser](https://github.com/vercel-labs/agent-browser) ≥ 0.38.1 and Node 22.22+/24.15+/26; +`tyto compile` also needs [Claude Code](https://claude.com/claude-code). + +```bash +brew install agent-browser && agent-browser install +git clone git@github.com:YOLOVibeCode/tyto.git && cd tyto && npm install +node packages/cli/bin/tyto.mjs install # tyto on PATH + skill for Claude Code and Cursor +tyto doctor +``` + ## How it works ```bash diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 3063758..41aa1fd 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -216,10 +216,16 @@ prints a ready-to-run `tyto repair` command on stderr. - `tyto run records passing inputs in the regression list`; `tyto run prints a repair hint on a miss` ### Slice 8 — install and doctor -- `install writes the tyto skill for Claude Code and Cursor without overwriting user edits` -- `install updates the web-access rule to check tyto recipes first` -- `doctor reports an agent-browser older than 0.38.1` -- `doctor detects a tyto-rx daemon started with a different default timeout` +`tyto install` writes `~/.local/bin/tyto` pinned to the installing Node (the user's default Node may be too old), +the `tyto` skill for Claude Code and Cursor (tracked by hash in `~/.tyto/installed.json`; edited copies are kept), +and replaces step 4 of the web-access rule once. agent-browser stores only a hash of a session's launch settings, +so the doctor cannot read a daemon's timeout; it lists Tyto's running sessions and `--fix` closes them so they +restart with Tyto's settings. +- `writes the tyto launcher into the bin dir, pinned to this Node` +- `install writes the tyto skill for Claude Code and Cursor`; `install does not overwrite a skill the user edited` +- `install updates the web-access rule to check tyto recipes first, once` +- `doctor reports an agent-browser older than 0.38.1`; `doctor reports a missing agent-browser and a missing launcher` +- `doctor --fix closes Tyto's running sessions so they restart with Tyto's settings` ### Slice 9 — OpenAI-compatible compiler - `the tool-call loop runs compile-tool calls and stops on the final recipe JSON` diff --git a/packages/cli/src/compose.ts b/packages/cli/src/compose.ts index dd5bab1..b091e9e 100644 --- a/packages/cli/src/compose.ts +++ b/packages/cli/src/compose.ts @@ -2,7 +2,13 @@ import { spawn } from "node:child_process"; import { homedir } from "node:os"; import { fileURLToPath } from "node:url"; import { join } from "node:path"; -import { AgentBrowserRunner, StreamEventSource } from "@tyto/agent-browser"; +import { AgentBrowserRunner, StreamEventSource, agentBrowserSocketDir } from "@tyto/agent-browser"; +import { execFile } from "node:child_process"; +import { access, readFile } from "node:fs/promises"; +import { constants } from "node:fs"; +import { delimiter } from "node:path"; +import { promisify } from "node:util"; +import type { TytoSession } from "./doctor.ts"; import { ClaudeCodeCompiler } from "@tyto/compiler"; import { createInterface } from "node:readline/promises"; import { text } from "node:stream/consumers"; @@ -11,6 +17,48 @@ import { FileLogMarks, FileSessionLock, FileTraceStore, FilesystemRecipeStore, e import { requestControl, serveControl } from "./learn/control.ts"; import { runListener } from "./learn/listener.ts"; +const run = promisify(execFile); +const TYTO_SESSIONS = ["tyto-rx", "tyto-rx-auth", "tyto-compile"]; + +async function versionOf(bin: string): Promise { + try { + const { stdout } = await run(bin, ["--version"], { timeout: 10_000 }); + return /(\d+\.\d+\.\d+)/.exec(stdout)?.[1] ?? null; + } catch { + return null; + } +} + +async function onPath(name: string, pathVar: string | undefined): Promise { + for (const dir of (pathVar ?? "").split(delimiter).filter(Boolean)) { + const candidate = join(dir, name); + try { + await access(candidate, constants.X_OK); + return candidate; + } catch { + continue; + } + } + return null; +} + +async function runningSessions(env: NodeJS.ProcessEnv): Promise { + const dir = agentBrowserSocketDir(env); + const out: TytoSession[] = []; + for (const session of TYTO_SESSIONS) { + const pid = Number((await readFile(join(dir, `${session}.pid`), "utf8").catch(() => "")).trim()); + if (!pid) continue; + try { + process.kill(pid, 0); + } catch { + continue; + } + const version = (await readFile(join(dir, `${session}.version`), "utf8").catch(() => "")).trim() || null; + out.push({ session, pid, version }); + } + return out; +} + const BIN = fileURLToPath(new URL("../bin/tyto.mjs", import.meta.url)); const LISTEN_MAX_MS = 2 * 60 * 60 * 1000; import type { CliDeps } from "./main.ts"; @@ -70,6 +118,19 @@ export async function composeDeps(env: NodeJS.ProcessEnv = process.env): Promise rl.close(); } }, + setup: { + install: { home: homedir(), binDir: join(homedir(), ".local", "bin"), nodePath: process.execPath, cliBin: BIN }, + doctor: { + nodeVersion: process.versions.node, + agentBrowserVersion: () => versionOf(env.TYTO_AGENT_BROWSER ?? "agent-browser"), + claudeVersion: () => versionOf("claude"), + launcher: () => onPath("tyto", env.PATH), + sessions: () => runningSessions(env), + closeSession: async (session) => { + await runner.run(["close"], { session }); + }, + }, + }, out: (line) => process.stdout.write(`${line}\n`), err: (line) => process.stderr.write(`${line}\n`), }; diff --git a/packages/cli/src/doctor.ts b/packages/cli/src/doctor.ts new file mode 100644 index 0000000..0b05b99 --- /dev/null +++ b/packages/cli/src/doctor.ts @@ -0,0 +1,69 @@ +export type TytoSession = { session: string; pid: number; version: string | null }; + +export type DoctorDeps = { + nodeVersion: string; + agentBrowserVersion: () => Promise; + claudeVersion: () => Promise; + /** Path of `tyto` on PATH, or null. */ + launcher: () => Promise; + /** Tyto's own agent-browser sessions that are running (tyto-rx, tyto-rx-auth, tyto-compile). */ + sessions: () => Promise; + closeSession: (session: string) => Promise; +}; + +type Io = { out: (line: string) => void; err: (line: string) => void }; + +const MIN_AGENT_BROWSER = [0, 38, 1] as const; + +function parse(version: string): number[] { + return (/(\d+)\.(\d+)\.(\d+)/.exec(version) ?? []).slice(1).map(Number); +} + +function atLeast(v: readonly number[], min: readonly number[]): boolean { + for (let i = 0; i < min.length; i += 1) { + if ((v[i] ?? 0) !== (min[i] ?? 0)) return (v[i] ?? 0) > (min[i] ?? 0); + } + return true; +} + +/** Matches package.json engines: ^22.22.2 || ^24.15.0 || >=26. */ +export function nodeSupported(version: string): boolean { + const [major = 0, minor = 0, patch = 0] = parse(version); + if (major === 22) return atLeast([major, minor, patch], [22, 22, 2]); + if (major === 24) return atLeast([major, minor, patch], [24, 15, 0]); + return major >= 26; +} + +export async function doctor(args: readonly string[], deps: DoctorDeps, io: Io): Promise { + let problems = 0; + const bad = (line: string): void => { + problems += 1; + io.out(`✗ ${line}`); + }; + if (nodeSupported(deps.nodeVersion)) io.out(`✓ Node ${deps.nodeVersion}`); + else bad(`Node ${deps.nodeVersion} is too old; Tyto needs 22.22+, 24.15+, or 26 (nvm install 26)`); + + const ab = await deps.agentBrowserVersion(); + if (ab === null) bad("agent-browser not found: brew install agent-browser && agent-browser install"); + else if (!atLeast(parse(ab), MIN_AGENT_BROWSER)) bad(`agent-browser ${ab} is older than ${MIN_AGENT_BROWSER.join(".")}: brew upgrade agent-browser`); + else io.out(`✓ agent-browser ${ab}`); + + const claude = await deps.claudeVersion(); + io.out(claude ? `✓ Claude Code ${claude} (compiler)` : "! claude (Claude Code) not found: needed only for tyto compile and tyto repair"); + + const launcher = await deps.launcher(); + if (launcher) io.out(`✓ tyto on PATH: ${launcher}`); + else bad("tyto is not on your PATH: run tyto install (from the Tyto repo: node packages/cli/bin/tyto.mjs install)"); + + const sessions = await deps.sessions(); + for (const s of sessions) io.out(` running: ${s.session} (pid ${s.pid}${s.version ? `, agent-browser ${s.version}` : ""})`); + if (args.includes("--fix")) { + for (const s of sessions) { + await deps.closeSession(s.session); + io.out(` closed ${s.session}; it restarts with Tyto's settings on next use`); + } + } else if (sessions.length) { + io.out(" (if a replay ever waits ~25 s, run `tyto doctor --fix` to restart these with Tyto's 6 s timeout)"); + } + return problems ? 1 : 0; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index b044a0f..7cb2f58 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,3 +3,5 @@ export { composeDeps } from "./compose.ts"; export { requestControl, serveControl, type ControlReply, type ControlRequest } from "./learn/control.ts"; export { runListener, type ListenerDeps } from "./learn/listener.ts"; export { learnCommand, type LearnDeps } from "./learn/commands.ts"; +export { TYTO_SKILL, install, type InstallOptions } from "./install.ts"; +export { doctor, nodeSupported, type DoctorDeps, type TytoSession } from "./doctor.ts"; diff --git a/packages/cli/src/install.ts b/packages/cli/src/install.ts new file mode 100644 index 0000000..a8cb780 --- /dev/null +++ b/packages/cli/src/install.ts @@ -0,0 +1,95 @@ +import { createHash } from "node:crypto"; +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +export type InstallOptions = { home: string; binDir: string; nodePath: string; cliBin: string }; +type Io = { out: (line: string) => void; err: (line: string) => void }; + +export const TYTO_SKILL = `--- +name: tyto +description: Replays repeated browser tasks in about a second with no model, and gives one-call page briefs (failed requests with bodies, JavaScript errors, console, API calls). Use before browsing a site where a task may already be a recipe, when a browser task will repeat or run over many inputs, or when you need to know why a page is broken. Works on top of agent-browser. +allowed-tools: Bash(tyto:*) +--- + +# tyto + +1. **Before browsing**, run \`tyto recipes\`. If one matches, \`tyto run --param value\` answers in about + 0.3 s with no model. Exit 0 prints the JSON answer. Exit 3 is a MISS (the site changed): run the printed + \`tyto repair …\` command, or fall back to agent-browser. +2. **A task that will repeat**: \`tyto learn --session \`, do it once with \`agent-browser --session …\`, + check typed inputs with \`tyto learn status \`, then + \`tyto learn stop --task "" --param input_N=\` and \`tyto compile \`. +3. **Why is a page broken?** \`tyto open --session \` prints the brief. \`tyto find \` searches the + whole page. \`tyto click|fill|press … --session \` acts and prints what happened next. +4. Recipes that use the user's logins run only after the user approves them with \`tyto recipes approve \`. + Never approve one on the user's behalf. +`; + +const STEP_4 = `4. **Repeats**: check \`tyto recipes\` first; if one matches, \`tyto run --param value\` (~0.3 s, no + model). For a task that will repeat, record it once: \`tyto learn --session \` → do it with + agent-browser → \`tyto learn stop …\` → \`tyto compile \`. On a MISS, run the printed \`tyto repair …\` + or fall back to browsing. +`; + +function sha(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} + +async function readOr(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch (err) { + if (err instanceof Error && "code" in err && err.code === "ENOENT") return null; + throw err; + } +} + +async function writeSkill(path: string, manifest: Record, io: Io): Promise { + const current = await readOr(path); + const edited = current !== null && current !== TYTO_SKILL && manifest[path] !== sha(current); + if (edited) { + io.out(` kept your edited ${path}`); + return; + } + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, TYTO_SKILL); + manifest[path] = sha(TYTO_SKILL); + io.out(` ✓ skill ${path}`); +} + +async function updateRule(path: string, io: Io): Promise { + const text = await readOr(path); + if (text === null) return; + if (text.includes("tyto recipes")) { + io.out(` ✓ rule already mentions tyto: ${path}`); + return; + } + const step4 = /^4\. \*\*Repeats\*\*[\s\S]*?(?=^5\. )/m; + if (!step4.test(text)) { + io.out(` ! left ${path} alone (no step 4 "Repeats" to replace)`); + return; + } + await writeFile(path, text.replace(step4, STEP_4)); + io.out(` ✓ rule ${path}`); +} + +/** Put `tyto` on PATH (pinned to this Node), install the skill for Claude Code and Cursor, and update the web-access rule. */ +export async function install(opts: InstallOptions, io: Io): Promise { + const launcher = join(opts.binDir, "tyto"); + await mkdir(opts.binDir, { recursive: true }); + await writeFile(launcher, `#!/bin/sh\n# tyto launcher (tyto install). Pinned to the Node that installed it.\nexec ${JSON.stringify(opts.nodePath)} ${JSON.stringify(opts.cliBin)} "$@"\n`); + await chmod(launcher, 0o755); + io.out(` ✓ launcher ${launcher} (Node ${opts.nodePath})`); + + const manifestPath = join(opts.home, ".tyto", "installed.json"); + const manifest = JSON.parse((await readOr(manifestPath)) ?? "{}") as Record; + await writeSkill(join(opts.home, ".claude", "skills", "tyto", "SKILL.md"), manifest, io); + await writeSkill(join(opts.home, ".cursor", "skills", "tyto", "SKILL.md"), manifest, io); + await mkdir(dirname(manifestPath), { recursive: true, mode: 0o700 }); + await writeFile(manifestPath, JSON.stringify(manifest, null, 2), { mode: 0o600 }); + + await updateRule(join(opts.home, ".claude", "rules", "web-access.md"), io); + await updateRule(join(opts.home, ".cursor", "rules", "web-access.mdc"), io); + io.out("Done. Check with: tyto doctor"); + return 0; +} diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index e0ce0b4..6694d36 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -2,6 +2,8 @@ import { EXIT, appendRegression, executeRecipe, type Compiler, type ExecDeps, ty import { approve, compile, compileTool, type CompileToolDeps } from "./compile/commands.ts"; import { repair } from "./compile/repair.ts"; import { UsageError, asStrings, paramFlags, parseParams, subset } from "./args.ts"; +import { doctor, type DoctorDeps } from "./doctor.ts"; +import { install, type InstallOptions } from "./install.ts"; import { ACTIONS, act, brief, find, open, type BrowseDeps } from "./browse.ts"; import { learnCommand, type LearnDeps } from "./learn/commands.ts"; @@ -14,12 +16,17 @@ export type CliDeps = { compiler: Compiler; compileTool: CompileToolDeps; confirm: (question: string) => Promise; + /** Setup commands (install, doctor); wired only in the real composition root. */ + setup?: { install: InstallOptions; doctor: DoctorDeps }; out: (line: string) => void; err: (line: string) => void; }; export const USAGE = `usage: tyto + tyto install [--bin-dir d] put tyto on PATH; add the skill for Claude Code and Cursor + tyto doctor [--fix] check Node, agent-browser, Claude Code, PATH, Tyto's sessions + tyto open [--session s] [--json] open a page and print its brief (errors, network, state, elements) tyto brief [--session s] [--json] brief of the current page tyto find [--session s] search the whole page text @@ -136,6 +143,14 @@ export async function main(argv: readonly string[], deps: CliDeps): Promise= 0 && args[i + 1] ? { binDir: args[i + 1] ?? "" } : {}) }, deps); + } + case "doctor": + if (!deps.setup) throw new UsageError("doctor is not available here"); + return await doctor(args, deps.setup.doctor, deps); case "repair": return await repair(args, deps, deps); case "compile": diff --git a/packages/cli/test/install.test.ts b/packages/cli/test/install.test.ts new file mode 100644 index 0000000..55136eb --- /dev/null +++ b/packages/cli/test/install.test.ts @@ -0,0 +1,118 @@ +import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { doctor, install, type DoctorDeps } from "../src/index.ts"; + +const RULE = `# Web access: use the fastest tool that can do the job + +1. **Public data with an API**: call the API. +2. **Static public page, read-only**: WebFetch. +3. **Use \`agent-browser\`** when … +4. **Repeats**: if a site task will run again or over many inputs, once it works save the exact + \`agent-browser\` commands as a script with the inputs as parameters (\`agent-browser batch …\`), verify its + output, and run the script next time instead of browsing. Fall back to browsing only if it fails. +5. **Inside agent-browser**: one \`--session\` per task. +`; + +async function home(): Promise { + const h = await mkdtemp(join(tmpdir(), "tyto-home-")); + await mkdir(join(h, ".claude", "rules"), { recursive: true }); + await mkdir(join(h, ".cursor", "rules"), { recursive: true }); + await writeFile(join(h, ".claude", "rules", "web-access.md"), RULE); + await writeFile(join(h, ".cursor", "rules", "web-access.mdc"), `---\nalwaysApply: true\n---\n\n${RULE}`); + return h; +} + +function io() { + const lines: string[] = []; + return { lines, out: (s: string) => lines.push(s), err: (s: string) => lines.push(`ERR ${s}`) }; +} + +const OPTS = { nodePath: "/opt/node26/bin/node", cliBin: "/src/tyto/packages/cli/bin/tyto.mjs" }; + +describe("tyto install", () => { + it("writes the tyto launcher into the bin dir, pinned to this Node", async () => { + const h = await home(); + await install({ ...OPTS, home: h, binDir: join(h, ".local", "bin") }, io()); + const launcher = await readFile(join(h, ".local", "bin", "tyto"), "utf8"); + expect(launcher).toContain('"/opt/node26/bin/node" "/src/tyto/packages/cli/bin/tyto.mjs"'); + expect((await stat(join(h, ".local", "bin", "tyto"))).mode & 0o111).not.toBe(0); + }); + + it("install writes the tyto skill for Claude Code and Cursor", async () => { + const h = await home(); + await install({ ...OPTS, home: h, binDir: join(h, "bin") }, io()); + for (const dir of [".claude", ".cursor"]) { + const skill = await readFile(join(h, dir, "skills", "tyto", "SKILL.md"), "utf8"); + expect(skill).toMatch(/^---\nname: tyto/); + expect(skill).toMatch(/tyto recipes/); + expect(skill).toMatch(/tyto learn/); + } + }); + + it("install does not overwrite a skill the user edited", async () => { + const h = await home(); + const o = io(); + await install({ ...OPTS, home: h, binDir: join(h, "bin") }, o); + const path = join(h, ".claude", "skills", "tyto", "SKILL.md"); + await writeFile(path, "my own notes"); + await install({ ...OPTS, home: h, binDir: join(h, "bin") }, o); + expect(await readFile(path, "utf8")).toBe("my own notes"); + expect(o.lines.join("\n")).toMatch(/kept your edited/); + }); + + it("install updates the web-access rule to check tyto recipes first, once", async () => { + const h = await home(); + await install({ ...OPTS, home: h, binDir: join(h, "bin") }, io()); + await install({ ...OPTS, home: h, binDir: join(h, "bin") }, io()); + for (const f of [join(h, ".claude", "rules", "web-access.md"), join(h, ".cursor", "rules", "web-access.mdc")]) { + const text = await readFile(f, "utf8"); + expect(text).toMatch(/4\. \*\*Repeats\*\*: check `tyto recipes`/); + expect(text.match(/tyto recipes/g)?.length).toBe(1); + expect(text).toMatch(/5\. \*\*Inside agent-browser\*\*/); + expect(text).not.toMatch(/agent-browser batch …/); + } + }); +}); + +function doctorDeps(overrides: Partial = {}): DoctorDeps { + return { + nodeVersion: "26.9.0", + agentBrowserVersion: async () => "0.38.1", + claudeVersion: async () => "2.1.285", + launcher: async () => "/home/u/.local/bin/tyto", + sessions: async () => [{ session: "tyto-rx", pid: 42, version: "0.38.1" }], + closeSession: async () => undefined, + ...overrides, + }; +} + +describe("tyto doctor", () => { + it("reports all clear on a healthy setup", async () => { + const o = io(); + expect(await doctor([], doctorDeps(), o)).toBe(0); + expect(o.lines.join("\n")).toMatch(/agent-browser 0\.38\.1/); + }); + + it("doctor reports an agent-browser older than 0.38.1", async () => { + const o = io(); + expect(await doctor([], doctorDeps({ agentBrowserVersion: async () => "0.37.9" }), o)).toBe(1); + expect(o.lines.join("\n")).toMatch(/agent-browser 0\.37\.9 .*0\.38\.1/); + }); + + it("doctor reports a missing agent-browser and a missing launcher", async () => { + const o = io(); + await doctor([], doctorDeps({ agentBrowserVersion: async () => null, launcher: async () => null }), o); + const text = o.lines.join("\n"); + expect(text).toMatch(/brew install agent-browser/); + expect(text).toMatch(/tyto install/); + }); + + it("doctor --fix closes Tyto's running sessions so they restart with Tyto's settings", async () => { + const closed: string[] = []; + const o = io(); + await doctor(["--fix"], doctorDeps({ closeSession: async (s) => void closed.push(s) }), o); + expect(closed).toEqual(["tyto-rx"]); + }); +});