Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
994 changes: 993 additions & 1 deletion package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"examples/*"
],
"scripts": {
"test": "echo \"no packages yet\""
"test": "npm test -w packages/next"
},
"engines": {
"node": ">=20"
Expand Down
80 changes: 80 additions & 0 deletions packages/next/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# @sightmap/next

The Next.js side of Sightmap and Sightkick. Three commands and one component, no runtime
dependencies beyond `next` and `react`.

```bash
npm i -D @sightmap/sightmap @sightmap/sightkick agent-browser
npm i @sightmap/next
```

## `sightmap-next seed`

One-shot scaffold of `.sightmap/` from the router. Never overwrites.

```
sightmap-next seed [app-root] [--app-dir DIR] [--sightmap-dir DIR] [--base-url URL] [--dry-run]
```

- Walks `app/**/page.*` (and `pages/**`). Route groups vanish, `[id]` → `:id`,
`[...slug]` → `**`, `@slots`, `(.)intercepts`, and `_private` folders are skipped.
- One feature file per top-level route (`home.yaml`, `tasks.yaml`, …), per the spec's
authoring conventions. Each view gets `route:`, `source:`, the layout chain as
`dependencies:`, and `stability: stub`.
- Route handlers under `app/api` become `requests:` seeds, with `method:` when the file
exports exactly one HTTP verb.

Then curate against the running app with the `sightmap-authoring` skill and drop the
`stub` markers.

## `sightmap-next build`

```
sightmap-next build [app-root] [--public-dir DIR] [--no-init-script]
```

Writes, using the `sightmap` and `sightkick` CLIs:

| File | What |
|---|---|
| `public/.well-known/sightmap.json` | the corpus, `sightmap export` |
| `public/.well-known/sightkick.json` | the compiled tool IR, `sightkick build` |
| `public/sightkick-runtime.js` | the runtime that registers the IR on `document.modelContext` |
| `webmcp.init.js` | runtime + IR in one file for `agent-browser --init-script` |

Wire it as `"prebuild": "sightmap-next build"`.

## `<SightkickTools/>`

```tsx
// app/layout.tsx
import { SightkickTools } from "@sightmap/next";
import ir from "../public/.well-known/sightkick.json";

<SightkickTools ir={ir} /> // inline the IR (registers as soon as the runtime loads)
<SightkickTools /> // or fetch /.well-known/sightkick.json after hydration
<SightkickTools enabled={process.env.VERCEL_ENV !== "production"} /> // gate it
```

Registers the tool layer on every page. Tools are view-scoped and re-register on
client-side navigation. If an agent-browser init script already booted the runtime, the
component reuses it.

## `sightmap-next run-plan`

```
sightmap-next run-plan <plan.json ...> [--base-url URL] [--init-script FILE] [--session NAME]
[--stamp] [--stale-ok] [--dry-run]
```

Replays a stored plan — a Gherkin scenario resolved to tool calls and expectations —
through `agent-browser webmcp invoke`. Same plan format and expectation vocabulary as
Sightkick's `scripts/run-plan.mjs` (`ok`, `value.{equals,contains,absent}`,
`list.{length,contains,excludes}`); only the executor differs. `--stamp` records the
feature-file and compiled-IR hashes; a later run refuses to proceed when either moved.

## Tests

```bash
npm test
```
67 changes: 67 additions & 0 deletions packages/next/bin/sightmap-next.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/usr/bin/env node
// sightmap-next — the Next.js side of Sightmap + Sightkick.
//
// seed scaffold .sightmap/ view stubs from the App Router (one-shot, never overwrites)
// build publish the corpus + compiled WebMCP tool layer under public/.well-known/
// and emit webmcp.init.js for agent-browser --init-script
// run-plan replay a stored plan (a Gherkin scenario resolved to tool calls)
// through `agent-browser webmcp invoke`, with no agent in the loop
import { seedCommand } from "../src/seed.mjs";
import { buildCommand } from "../src/build.mjs";
import { runPlanCommand } from "../src/run-plan.mjs";

