Skip to content
Open
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
63 changes: 62 additions & 1 deletion packages/cli/src/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<string | null> {
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<string | null> {
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<TytoSession[]> {
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";
Expand Down Expand Up @@ -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`),
};
Expand Down
69 changes: 69 additions & 0 deletions packages/cli/src/doctor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
export type TytoSession = { session: string; pid: number; version: string | null };

export type DoctorDeps = {
nodeVersion: string;
agentBrowserVersion: () => Promise<string | null>;
claudeVersion: () => Promise<string | null>;
/** Path of `tyto` on PATH, or null. */
launcher: () => Promise<string | null>;
/** Tyto's own agent-browser sessions that are running (tyto-rx, tyto-rx-auth, tyto-compile). */
sessions: () => Promise<TytoSession[]>;
closeSession: (session: string) => Promise<void>;
};

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<number> {
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;
}
2 changes: 2 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
95 changes: 95 additions & 0 deletions packages/cli/src/install.ts
Original file line number Diff line number Diff line change
@@ -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 <name> --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 <name> --session <s>\`, do it once with \`agent-browser --session <s> …\`,
check typed inputs with \`tyto learn status <name>\`, then
\`tyto learn stop <name> --task "<what it answers>" --param input_N=<param>\` and \`tyto compile <name>\`.
3. **Why is a page broken?** \`tyto open <url> --session <s>\` prints the brief. \`tyto find <words>\` searches the
whole page. \`tyto click|fill|press … --session <s>\` 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 <name>\`.
Never approve one on the user's behalf.
`;

const STEP_4 = `4. **Repeats**: check \`tyto recipes\` first; if one matches, \`tyto run <name> --param value\` (~0.3 s, no
model). For a task that will repeat, record it once: \`tyto learn <name> --session <s>\` → do it with
agent-browser → \`tyto learn stop …\` → \`tyto compile <name>\`. 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<string | null> {
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<string, string>, io: Io): Promise<void> {
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<void> {
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<number> {
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<string, string>;
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;
}
15 changes: 15 additions & 0 deletions packages/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -14,12 +16,17 @@ export type CliDeps = {
compiler: Compiler;
compileTool: CompileToolDeps;
confirm: (question: string) => Promise<boolean>;
/** 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 <command>

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 <url> [--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 <words> [--session s] search the whole page text
Expand Down Expand Up @@ -136,6 +143,14 @@ export async function main(argv: readonly string[], deps: CliDeps): Promise<numb
return await test(args, deps);
case "recipes":
return await recipes(args, deps);
case "install": {
if (!deps.setup) throw new UsageError("install is not available here");
const i = args.indexOf("--bin-dir");
return await install({ ...deps.setup.install, ...(i >= 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":
Expand Down
Loading
Loading