@cairnvibe/indexer 0.2.6 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +25 -0
- package/dist/init.js +25 -3
- package/dist/webmcp.js +134 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ const llm_1 = require("./llm");
|
|
|
16
16
|
const manifest_1 = require("./manifest");
|
|
17
17
|
const diff_1 = require("./diff");
|
|
18
18
|
const docs_1 = require("./docs");
|
|
19
|
+
const webmcp_1 = require("./webmcp");
|
|
19
20
|
const init_1 = require("./init");
|
|
20
21
|
const setup_1 = require("./setup");
|
|
21
22
|
/**
|
|
@@ -180,6 +181,29 @@ async function main() {
|
|
|
180
181
|
console.error(`wrote ${outPath}`);
|
|
181
182
|
return;
|
|
182
183
|
}
|
|
184
|
+
if (command === "webmcp") {
|
|
185
|
+
const manifestPath = node_path_1.default.join(node_path_1.default.resolve(dir), "ui-manifest.json");
|
|
186
|
+
if (!node_fs_1.default.existsSync(manifestPath)) {
|
|
187
|
+
console.error(`cairn webmcp: no ${manifestPath} — run \`cairn build ${dir}\` first.`);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
const manifest = core_1.ManifestSchema.parse(JSON.parse(node_fs_1.default.readFileSync(manifestPath, "utf8")));
|
|
191
|
+
const component = (0, webmcp_1.generateWebMcpComponent)(manifest);
|
|
192
|
+
if (!component) {
|
|
193
|
+
console.error("cairn webmcp: no real, traced actions (apiCall-backed elements) found in this manifest — nothing safe to register. Nothing written.");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const outPath = node_path_1.default.join(node_path_1.default.resolve(dir), "components", "CairnWebMcpTools.tsx");
|
|
197
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(outPath), { recursive: true });
|
|
198
|
+
node_fs_1.default.writeFileSync(outPath, component);
|
|
199
|
+
console.error(`wrote ${outPath}`);
|
|
200
|
+
console.error("");
|
|
201
|
+
console.error("Next step: add it once, near your Copilot widget (e.g. app/layout.tsx):");
|
|
202
|
+
console.error(' import { CairnWebMcpTools } from "./components/CairnWebMcpTools";');
|
|
203
|
+
console.error(" <CairnWebMcpTools />");
|
|
204
|
+
console.error("Re-run `cairn webmcp` after a fresh `cairn build` whenever this app's real actions change.");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
183
207
|
console.error("usage:");
|
|
184
208
|
console.error(" cairn setup [dir] (the one-command path: installs deps, asks for keys — skippable, wires the widget in, builds once, auto-rebuilds on future `npm run build`)");
|
|
185
209
|
console.error(" cairn init <dir> (scaffolds the API route/server + .env.example, detects your framework — no prompts, no installs)");
|
|
@@ -188,6 +212,7 @@ async function main() {
|
|
|
188
212
|
console.error(" cairn build <url> [--provider anthropic|groq] [--out <dir>] [--storage-state <file>] (any framework — crawls a running app; --storage-state replays a saved logged-in session for auth-gated apps)");
|
|
189
213
|
console.error(" cairn diff <old-manifest.json> <new-manifest.json>");
|
|
190
214
|
console.error(" cairn docs <dir> (reads <dir>/ui-manifest.json, writes <dir>/CAIRN_DOCS.md)");
|
|
215
|
+
console.error(" cairn webmcp <dir> (reads <dir>/ui-manifest.json, writes <dir>/components/CairnWebMcpTools.tsx — registers this app's real, traced actions as WebMCP tools any agent can call, not just Cairn)");
|
|
191
216
|
process.exit(command ? 1 : 0);
|
|
192
217
|
}
|
|
193
218
|
main().catch((err) => {
|
package/dist/init.js
CHANGED
|
@@ -35,6 +35,20 @@ function writeIfAbsent(filePath, content, result) {
|
|
|
35
35
|
node_fs_1.default.writeFileSync(filePath, content);
|
|
36
36
|
result.filesWritten.push(filePath);
|
|
37
37
|
}
|
|
38
|
+
// Found live: if your Next.js config transpiles @cairnvibe/sdk (needed when
|
|
39
|
+
// importing the client widget straight from source rather than a compiled
|
|
40
|
+
// dist), webpack bundling the `ws` package used by the speak/transcribe/
|
|
41
|
+
// realtime routes silently breaks its Deepgram WebSocket connections — every
|
|
42
|
+
// request hung for ~10s then failed with ECONNRESET, even though the exact
|
|
43
|
+
// same code worked outside Next's dev bundler. Marking `ws` as a server
|
|
44
|
+
// external package (next.config.js) fixes it.
|
|
45
|
+
const WS_TRANSPILE_WARNING = [
|
|
46
|
+
" If your next.config.js sets transpilePackages for @cairnvibe/sdk, also",
|
|
47
|
+
' add "ws" to serverExternalPackages (Next 15) or',
|
|
48
|
+
' experimental.serverComponentsExternalPackages (Next 14) — webpack-',
|
|
49
|
+
" bundling ws otherwise silently breaks the Deepgram speak/transcribe",
|
|
50
|
+
" connections it uses.",
|
|
51
|
+
];
|
|
38
52
|
function runInit(dir, options = {}) {
|
|
39
53
|
const absDir = node_path_1.default.resolve(dir);
|
|
40
54
|
const pkgPath = node_path_1.default.join(absDir, "package.json");
|
|
@@ -66,6 +80,8 @@ function runInit(dir, options = {}) {
|
|
|
66
80
|
widgetProps.push('speakEndpoint="/api/copilot/speak"', 'transcribeEndpoint="/api/copilot/transcribe"');
|
|
67
81
|
}
|
|
68
82
|
result.nextSteps.push("1. cp .env.example .env and fill in your key(s).", "2. Add the widget to app/layout.tsx:", ' import { Copilot } from "@cairnvibe/sdk";', ` <Copilot ${widgetProps.join(" ")} />`, "3. npx cairn build . (scans this Next.js app's source)", "4. npm run dev, then ask it a question.");
|
|
83
|
+
if (options.voice)
|
|
84
|
+
result.nextSteps.push(...WS_TRANSPILE_WARNING);
|
|
69
85
|
}
|
|
70
86
|
else if (result.framework === "next-pages-router") {
|
|
71
87
|
writeIfAbsent(node_path_1.default.join(absDir, "pages", "api", "copilot.ts"), NEXT_PAGES_API_ROUTE, result);
|
|
@@ -76,6 +92,8 @@ function runInit(dir, options = {}) {
|
|
|
76
92
|
widgetProps.push('speakEndpoint="/api/copilot/speak"', 'transcribeEndpoint="/api/copilot/transcribe"');
|
|
77
93
|
}
|
|
78
94
|
result.nextSteps.push("1. cp .env.example .env and fill in your key(s).", "2. Add the widget to pages/_app.tsx:", ' import { Copilot } from "@cairnvibe/sdk";', ` <Copilot ${widgetProps.join(" ")} />`, "3. npx cairn build . (scans this Next.js app's source)", "4. npm run dev, then ask it a question.");
|
|
95
|
+
if (options.voice)
|
|
96
|
+
result.nextSteps.push(...WS_TRANSPILE_WARNING);
|
|
79
97
|
}
|
|
80
98
|
else {
|
|
81
99
|
writeIfAbsent(node_path_1.default.join(absDir, "cairn-server.cjs"), STANDALONE_SERVER, result);
|
|
@@ -151,7 +169,7 @@ export async function POST(request: Request) {
|
|
|
151
169
|
if ("error" in result.body) {
|
|
152
170
|
return Response.json(result.body, { status: result.status });
|
|
153
171
|
}
|
|
154
|
-
return new Response(result.body.
|
|
172
|
+
return new Response(result.body.stream, {
|
|
155
173
|
status: result.status,
|
|
156
174
|
headers: { "content-type": result.body.contentType },
|
|
157
175
|
});
|
|
@@ -170,6 +188,7 @@ export async function POST(request: Request) {
|
|
|
170
188
|
}
|
|
171
189
|
`;
|
|
172
190
|
const NEXT_PAGES_SPEAK_ROUTE = `import type { NextApiRequest, NextApiResponse } from "next";
|
|
191
|
+
import { Readable } from "node:stream";
|
|
173
192
|
import { createSpeakHandler } from "@cairnvibe/sdk/speak-server";
|
|
174
193
|
|
|
175
194
|
const handler = createSpeakHandler({ apiKey: process.env.DEEPGRAM_API_KEY ?? "" });
|
|
@@ -180,7 +199,8 @@ export default async function speak(req: NextApiRequest, res: NextApiResponse) {
|
|
|
180
199
|
if ("error" in result.body) {
|
|
181
200
|
return res.status(result.status).json(result.body);
|
|
182
201
|
}
|
|
183
|
-
res.status(result.status).setHeader("content-type", result.body.contentType)
|
|
202
|
+
res.status(result.status).setHeader("content-type", result.body.contentType);
|
|
203
|
+
Readable.fromWeb(result.body.stream).pipe(res);
|
|
184
204
|
}
|
|
185
205
|
`;
|
|
186
206
|
const NEXT_PAGES_TRANSCRIBE_ROUTE = `import type { NextApiRequest, NextApiResponse } from "next";
|
|
@@ -241,11 +261,13 @@ app.post("/api/copilot", async (req, res) => {
|
|
|
241
261
|
|
|
242
262
|
if (process.env.DEEPGRAM_API_KEY) {
|
|
243
263
|
const { createSpeakHandler } = require("@cairnvibe/sdk/speak-server");
|
|
264
|
+
const { Readable } = require("node:stream");
|
|
244
265
|
const speak = createSpeakHandler({ apiKey: process.env.DEEPGRAM_API_KEY });
|
|
245
266
|
app.post("/api/copilot/speak", async (req, res) => {
|
|
246
267
|
const result = await speak(req.body?.text ?? "");
|
|
247
268
|
if ("error" in result.body) return res.status(result.status).json(result.body);
|
|
248
|
-
res.status(result.status).type(result.body.contentType)
|
|
269
|
+
res.status(result.status).type(result.body.contentType);
|
|
270
|
+
Readable.fromWeb(result.body.stream).pipe(res);
|
|
249
271
|
});
|
|
250
272
|
}
|
|
251
273
|
|
package/dist/webmcp.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Cairn as a WebMCP *producer*, not just a consumer
|
|
3
|
+
// (https://webmachinelearning.github.io/webmcp/). The indexer already
|
|
4
|
+
// traces real, safe, mutating actions statically (l1-scan.ts's
|
|
5
|
+
// findApiCallIn — a real POST/PUT/PATCH/DELETE call an element's own
|
|
6
|
+
// handler already makes, never invented; GET is deliberately excluded, per
|
|
7
|
+
// ApiCallSchema's own doc comment, since a read-only call isn't an
|
|
8
|
+
// "action"). This turns that same traced set into real
|
|
9
|
+
// document.modelContext.registerTool() calls — so running `cairn build`
|
|
10
|
+
// doesn't just make a site usable by Cairn's own runtime, it also makes it
|
|
11
|
+
// agent-ready for any future agent that understands the standard, with zero
|
|
12
|
+
// Cairn lock-in (the generated component has no Cairn import at all).
|
|
13
|
+
//
|
|
14
|
+
// Pure formatting/codegen from an already-built manifest, no LLM call — same
|
|
15
|
+
// shape as docs.ts's generateDocsMarkdown.
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.generateWebMcpComponent = generateWebMcpComponent;
|
|
18
|
+
/** Turns a route into a name-safe prefix — "/" (home) has no segments to
|
|
19
|
+
* join, "/invoices" -> "invoices", "/agents/new" -> "agents-new". */
|
|
20
|
+
function routePrefix(route) {
|
|
21
|
+
const slug = route
|
|
22
|
+
.split("/")
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
.join("-")
|
|
25
|
+
.replace(/[^a-zA-Z0-9-]/g, "-");
|
|
26
|
+
return slug || "home";
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Collects every real apiCall-backed element across the whole manifest into
|
|
30
|
+
* one flat, deduped tool list. Deduped by (id, method, url) together, not
|
|
31
|
+
* id alone — id is only ever guaranteed unique WITHIN a page (l1-scan
|
|
32
|
+
* assigns them per-page), so two different page-scoped elements can share
|
|
33
|
+
* an id by coincidence and must stay two separate tools; a genuinely
|
|
34
|
+
* global element (e.g. a layout nav action) instead appears on every
|
|
35
|
+
* page's own `elements` array with the IDENTICAL apiCall each time
|
|
36
|
+
* (assembleManifest spreads globalElements onto every page) — matching on
|
|
37
|
+
* the full key is what tells "really the same action, seen N times" apart
|
|
38
|
+
* from "two different actions that happen to share an id".
|
|
39
|
+
*/
|
|
40
|
+
function collectTools(manifest) {
|
|
41
|
+
const seen = new Map();
|
|
42
|
+
const pages = [...manifest.pages].sort((a, b) => a.route.localeCompare(b.route));
|
|
43
|
+
for (const page of pages) {
|
|
44
|
+
for (const el of page.elements) {
|
|
45
|
+
if (!el.apiCall)
|
|
46
|
+
continue;
|
|
47
|
+
const key = `${el.id}::${el.apiCall.method}::${el.apiCall.url}`;
|
|
48
|
+
if (seen.has(key))
|
|
49
|
+
continue;
|
|
50
|
+
seen.set(key, {
|
|
51
|
+
name: `${routePrefix(page.route)}-${el.id}`,
|
|
52
|
+
description: el.does,
|
|
53
|
+
apiCall: el.apiCall,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return [...seen.values()];
|
|
58
|
+
}
|
|
59
|
+
function jsStringLiteral(value) {
|
|
60
|
+
return JSON.stringify(value);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Generates a real, self-contained TSX component ("use client") that
|
|
64
|
+
* registers every real apiCall-backed action in the manifest as a WebMCP
|
|
65
|
+
* tool, on mount, at the app root — one component, added once, covers every
|
|
66
|
+
* page (each tool's execute() fires the same same-origin fetch the client-
|
|
67
|
+
* side `do` verb's own apiCall fallback already does — see verb-executor.ts's
|
|
68
|
+
* executeApiCall — real session cookies via credentials: "same-origin", no
|
|
69
|
+
* request body since static tracing only ever captures method+url). Returns
|
|
70
|
+
* null when the manifest has no real apiCall-backed elements — nothing
|
|
71
|
+
* safe to register, so nothing to scaffold.
|
|
72
|
+
*/
|
|
73
|
+
function generateWebMcpComponent(manifest) {
|
|
74
|
+
const tools = collectTools(manifest);
|
|
75
|
+
if (tools.length === 0)
|
|
76
|
+
return null;
|
|
77
|
+
const registrations = tools
|
|
78
|
+
.map((tool) => ` register({
|
|
79
|
+
name: ${jsStringLiteral(tool.name)},
|
|
80
|
+
description: ${jsStringLiteral(tool.description)},
|
|
81
|
+
inputSchema: { type: "object", properties: {} },
|
|
82
|
+
execute: async () => {
|
|
83
|
+
const res = await fetch(${jsStringLiteral(tool.apiCall.url)}, { method: ${jsStringLiteral(tool.apiCall.method)}, credentials: "same-origin" });
|
|
84
|
+
return { ok: res.ok, status: res.status };
|
|
85
|
+
},
|
|
86
|
+
});`)
|
|
87
|
+
.join("\n\n");
|
|
88
|
+
return `// Generated by \`cairn webmcp\` from commit ${jsStringLiteral(manifest.commit)} at ${manifest.generatedAt}.
|
|
89
|
+
// Do not edit by hand — regenerate instead (\`npx cairn webmcp <dir>\` after
|
|
90
|
+
// a fresh \`cairn build\`, whenever the app's real actions change).
|
|
91
|
+
//
|
|
92
|
+
// Registers this app's own real, traced actions
|
|
93
|
+
// (https://webmachinelearning.github.io/webmcp/) so ANY agent that
|
|
94
|
+
// understands the standard — not just Cairn — can discover and call them
|
|
95
|
+
// directly. No Cairn import here on purpose: this keeps working even in an
|
|
96
|
+
// app that later removes Cairn entirely, and costs nothing on a browser
|
|
97
|
+
// that doesn't implement WebMCP yet (the overwhelming majority right now —
|
|
98
|
+
// registerTool() is checked for and simply skipped if absent).
|
|
99
|
+
"use client";
|
|
100
|
+
|
|
101
|
+
import { useEffect } from "react";
|
|
102
|
+
|
|
103
|
+
export function CairnWebMcpTools() {
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
const modelContext = (document as unknown as { modelContext?: any }).modelContext;
|
|
106
|
+
if (!modelContext?.registerTool) return;
|
|
107
|
+
|
|
108
|
+
let unregistered = false;
|
|
109
|
+
const handles: { remove?: () => void }[] = [];
|
|
110
|
+
const register = (tool: unknown) => {
|
|
111
|
+
void modelContext
|
|
112
|
+
.registerTool(tool)
|
|
113
|
+
.then((registered: { remove?: () => void }) => {
|
|
114
|
+
if (unregistered) registered?.remove?.();
|
|
115
|
+
else handles.push(registered);
|
|
116
|
+
})
|
|
117
|
+
.catch(() => {
|
|
118
|
+
// A page without a real WebMCP implementation, or a tool name
|
|
119
|
+
// this app already registered some other way — nothing to do.
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
${registrations}
|
|
124
|
+
|
|
125
|
+
return () => {
|
|
126
|
+
unregistered = true;
|
|
127
|
+
for (const handle of handles) handle?.remove?.();
|
|
128
|
+
};
|
|
129
|
+
}, []);
|
|
130
|
+
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
`;
|
|
134
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cairnvibe/indexer",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"description": "Cairn's analyzer and installer (the `cairn` CLI) — scans Next.js source or crawls any running app, and scaffolds the backend either way.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": { "access": "public" },
|