@illusoryai/pi-framer 0.1.0

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.
@@ -0,0 +1,397 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+
4
+ const PROJECT_URL = process.env.FRAMER_PROJECT_URL;
5
+ const API_KEY = process.env.FRAMER_API_KEY;
6
+
7
+ export default function (pi: ExtensionAPI) {
8
+ if (!PROJECT_URL || !API_KEY) {
9
+ return; // Framer not configured — skip silently
10
+ }
11
+
12
+ let framer: any = null;
13
+
14
+ async function getFramer(): Promise<any> {
15
+ if (framer) return framer;
16
+ const { connect } = await import("framer-api");
17
+ framer = await connect(PROJECT_URL!, API_KEY!);
18
+ return framer;
19
+ }
20
+
21
+ // Disconnect on session shutdown
22
+ pi.on("session_shutdown", async () => {
23
+ if (framer) {
24
+ try {
25
+ await framer.disconnect();
26
+ } catch {}
27
+ framer = null;
28
+ }
29
+ });
30
+
31
+ // --- Helpers ---
32
+
33
+ async function getCollectionByName(f: any, name: string) {
34
+ const collections = await f.getCollections();
35
+ return (
36
+ collections.find(
37
+ (c: any) =>
38
+ c.name?.toLowerCase() === name.toLowerCase() ||
39
+ c.id === name
40
+ ) || collections[0]
41
+ );
42
+ }
43
+
44
+ function extractFieldValue(entry: any, full = false): any {
45
+ if (!entry || typeof entry !== "object") return entry;
46
+ if (entry.type === "image" && entry.value?.url) return entry.value.url;
47
+ if (entry.type === "formattedText" && entry.value) {
48
+ if (full) return entry.value; // Return full HTML
49
+ const text = entry.value.replace(/<[^>]+>/g, "");
50
+ return text.length > 200 ? text.substring(0, 200) + "..." : text;
51
+ }
52
+ if ("value" in entry) return entry.value;
53
+ return entry;
54
+ }
55
+
56
+ function buildFieldData(
57
+ fields: any[],
58
+ assignments: string[]
59
+ ): Record<string, any> {
60
+ const data: Record<string, any> = {};
61
+ for (const assign of assignments) {
62
+ const eqIdx = assign.indexOf("=");
63
+ if (eqIdx === -1) continue;
64
+ const key = assign.substring(0, eqIdx).trim();
65
+ const val = assign.substring(eqIdx + 1).trim();
66
+
67
+ // Find field by name or ID
68
+ const field = fields.find(
69
+ (f: any) =>
70
+ f.name?.toLowerCase() === key.toLowerCase() || f.id === key
71
+ );
72
+ if (!field) continue;
73
+
74
+ const fieldId = field.id;
75
+ switch (field.type) {
76
+ case "boolean":
77
+ data[fieldId] = { type: "boolean", value: val === "true" };
78
+ break;
79
+ case "date":
80
+ data[fieldId] = { type: "date", value: new Date(val).toISOString() };
81
+ break;
82
+ case "number":
83
+ data[fieldId] = { type: "number", value: Number(val) };
84
+ break;
85
+ default:
86
+ data[fieldId] = {
87
+ type: "string",
88
+ value: val,
89
+ valueByLocale: {},
90
+ };
91
+ }
92
+ }
93
+ return data;
94
+ }
95
+
96
+ // --- Command handlers ---
97
+
98
+ async function handleInfo() {
99
+ const f = await getFramer();
100
+ const info = await f.getProjectInfo();
101
+ return JSON.stringify(info, null, 2);
102
+ }
103
+
104
+ async function handleCollections() {
105
+ const f = await getFramer();
106
+ const collections = await f.getCollections();
107
+ const result = collections.map((c: any) => ({
108
+ name: c.name,
109
+ id: c.id,
110
+ managedBy: c.managedBy,
111
+ slugField: c.slugFieldName,
112
+ }));
113
+ return JSON.stringify(result, null, 2);
114
+ }
115
+
116
+ async function handleSchema(collectionName: string) {
117
+ const f = await getFramer();
118
+ const col = await getCollectionByName(f, collectionName);
119
+ if (!col) return "Collection not found";
120
+ const fields = await col.getFields();
121
+ const result = fields.map((field: any) => ({
122
+ id: field.id,
123
+ name: field.name,
124
+ type: field.type,
125
+ }));
126
+ return `Collection: ${col.name} (${col.id})\nSlug field: ${col.slugFieldName}\n\n${JSON.stringify(result, null, 2)}`;
127
+ }
128
+
129
+ async function handleItems(collectionName: string) {
130
+ const f = await getFramer();
131
+ const col = await getCollectionByName(f, collectionName);
132
+ if (!col) return "Collection not found";
133
+ const fields = await col.getFields();
134
+ const items = await col.getItems();
135
+
136
+ const fieldMap: Record<string, string> = {};
137
+ for (const field of fields) fieldMap[field.id] = field.name;
138
+
139
+ // Find title field
140
+ const titleField = fields.find(
141
+ (field: any) =>
142
+ field.name?.toLowerCase() === "title" ||
143
+ field.name?.toLowerCase() === "name"
144
+ );
145
+
146
+ const result = items.map((item: any) => {
147
+ const title = titleField
148
+ ? extractFieldValue(item.fieldData?.[titleField.id])
149
+ : item.slug;
150
+ return {
151
+ id: item.id,
152
+ slug: item.slug,
153
+ draft: item.draft,
154
+ title,
155
+ };
156
+ });
157
+ return `${items.length} items in ${col.name}:\n\n${JSON.stringify(result, null, 2)}`;
158
+ }
159
+
160
+ async function handleItem(slugOrId: string) {
161
+ const f = await getFramer();
162
+ const collections = await f.getCollections();
163
+
164
+ for (const col of collections) {
165
+ const fields = await col.getFields();
166
+ const items = await col.getItems();
167
+ const fieldMap: Record<string, string> = {};
168
+ for (const field of fields) fieldMap[field.id] = field.name;
169
+
170
+ const item = items.find(
171
+ (i: any) => i.slug === slugOrId || i.id === slugOrId
172
+ );
173
+ if (!item) continue;
174
+
175
+ const data: Record<string, any> = {
176
+ id: item.id,
177
+ slug: item.slug,
178
+ draft: item.draft,
179
+ collection: col.name,
180
+ };
181
+
182
+ for (const [fieldId, value] of Object.entries(item.fieldData || {})) {
183
+ const fieldName = fieldMap[fieldId] || fieldId;
184
+ data[fieldName] = extractFieldValue(value, true);
185
+ }
186
+
187
+ return JSON.stringify(data, null, 2);
188
+ }
189
+ return `Item not found: ${slugOrId}`;
190
+ }
191
+
192
+ async function handleUpdate(args: string) {
193
+ const parts = args.split(/\s+/);
194
+ const slugOrId = parts[0];
195
+ const assignments = parts.slice(1);
196
+
197
+ if (!slugOrId || assignments.length === 0) {
198
+ return "Usage: update <slug-or-id> field1=value1 field2=value2";
199
+ }
200
+
201
+ const f = await getFramer();
202
+ const collections = await f.getCollections();
203
+
204
+ for (const col of collections) {
205
+ const fields = await col.getFields();
206
+ const items = await col.getItems();
207
+ const item = items.find(
208
+ (i: any) => i.slug === slugOrId || i.id === slugOrId
209
+ );
210
+ if (!item) continue;
211
+
212
+ const fieldData = buildFieldData(fields, assignments);
213
+ if (Object.keys(fieldData).length === 0) {
214
+ return `No valid fields found. Available: ${fields.map((f: any) => f.name).join(", ")}`;
215
+ }
216
+
217
+ const updated = await item.setAttributes({ fieldData });
218
+ return `Updated ${slugOrId}:\n${JSON.stringify(fieldData, null, 2)}`;
219
+ }
220
+ return `Item not found: ${slugOrId}`;
221
+ }
222
+
223
+ async function handleCreate(args: string) {
224
+ // Parse: create <collection> field1=value1 field2=value2
225
+ const parts = args.split(/\s+/);
226
+ const collectionName = parts[0];
227
+ const assignments = parts.slice(1);
228
+
229
+ if (!collectionName) {
230
+ return "Usage: create <collection> field1=value1 field2=value2";
231
+ }
232
+
233
+ const f = await getFramer();
234
+ const col = await getCollectionByName(f, collectionName);
235
+ if (!col) return "Collection not found";
236
+
237
+ const fields = await col.getFields();
238
+ const fieldData = buildFieldData(fields, assignments);
239
+
240
+ // Extract slug if provided
241
+ const slugAssign = assignments.find((a) =>
242
+ a.toLowerCase().startsWith("slug=")
243
+ );
244
+ const slug = slugAssign ? slugAssign.split("=")[1] : undefined;
245
+
246
+ const newItem: any = { fieldData, draft: false };
247
+ if (slug) newItem.slug = slug;
248
+
249
+ await col.addItems([newItem]);
250
+ return `Created item in ${col.name}${slug ? ` with slug: ${slug}` : ""}`;
251
+ }
252
+
253
+ async function handlePublish() {
254
+ const f = await getFramer();
255
+ const result = await f.publish();
256
+ return JSON.stringify(result, null, 2);
257
+ }
258
+
259
+ async function handleDeploy(deploymentId: string) {
260
+ if (!deploymentId) return "Usage: deploy <deployment-id>";
261
+ const f = await getFramer();
262
+ const result = await f.deploy(deploymentId);
263
+ return JSON.stringify(result, null, 2);
264
+ }
265
+
266
+ async function handleChanges() {
267
+ const f = await getFramer();
268
+ const changes = await f.getChangedPaths();
269
+ const contributors = await f.getChangeContributors();
270
+ return JSON.stringify({ changes, contributors }, null, 2);
271
+ }
272
+
273
+ async function handleCode(args?: string) {
274
+ const f = await getFramer();
275
+ if (!args) {
276
+ const code = await f.getCustomCode();
277
+ return JSON.stringify(code, null, 2);
278
+ }
279
+ // Set custom code: code <location> <html>
280
+ const [location, ...rest] = args.split(/\s+/);
281
+ const html = rest.join(" ");
282
+ const validLocations = ["headStart", "headEnd", "bodyStart", "bodyEnd"];
283
+ if (!validLocations.includes(location)) {
284
+ return `Invalid location. Use: ${validLocations.join(", ")}`;
285
+ }
286
+ await f.setCustomCode({ [location]: { html, disabled: false } });
287
+ return `Custom code set at ${location}`;
288
+ }
289
+
290
+ async function handlePublishDeploy() {
291
+ const f = await getFramer();
292
+ const pubResult = await f.publish();
293
+ const deploymentId = pubResult.deployment?.id;
294
+ if (!deploymentId) return "Publish failed — no deployment ID returned";
295
+ const deployResult = await f.deploy(deploymentId);
296
+ return `Published and deployed!\n\nPublish: ${JSON.stringify(pubResult, null, 2)}\n\nDeploy: ${JSON.stringify(deployResult, null, 2)}`;
297
+ }
298
+
299
+ // --- Register tool ---
300
+
301
+ pi.registerTool({
302
+ name: "framer",
303
+ label: "Framer CMS",
304
+ description:
305
+ "Manage Framer site via Server API. CMS operations, publishing, and custom code.\n\n" +
306
+ "Commands:\n" +
307
+ " info — Project info\n" +
308
+ " collections — List all CMS collections\n" +
309
+ " schema <collection> — Collection field schema\n" +
310
+ " items <collection> — List items (title, slug, draft)\n" +
311
+ " item <slug-or-id> — Full item details\n" +
312
+ " update <slug-or-id> field=val — Update item fields\n" +
313
+ " create <collection> field=val — Create new item\n" +
314
+ " publish — Create preview deployment\n" +
315
+ " deploy <deployment-id> — Promote to production\n" +
316
+ " ship — Publish + deploy in one step\n" +
317
+ " changes — Pending changes + contributors\n" +
318
+ " code — Get custom code\n" +
319
+ " code <location> <html> — Set custom code (headStart|headEnd|bodyStart|bodyEnd)",
320
+ parameters: Type.Object({
321
+ command: Type.String({
322
+ description:
323
+ 'Framer command, e.g. \'items Blog\', \'update my-post-slug "Small Description"="New description"\'',
324
+ }),
325
+ }),
326
+ async execute(_toolCallId, params, _signal) {
327
+ try {
328
+ const cmd = params.command.trim();
329
+ const spaceIdx = cmd.indexOf(" ");
330
+ const action = spaceIdx === -1 ? cmd : cmd.substring(0, spaceIdx);
331
+ const args = spaceIdx === -1 ? "" : cmd.substring(spaceIdx + 1).trim();
332
+
333
+ let result: string;
334
+
335
+ switch (action.toLowerCase()) {
336
+ case "info":
337
+ result = await handleInfo();
338
+ break;
339
+ case "collections":
340
+ result = await handleCollections();
341
+ break;
342
+ case "schema":
343
+ result = await handleSchema(args || "Blog");
344
+ break;
345
+ case "items":
346
+ result = await handleItems(args || "Blog");
347
+ break;
348
+ case "item":
349
+ result = await handleItem(args);
350
+ break;
351
+ case "update":
352
+ result = await handleUpdate(args);
353
+ break;
354
+ case "create":
355
+ result = await handleCreate(args);
356
+ break;
357
+ case "publish":
358
+ result = await handlePublish();
359
+ break;
360
+ case "deploy":
361
+ result = await handleDeploy(args);
362
+ break;
363
+ case "ship":
364
+ result = await handlePublishDeploy();
365
+ break;
366
+ case "changes":
367
+ result = await handleChanges();
368
+ break;
369
+ case "code":
370
+ result = await handleCode(args || undefined);
371
+ break;
372
+ default:
373
+ result = `Unknown command: ${action}. Use: info, collections, schema, items, item, update, create, publish, deploy, ship, changes, code`;
374
+ }
375
+
376
+ return {
377
+ content: [{ type: "text", text: result }],
378
+ details: { command: params.command },
379
+ };
380
+ } catch (err: any) {
381
+ // Reset connection on error
382
+ if (
383
+ err.message?.includes("closed") ||
384
+ err.message?.includes("disconnect")
385
+ ) {
386
+ framer = null;
387
+ }
388
+ return {
389
+ content: [
390
+ { type: "text", text: `Error: ${err.message || String(err)}` },
391
+ ],
392
+ isError: true,
393
+ };
394
+ }
395
+ },
396
+ });
397
+ }
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@illusoryai/pi-framer",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "keywords": ["pi-package"],
7
+ "pi": {
8
+ "extensions": "extensions",
9
+ "skills": "skills"
10
+ },
11
+ "dependencies": {
12
+ "framer-api": "^0.2.4"
13
+ },
14
+ "peerDependencies": {
15
+ "@mariozechner/pi-ai": "*",
16
+ "@mariozechner/pi-coding-agent": "*"
17
+ }
18
+ }
@@ -0,0 +1,76 @@
1
+ ---
2
+ name: framer-cms
3
+ description: Manage Framer CMS content, publish deployments, and automate blog operations via the framer tool. Use when creating/editing blog posts, publishing site changes, or managing CMS collections.
4
+ allowed-tools: framer, read, bash
5
+ ---
6
+
7
+ # Framer CMS Operations
8
+
9
+ **Use this when:** Creating or editing blog posts, publishing site changes, managing CMS collections, or automating content workflows.
10
+
11
+ ## Quick Reference
12
+
13
+ | Command | Purpose |
14
+ |---------|---------|
15
+ | `framer info` | Project info |
16
+ | `framer collections` | List all CMS collections |
17
+ | `framer schema Blog` | Blog collection field schema |
18
+ | `framer items Blog` | List all blog posts (title, slug, draft) |
19
+ | `framer item <slug>` | Full item details |
20
+ | `framer update <slug> field=value` | Update item fields |
21
+ | `framer create Blog field=value` | Create new blog post |
22
+ | `framer ship` | Publish + deploy to production in one step |
23
+ | `framer changes` | Show pending changes |
24
+ | `framer code` | Get/set custom code injection |
25
+
26
+ ## Blog Collection Schema
27
+
28
+ | Field | ID | Type |
29
+ |-------|----|------|
30
+ | Category | `V20PYwhSX` | string ("Guide" or "Insight") |
31
+ | Title | `RdldGed5b` | string |
32
+ | Small Description | `gZduoeUwm` | string (meta description) |
33
+ | Date | `oErJVDjg0` | date |
34
+ | Image | `wYf3IKXhH` | image |
35
+ | Featured | `BESrV0imp` | boolean |
36
+ | Author | `ifzqi5HZI` | string |
37
+ | Content | `JG8gJablh` | formattedText (HTML) |
38
+
39
+ ## Common Workflows
40
+
41
+ ### Edit a blog post's meta description
42
+
43
+ ```
44
+ framer update ai-web-scraping-mobile-proxies "Small Description"="New meta description here"
45
+ ```
46
+
47
+ ### Create a new blog post
48
+
49
+ ```
50
+ framer create Blog Title="My New Post" "Small Description"="Post summary" Category=Guide Author="Josiah Richards" Date=2026-02-14 slug=my-new-post
51
+ ```
52
+
53
+ ### Publish and deploy changes
54
+
55
+ ```
56
+ framer ship
57
+ ```
58
+
59
+ This runs `publish()` (creates preview) then `deploy(id)` (promotes to www.illusory.io).
60
+
61
+ ### After publishing: regenerate llms.txt
62
+
63
+ ```bash
64
+ bun run scripts/generate-llms.ts
65
+ ```
66
+
67
+ Add curated overrides in `workers/illusory-edge/llms-config.json` for correct titles/descriptions.
68
+
69
+ ## Architecture Notes
70
+
71
+ - **WebSocket SDK** — `framer-api` connects via persistent WebSocket, not REST
72
+ - **Connection lifecycle** — extension connects on first use, disconnects on session shutdown
73
+ - **No transactions** — handle partial failures; CMS updates are immediate
74
+ - **Publishing** — CMS changes are NOT live until `ship` (or `publish` + `deploy`)
75
+ - **Custom domain** — deploys update `www.illusory.io` via Framer's CDN
76
+ - **Worker origin** — currently `smart-let-525421.framer.app`