const USAGE = `sightmap-next — Next.js compatibility layer for Sightmap + Sightkick

Usage:
sightmap-next seed [app-root] [--app-dir DIR] [--sightmap-dir DIR] [--base-url URL] [--dry-run]
sightmap-next build [app-root] [--public-dir DIR] [--no-init-script] [--sightkick-args "..."]
sightmap-next run-plan <plan.json ...> [--base-url URL] [--init-script FILE] [--session NAME]
[--stamp] [--stale-ok] [--dry-run]

seed Walk app/**/page.* (and pages/**) and write one stub view per route into
.sightmap/<feature>.yaml, following the spec's authoring conventions
(one feature file per top-level route, stability: stub, source: the page
file, dependencies: the layout chain). Route handlers under app/api become
requests: seeds. Existing files are never touched — the corpus is a curated
authority; this is a scaffold, not a generator.
build Run 'sightmap export' and 'sightkick build' / 'sightkick runtime' and write
public/.well-known/sightmap.json the corpus (what the app is)
public/.well-known/sightkick.json the compiled tool IR (what the app can do)
public/sightkick-runtime.js the WebMCP runtime the tools register through
webmcp.init.js runtime + IR in one file, for
'agent-browser --init-script'
run-plan For each plan: open the app in agent-browser, wait for the tools to
register, invoke each step via 'agent-browser webmcp invoke', check the
expectation. Refuses to run a plan whose scenario or compiled IR has
changed since it was stamped.
`;

const [cmd, ...rest] = process.argv.slice(2);
try {
switch (cmd) {
case "seed":
process.exitCode = await seedCommand(rest);
break;
case "build":
process.exitCode = await buildCommand(rest);
break;
case "run-plan":
process.exitCode = await runPlanCommand(rest);
break;
case undefined:
case "-h":
case "--help":
case "help":
process.stdout.write(USAGE);
process.exitCode = cmd ? 0 : 2;
break;
default:
process.stderr.write(
`sightmap-next: unknown command "${cmd}"\n\n${USAGE}`,
);
process.exitCode = 2;
}
} catch (err) {
process.stderr.write(`✗ ${err?.message ?? err}\n`);
process.exitCode = 1;
}
37 changes: 37 additions & 0 deletions packages/next/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "@sightmap/next",
"version": "0.0.1",
"description": "Next.js compatibility layer for Sightmap and Sightkick: seed a .sightmap/ corpus from the App Router, publish the corpus and the compiled WebMCP tool layer at /.well-known/, boot the tools on every page, and replay natural-language plans through agent-browser.",
"license": "MIT",
"type": "module",
"homepage": "https://sightmap.org",
"repository": {
"type": "git",
"url": "git+https://github.com/sightmap/sightmap-next.git",
"directory": "packages/next"
},
"keywords": ["sightmap", "sightkick", "webmcp", "nextjs", "vercel", "agent-browser", "agent"],
"bin": {
"sightmap-next": "bin/sightmap-next.mjs"
},
"exports": {
".": {
"types": "./src/index.d.ts",
"default": "./src/index.js"
},
"./seed": "./src/seed.mjs",
"./build": "./src/build.mjs",
"./run-plan": "./src/run-plan.mjs"
},
"files": ["bin/", "src/", "README.md"],
"scripts": {
"test": "node --test \"test/*.test.mjs\""
},
"peerDependencies": {
"next": ">=14",
"react": ">=18"
},
"engines": {
"node": ">=20"
}
}
31 changes: 31 additions & 0 deletions packages/next/src/args.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Tiny flag parser: --k v, --k=v, --flag, --no-flag. Positionals in `_`.
export function parseArgs(argv, { booleans = [] } = {}) {
const out = { _: [] };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (!a.startsWith("--")) {
out._.push(a);
continue;
}
const eq = a.indexOf("=");
if (eq !== -1) {
out[a.slice(2, eq)] = a.slice(eq + 1);
continue;
}
const key = a.slice(2);
if (key.startsWith("no-")) {
out[key.slice(3)] = false;
continue;
}
if (
booleans.includes(key) ||
i + 1 >= argv.length ||
argv[i + 1].startsWith("--")
) {
out[key] = true;
continue;
}
out[key] = argv[++i];
}
return out;
}
100 changes: 100 additions & 0 deletions packages/next/src/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// build — publish the corpus and the compiled tool layer as static assets.
//
// This is the `sitemap.xml` half of the analogy: a deployed app tells machine
// readers what it is (`/.well-known/sightmap.json`) and what it can do
// (`/.well-known/sightkick.json`), served from the CDN like any other public
// file. The runtime that turns the IR into WebMCP tools ships alongside, and
// `webmcp.init.js` bundles runtime + IR into the one file agent-browser's
// `--init-script` wants — the artifact its `webmcp-gen` skill would otherwise
// hand-write.
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, relative } from "node:path";
import { parseArgs } from "./args.mjs";
import { requireBin, run } from "./cli-tools.mjs";

export const WELL_KNOWN = {
corpus: ".well-known/sightmap.json",
ir: ".well-known/sightkick.json",
runtime: "sightkick-runtime.js",
};

/** runtime bundle + IR → a single self-booting script for `agent-browser --init-script`. */
export function renderInitScript(runtimeJs, irJson) {
const ir = JSON.stringify(JSON.parse(irJson));
return (
`// Generated by sightmap-next build — do not edit. Sightkick runtime + compiled IR.\n` +
`// Load before navigation: agent-browser --init-script ./webmcp.init.js open <url>\n` +
`// Then: agent-browser webmcp list\n` +
`window.__sightkick_ir = ${ir};\n` +
runtimeJs +
`\n;(function(){ if (window.__sightkick && !window.__sightkick.ir) window.__sightkick.load(window.__sightkick_ir); })();\n`
);
}

export function build(
root,
{
publicDir = "public",
initScript = "webmcp.init.js",
sightkickArgs = [],
log = console.log,
} = {},
) {
const sightmap = requireBin("sightmap", root, "npm i -D @sightmap/sightmap");
const sightkick = requireBin(
"sightkick",
root,
"npm i -D @sightmap/sightkick",
);
const pub = join(root, publicDir);
mkdirSync(join(pub, ".well-known"), { recursive: true });

const corpusOut = join(pub, WELL_KNOWN.corpus);
run(sightmap, ["export", root, "-o", corpusOut], { cwd: root });
log(`✓ ${relative(root, corpusOut)} (sightmap export)`);

const irOut = join(pub, WELL_KNOWN.ir);
const b = run(sightkick, ["build", root, "-o", irOut, ...sightkickArgs], {
cwd: root,
});
log(
`✓ ${relative(root, irOut)} (${(b.stdout || b.stderr).trim().split("\n").pop()})`,
);

const rtOut = join(pub, WELL_KNOWN.runtime);
run(sightkick, ["runtime", "-o", rtOut], { cwd: root });
log(`✓ ${relative(root, rtOut)} (sightkick runtime)`);

if (initScript) {
const out = join(root, initScript);
writeFileSync(
out,
renderInitScript(
readFileSync(rtOut, "utf8"),
readFileSync(irOut, "utf8"),
),
);
log(
`✓ ${relative(root, out)} (runtime + IR, for agent-browser --init-script)`,
);
}
return { corpusOut, irOut, rtOut };
}

export async function buildCommand(argv) {
const args = parseArgs(argv, { booleans: ["init-script"] });
const root = args._[0] ?? process.cwd();
build(root, {
publicDir: args["public-dir"],
initScript:
args["init-script"] === false
? null
: typeof args["init-script"] === "string"
? args["init-script"]
: undefined,
sightkickArgs: args["sightkick-args"]
? String(args["sightkick-args"]).split(/\s+/).filter(Boolean)
: [],
});
return 0;
}
46 changes: 46 additions & 0 deletions packages/next/src/cli-tools.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Resolve the sightmap / sightkick / agent-browser CLIs: the nearest
// node_modules/.bin above the app dir (they're normally devDependencies), then PATH.
import { existsSync } from "node:fs";
import { join, dirname, delimiter } from "node:path";
import { spawnSync } from "node:child_process";

export function resolveBin(name, root = process.cwd()) {
const file = process.platform === "win32" ? `${name}.cmd` : name;
// Walk up from the app dir: npm workspaces hoist bins to an ancestor node_modules/.bin.
for (let dir = root; ; dir = dirname(dir)) {
const local = join(dir, "node_modules", ".bin", file);
if (existsSync(local)) return local;
if (dirname(dir) === dir) break;
}
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
const p = join(dir, process.platform === "win32" ? `${name}.cmd` : name);
if (dir && existsSync(p)) return p;
}
return null;
}

export function run(bin, args, { cwd, input, quiet = false } = {}) {
const res = spawnSync(bin, args, {
cwd,
input,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
shell: process.platform === "win32",
});
if (res.error) throw new Error(`${bin}: ${res.error.message}`);
if (res.status !== 0 && !quiet) {
throw new Error(
`${[bin, ...args].join(" ")} exited ${res.status}\n${res.stderr || res.stdout}`,
);
}
return res;
}

export function requireBin(name, root, hint) {
const bin = resolveBin(name, root);
if (!bin)
throw new Error(
`"${name}" not found on PATH or in node_modules/.bin. ${hint}`,
);
return bin;
}
17 changes: 17 additions & 0 deletions packages/next/src/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { ReactElement } from "react";

export interface SightkickToolsProps {
/** The compiled IR: a URL to fetch (default "/.well-known/sightkick.json") or the parsed JSON to inline. */
ir?: string | object;
/** URL of the Sightkick runtime bundle (default "/sightkick-runtime.js"). */
runtime?: string;
/** Render nothing when false (e.g. gate on an environment variable). */
enabled?: boolean;
/** next/script strategy; "afterInteractive" by default. */
strategy?: "afterInteractive" | "lazyOnload" | "beforeInteractive";
}

export function SightkickTools(
props?: SightkickToolsProps,
): ReactElement | null;
export default SightkickTools;
Loading
Loading