@1dolinski/fastforms 0.2.0 → 0.4.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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # fastforms
2
2
 
3
- Fill any form fast. Manage multiple personas locally, pick the right one at fill time.
3
+ Fill any form fast. Manage multiple personas locally, pick the right ones at fill time.
4
4
 
5
5
  ## Quick start
6
6
 
@@ -8,9 +8,8 @@ Fill any form fast. Manage multiple personas locally, pick the right one at fill
8
8
  # 1. Create your first user + business persona
9
9
  npx @1dolinski/fastforms init
10
10
 
11
- # 2. Add more personas
12
- npx @1dolinski/fastforms add user
13
- npx @1dolinski/fastforms add business
11
+ # 2. Add a form persona (org context + form-specific answers)
12
+ npx @1dolinski/fastforms add form
14
13
 
15
14
  # 3. Enable remote debugging in Chrome
16
15
  # Open chrome://inspect/#remote-debugging and toggle it on
@@ -22,15 +21,27 @@ npx @1dolinski/fastforms fill https://example.com/apply
22
21
  ## How it works
23
22
 
24
23
  1. **`fastforms init`** walks you through creating user + business personas interactively
25
- 2. Personas are saved as individual JSON files in `.fastforms/users/` and `.fastforms/businesses/`
26
- 3. **`fastforms fill <url>`** connects to Chrome, lets you pick personas, fills by label matching
27
- 4. **Review and submit manually** in Chrome
24
+ 2. **`fastforms add form`** captures the form's org, purpose, and form-specific answers
25
+ 3. Personas are saved as individual JSON files in `.fastforms/`
26
+ 4. **`fastforms fill <url>`** connects to Chrome, picks personas, fills by label matching
27
+ 5. Form personas auto-match by URL — no need to specify which one
28
+ 6. **Review and submit manually** in Chrome
28
29
 
29
30
  ## Requirements
30
31
 
31
32
  - Chrome >= 144
32
33
  - Node.js >= 18
33
34
 
35
+ ## Persona types
36
+
37
+ | Type | What it captures | Example |
38
+ |---|---|---|
39
+ | **User** | Who you are | Name, email, role, GitHub, bio |
40
+ | **Business** | What you're building | Company, product, traction, one-liner |
41
+ | **Form** | Who's asking & why | Org, purpose, form-specific answers |
42
+
43
+ Form persona facts **override** user/business data. "What attracts you to Nitro" belongs on the Nitro form persona, not on your user persona.
44
+
34
45
  ## Commands
35
46
 
36
47
  | Command | Description |
@@ -38,6 +49,7 @@ npx @1dolinski/fastforms fill https://example.com/apply
38
49
  | `fastforms init` | Create your first user + business persona |
39
50
  | `fastforms add user` | Add another user persona |
40
51
  | `fastforms add business` | Add another business persona |
52
+ | `fastforms add form` | Add a form persona (org + answers) |
41
53
  | `fastforms list` | Show all saved personas |
42
54
  | `fastforms fill <url>` | Fill any form (pick from personas) |
43
55
  | `fastforms edit` | Edit an existing persona |
@@ -50,6 +62,7 @@ npx @1dolinski/fastforms fill https://example.com/apply
50
62
  |---|---|
51
63
  | `--user <hint>` | Pre-select user persona by name |
52
64
  | `--business <hint>` | Pre-select business persona by name |
65
+ | `--form <hint>` | Pre-select form persona by name |
53
66
  | `--web` | Use web app personas instead of local files |
54
67
  | `--dir <path>` | Custom persona directory path |
55
68
  | `--port <port>` | Chrome debug port (auto-detected) |
@@ -59,15 +72,18 @@ npx @1dolinski/fastforms fill https://example.com/apply
59
72
  ```
60
73
  .fastforms/
61
74
  users/
62
- chris.json # A user persona
63
- work-chris.json # Another user persona
75
+ chris.json
76
+ work-chris.json
64
77
  businesses/
65
- apinow.json # A business persona
66
- sideproject.json # Another business persona
67
- defaults.json # Remembers your last selection
78
+ apinow.json
79
+ sideproject.json
80
+ forms/
81
+ nitro-accelerator.json
82
+ yc-application.json
83
+ defaults.json
68
84
  ```
69
85
 
70
- Each user JSON file — just fill in what you have:
86
+ ### User persona (`users/chris.json`)
71
87
 
72
88
  ```json
73
89
  {
@@ -86,7 +102,7 @@ Each user JSON file — just fill in what you have:
86
102
  }
87
103
  ```
88
104
 
89
- Each business JSON file:
105
+ ### Business persona (`businesses/apinow.json`)
90
106
 
91
107
  ```json
92
108
  {
@@ -98,6 +114,27 @@ Each business JSON file:
98
114
  }
99
115
  ```
100
116
 
117
+ ### Form persona (`forms/nitro-accelerator.json`)
118
+
119
+ ```json
120
+ {
121
+ "name": "Nitro Accelerator",
122
+ "urls": ["nitroacc.xyz"],
123
+ "organization": "Nitro",
124
+ "purpose": "Crypto accelerator application for early-stage founders",
125
+ "notes": "NYC-based, 1-month residency",
126
+ "deadline": "2026-04-01",
127
+ "facts": {
128
+ "attracts": "The density of high-signal founders and the NYC residency",
129
+ "mentor": "Vitalik — his work on public goods aligns with our mission",
130
+ "spend": "Engineering hires and infrastructure for mainnet launch",
131
+ "last week": "Shipped v2 of the API, onboarded 3 beta customers"
132
+ }
133
+ }
134
+ ```
135
+
136
+ The `facts` on a form persona are form-specific answers keyed by label hints. They override user/business data when a form field matches.
137
+
101
138
  ## Web app (optional)
102
139
 
103
140
  You can also manage personas in the web UI at [293-fastforms.vercel.app/persona](https://293-fastforms.vercel.app/persona) and use `--web` flag to pull from there. The web app has an "Export for CLI" button to download your personas as local JSON files.
@@ -116,7 +153,7 @@ node bin/fastforms.js fill https://example.com/apply
116
153
 
117
154
  ### Adding a new form mapping
118
155
 
119
- 1. Add a `buildXxxData(user, biz)` function in `lib/fill.js`
156
+ 1. Add a `buildXxxData(user, biz, form)` function in `lib/fill.js`
120
157
  2. Add a URL check in `fillForm()`
121
158
  3. Test with `node bin/fastforms.js fill <url>`
122
159
 
package/SKILL.md CHANGED
@@ -11,15 +11,18 @@ Use this skill when the user says any of:
11
11
  - "fill this form with my persona"
12
12
  - "use fastforms", "fastforms fill"
13
13
  - "set up my personas", "init fastforms"
14
- - "add a persona", "add another persona"
14
+ - "add a persona", "add a form persona"
15
15
 
16
16
  ## What it does
17
17
 
18
18
  `fastforms` is a CLI tool that:
19
19
 
20
- 1. Manages multiple personas locally in `.fastforms/users/` and `.fastforms/businesses/` directories
20
+ 1. Manages three types of personas locally in `.fastforms/`:
21
+ - **User** — who you are (name, email, bio, skills)
22
+ - **Business** — what you're building (company, product, traction)
23
+ - **Form** — who's asking and why (org, purpose, form-specific answers)
21
24
  2. Connects to Chrome via the DevTools Protocol
22
- 3. Lets you pick which user + business persona to use at fill time
25
+ 3. Lets you pick which user + business + form persona to use at fill time
23
26
  4. Fills any form using label-matching — never submits
24
27
 
25
28
  ## Quick start
@@ -28,9 +31,8 @@ Use this skill when the user says any of:
28
31
  # 1. Create your first user + business persona
29
32
  npx @1dolinski/fastforms init
30
33
 
31
- # 2. Add more personas
32
- npx @1dolinski/fastforms add user
33
- npx @1dolinski/fastforms add business
34
+ # 2. Add a form persona with org context and form-specific answers
35
+ npx @1dolinski/fastforms add form
34
36
 
35
37
  # 3. Enable remote debugging in Chrome
36
38
  # Open chrome://inspect/#remote-debugging
@@ -43,65 +45,72 @@ npx @1dolinski/fastforms fill https://example.com/apply
43
45
 
44
46
  ### `npx @1dolinski/fastforms init`
45
47
 
46
- Walks through creating a user + business persona. Saves to `.fastforms/users/<name>.json` and `.fastforms/businesses/<name>.json`.
48
+ Walks through creating a user + business persona (optionally a form persona too).
47
49
 
48
- ### `npx @1dolinski/fastforms add user|business`
50
+ ### `npx @1dolinski/fastforms add user|business|form`
49
51
 
50
- Add another user or business persona. Run as many times as you want.
52
+ Add another persona of any type.
51
53
 
52
54
  ### `npx @1dolinski/fastforms list`
53
55
 
54
- Show all saved personas.
56
+ Show all saved personas across all types.
55
57
 
56
58
  ### `npx @1dolinski/fastforms fill <url>`
57
59
 
58
- Fills any form. If you have multiple personas, prompts you to pick which ones to use.
60
+ Fills any form. If you have multiple personas, prompts you to pick. Form personas auto-match by URL.
59
61
 
60
62
  Options:
61
63
  - `--user <hint>` — pre-select a user persona by name
62
64
  - `--business <hint>` — pre-select a business persona by name
65
+ - `--form <hint>` — pre-select a form persona by name
63
66
  - `--web` — use web app personas (https://293-fastforms.vercel.app) instead of local files
64
67
  - `--dir <path>` — custom path to persona directory
65
68
  - `--port <port>` — Chrome debug port (auto-detected by default)
66
69
 
67
70
  ### `npx @1dolinski/fastforms edit`
68
71
 
69
- Pick a persona to edit interactively.
72
+ Pick any persona to edit interactively.
70
73
 
71
74
  ### `npx @1dolinski/fastforms remove`
72
75
 
73
- Pick a persona to delete.
76
+ Pick any persona to delete.
74
77
 
75
- ### `npx @1dolinski/fastforms personas`
78
+ ## Persona types
76
79
 
77
- Opens the web persona manager in Chrome.
80
+ ### User persona
81
+ Who you are. Name, email, role, GitHub, LinkedIn, bio, custom facts.
78
82
 
79
- ## Persona sources
83
+ ### Business persona
84
+ What you're building. Company, product, one-liner, traction, business model.
80
85
 
81
- 1. **Local `.fastforms/` directory** (default) — simple JSON files, no web app needed
82
- 2. **Web app** (`--web` flag) https://293-fastforms.vercel.app/persona manages personas in localStorage, CLI reads via CDP
86
+ ### Form persona
87
+ Who's asking and why. The organization that owns the form, the form's purpose, and **form-specific answers** that override user/business data. For example, "what attracts you to Nitro" is a Nitro-specific answer — it belongs on the form persona, not on your user or business persona.
88
+
89
+ Form personas auto-match by URL. If your form persona has `urls: ["nitroacc.xyz"]` and you fill `https://nitroacc.xyz/apply`, it auto-selects.
83
90
 
84
91
  ## How it works
85
92
 
86
- 1. Reads all personas from `.fastforms/users/` and `.fastforms/businesses/`
93
+ 1. Reads all personas from `.fastforms/users/`, `.fastforms/businesses/`, and `.fastforms/forms/`
87
94
  2. If multiple, prompts you to pick which user + business persona to use
88
- 3. Auto-discovers Chrome's debug port from `DevToolsActivePort`
89
- 4. Opens (or reuses) the target form URL tab
90
- 5. Fills using label-matching heuristics. Has site-specific mappings for known forms.
91
- 6. Shows what was filled, what was skipped. **Never submits.**
95
+ 3. Auto-matches form persona by URL (or prompts if multiple)
96
+ 4. Auto-discovers Chrome's debug port from `DevToolsActivePort`
97
+ 5. Opens (or reuses) the target form URL tab
98
+ 6. Fills using label-matching. Form persona facts are checked first as overrides.
99
+ 7. Shows what was filled, what was skipped. **Never submits.**
92
100
 
93
101
  ## Agent instructions
94
102
 
95
103
  When the user asks you to fill a form:
96
104
 
97
105
  1. Check if `.fastforms/` exists. If not, run `npx @1dolinski/fastforms init`
98
- 2. Run `npx @1dolinski/fastforms fill <the-url>`
99
- 3. If Chrome debugging isn't enabled, tell the user to open `chrome://inspect/#remote-debugging`
100
- 4. After filling, tell the user to review in Chrome and submit manually
106
+ 2. If the form is new, suggest `npx @1dolinski/fastforms add form` to capture form-specific context
107
+ 3. Run `npx @1dolinski/fastforms fill <the-url>`
108
+ 4. If Chrome debugging isn't enabled, tell the user to open `chrome://inspect/#remote-debugging`
109
+ 5. After filling, tell the user to review in Chrome and submit manually
101
110
 
102
111
  When the user wants to add a persona:
103
112
 
104
- 1. Run `npx @1dolinski/fastforms add user` or `npx @1dolinski/fastforms add business`
113
+ 1. Run `npx @1dolinski/fastforms add user|business|form`
105
114
 
106
115
  When the user wants to see their personas:
107
116
 
package/bin/fastforms.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  connectToChrome,
11
11
  pullPersonas,
12
12
  selectPersonas,
13
+ selectFormPersona,
13
14
  showPersonaDetails,
14
15
  offerSetDefaults,
15
16
  offerOpenPersonaManager,
@@ -21,12 +22,14 @@ import {
21
22
  loadLocalPersonas,
22
23
  saveUserPersona,
23
24
  saveBusinessPersona,
25
+ saveFormPersona,
24
26
  deletePersonaFile,
25
27
  listPersonaFiles,
26
28
  loadDefaults,
27
29
  saveDefaults,
28
30
  userTemplate,
29
31
  businessTemplate,
32
+ formTemplate,
30
33
  } from "../lib/local.js";
31
34
 
32
35
  const args = process.argv.slice(2);
@@ -69,6 +72,7 @@ function help() {
69
72
  fastforms init Create your first user + business persona
70
73
  fastforms add user Add another user persona
71
74
  fastforms add business Add another business persona
75
+ fastforms add form Add a form persona (org + purpose + answers)
72
76
  fastforms list List all personas
73
77
  fastforms edit Edit an existing persona
74
78
  fastforms remove Remove a persona
@@ -81,11 +85,17 @@ function help() {
81
85
  --dir <path> Path to .fastforms/ directory (default: auto-detect)
82
86
  --user <hint> User persona name/hint to pre-select
83
87
  --business <hint> Business persona name/hint to pre-select
88
+ --form <hint> Form persona name/hint to pre-select
84
89
  --port <port> Chrome debug port (auto-detected by default)
85
90
 
91
+ Persona types:
92
+ user — who you are (name, email, bio, skills)
93
+ business — what you're building (company, product, traction)
94
+ form — who's asking and why (org, purpose, form-specific answers)
95
+
86
96
  Quick start:
87
97
  1. npx @1dolinski/fastforms init
88
- 2. npx @1dolinski/fastforms add user # add more personas
98
+ 2. npx @1dolinski/fastforms add form # add form-specific context
89
99
  3. Enable remote debugging: chrome://inspect/#remote-debugging
90
100
  4. npx @1dolinski/fastforms fill https://example.com/apply
91
101
  `);
@@ -172,6 +182,55 @@ async function promptBusiness(existing) {
172
182
  return b;
173
183
  }
174
184
 
185
+ async function promptForm(existing) {
186
+ const ep = existing?.profile || {};
187
+ const f = formTemplate();
188
+
189
+ const show = (val) => val ? ` [${String(val).slice(0, 40)}]` : "";
190
+
191
+ f.name = await ask(` Form name (e.g. "Nitro Accelerator")${show(existing?.name)}: `, existing?.name || "");
192
+ f.organization = await ask(` Organization${show(ep.organization)}: `, ep.organization || "");
193
+ f.purpose = await ask(` Purpose (e.g. "crypto accelerator application")${show(ep.purpose)}: `, ep.purpose || "");
194
+
195
+ const existingUrls = existing?.urls || [];
196
+ if (existingUrls.length) {
197
+ console.log(`\n Current URLs: ${existingUrls.join(", ")}`);
198
+ }
199
+ console.log("\n Add URL patterns this form matches (press Enter to finish):\n");
200
+ f.urls = [...existingUrls];
201
+ while (true) {
202
+ const url = await ask(" url: ");
203
+ if (!url) break;
204
+ if (!f.urls.includes(url)) f.urls.push(url);
205
+ }
206
+
207
+ f.notes = await ask(` Notes / context${show(ep.notes)}: `, ep.notes || "");
208
+ f.deadline = await ask(` Deadline${show(ep.deadline)}: `, ep.deadline || "");
209
+ f.requirements = await ask(` Requirements${show(ep.requirements)}: `, ep.requirements || "");
210
+
211
+ const existingFacts = {};
212
+ for (const cf of (existing?.customFacts || [])) {
213
+ if (cf.enabled !== false && cf.key) existingFacts[cf.key] = cf.value;
214
+ }
215
+ f.facts = { ...existingFacts };
216
+
217
+ if (Object.keys(f.facts).length) {
218
+ console.log("\n Current form-specific answers:");
219
+ for (const [k, v] of Object.entries(f.facts)) console.log(` ${k} = ${v}`);
220
+ }
221
+ console.log("\n Add form-specific answers (label hint = answer). Press Enter to finish.");
222
+ console.log(" These override user/business data for matching fields.\n");
223
+ while (true) {
224
+ const raw = await ask(" answer: ");
225
+ if (!raw) break;
226
+ const eq = raw.indexOf("=");
227
+ if (eq === -1) { console.log(" Use format: field hint = answer"); continue; }
228
+ f.facts[raw.slice(0, eq).trim()] = raw.slice(eq + 1).trim();
229
+ }
230
+
231
+ return f;
232
+ }
233
+
175
234
  // ---------------------------------------------------------------------------
176
235
  // init — first-time setup: one user + one business
177
236
  // ---------------------------------------------------------------------------
@@ -185,7 +244,7 @@ async function init() {
185
244
  if (existingUsers.length) {
186
245
  const ans = await ask(` ${existingUsers.length} persona(s) already exist. Add another? [Y/n]: `);
187
246
  if (ans.toLowerCase() === "n") {
188
- console.log(" Use 'fastforms add user' or 'fastforms add business' to add more.\n");
247
+ console.log(" Use 'fastforms add user|business|form' to add more.\n");
189
248
  closeRL();
190
249
  return;
191
250
  }
@@ -202,7 +261,16 @@ async function init() {
202
261
  const bizSlug = saveBusinessPersona(dir, biz);
203
262
  console.log(`\n Saved businesses/${bizSlug}.json`);
204
263
 
264
+ const addForm = await ask("\n Add a form persona now? (org + purpose + form-specific answers) [y/N]: ");
265
+ if (addForm.toLowerCase() === "y") {
266
+ console.log("\n --- Form persona ---\n");
267
+ const form = await promptForm();
268
+ const formSlug = saveFormPersona(dir, form);
269
+ console.log(`\n Saved forms/${formSlug}.json`);
270
+ }
271
+
205
272
  console.log(`\n Personas saved to ${dir}/`);
273
+ console.log(" Tip: add form-specific context with 'fastforms add form'");
206
274
  console.log(" Next: npx @1dolinski/fastforms fill <url>\n");
207
275
  closeRL();
208
276
  }
@@ -213,8 +281,8 @@ async function init() {
213
281
 
214
282
  async function addPersona() {
215
283
  const type = args[1];
216
- if (type !== "user" && type !== "business") {
217
- console.error(" Usage: fastforms add user|business\n");
284
+ if (type !== "user" && type !== "business" && type !== "form") {
285
+ console.error(" Usage: fastforms add user|business|form\n");
218
286
  process.exit(1);
219
287
  }
220
288
 
@@ -226,11 +294,16 @@ async function addPersona() {
226
294
  const user = await promptUser();
227
295
  const slug = saveUserPersona(dir, user);
228
296
  console.log(`\n Saved users/${slug}.json to ${dir}/\n`);
229
- } else {
297
+ } else if (type === "business") {
230
298
  console.log("\n --- New business persona ---\n");
231
299
  const biz = await promptBusiness();
232
300
  const slug = saveBusinessPersona(dir, biz);
233
301
  console.log(`\n Saved businesses/${slug}.json to ${dir}/\n`);
302
+ } else {
303
+ console.log("\n --- New form persona ---\n");
304
+ const form = await promptForm();
305
+ const slug = saveFormPersona(dir, form);
306
+ console.log(`\n Saved forms/${slug}.json to ${dir}/\n`);
234
307
  }
235
308
  closeRL();
236
309
  }
@@ -250,6 +323,7 @@ function listAll() {
250
323
 
251
324
  const users = listPersonaFiles(dir, "user");
252
325
  const businesses = listPersonaFiles(dir, "business");
326
+ const forms = listPersonaFiles(dir, "form");
253
327
 
254
328
  console.log(`\n Personas in ${dir}/\n`);
255
329
 
@@ -276,6 +350,20 @@ function listAll() {
276
350
  }
277
351
 
278
352
  console.log();
353
+
354
+ if (forms.length) {
355
+ console.log(" Form personas:");
356
+ for (const f of forms) {
357
+ const urls = f.data?.urls?.length ? ` [${f.data.urls.join(", ")}]` : "";
358
+ const purpose = f.data?.purpose ? ` — ${f.data.purpose}` : "";
359
+ const facts = f.data?.facts ? Object.keys(f.data.facts).length : 0;
360
+ console.log(` ${f.slug}${urls}${purpose}${facts ? ` (${facts} answers)` : ""}`);
361
+ }
362
+ } else {
363
+ console.log(" No form personas. Run: fastforms add form");
364
+ }
365
+
366
+ console.log();
279
367
  }
280
368
 
281
369
  // ---------------------------------------------------------------------------
@@ -291,9 +379,11 @@ async function edit() {
291
379
 
292
380
  const users = listPersonaFiles(dir, "user");
293
381
  const businesses = listPersonaFiles(dir, "business");
382
+ const forms = listPersonaFiles(dir, "form");
294
383
  const all = [
295
384
  ...users.map((u) => ({ ...u, type: "user", label: `user: ${u.slug} (${u.data?.fullName || u.data?.name || "?"})` })),
296
385
  ...businesses.map((b) => ({ ...b, type: "business", label: `biz: ${b.slug} (${b.data?.name || "?"})` })),
386
+ ...forms.map((f) => ({ ...f, type: "form", label: `form: ${f.slug} (${f.data?.organization || f.data?.name || "?"})` })),
297
387
  ];
298
388
 
299
389
  if (!all.length) {
@@ -317,13 +407,20 @@ async function edit() {
317
407
  if (updated.name !== picked.data?.name) deletePersonaFile(dir, "user", picked.slug);
318
408
  const slug = saveUserPersona(dir, updated);
319
409
  console.log(`\n Updated users/${slug}.json\n`);
320
- } else {
410
+ } else if (picked.type === "business") {
321
411
  const existing = dump.businessPersonas.find((p) => p.name === picked.data?.name) || null;
322
412
  console.log(`\n --- Edit business: ${picked.slug} ---\n`);
323
413
  const updated = await promptBusiness(existing);
324
414
  if (updated.name !== picked.data?.name) deletePersonaFile(dir, "business", picked.slug);
325
415
  const slug = saveBusinessPersona(dir, updated);
326
416
  console.log(`\n Updated businesses/${slug}.json\n`);
417
+ } else {
418
+ const existing = (dump.formPersonas || []).find((p) => p.name === picked.data?.name) || null;
419
+ console.log(`\n --- Edit form: ${picked.slug} ---\n`);
420
+ const updated = await promptForm(existing);
421
+ if (updated.name !== picked.data?.name) deletePersonaFile(dir, "form", picked.slug);
422
+ const slug = saveFormPersona(dir, updated);
423
+ console.log(`\n Updated forms/${slug}.json\n`);
327
424
  }
328
425
  closeRL();
329
426
  }
@@ -341,9 +438,11 @@ async function remove() {
341
438
 
342
439
  const users = listPersonaFiles(dir, "user");
343
440
  const businesses = listPersonaFiles(dir, "business");
441
+ const forms = listPersonaFiles(dir, "form");
344
442
  const all = [
345
443
  ...users.map((u) => ({ ...u, type: "user", label: `user: ${u.slug} (${u.data?.fullName || u.data?.name || "?"})` })),
346
444
  ...businesses.map((b) => ({ ...b, type: "business", label: `biz: ${b.slug} (${b.data?.name || "?"})` })),
445
+ ...forms.map((f) => ({ ...f, type: "form", label: `form: ${f.slug} (${f.data?.name || "?"})` })),
347
446
  ];
348
447
 
349
448
  if (!all.length) {
@@ -361,7 +460,8 @@ async function remove() {
361
460
  const confirm = await ask(` Delete ${picked.type}/${picked.slug}? [y/N]: `);
362
461
  if (confirm.toLowerCase() === "y") {
363
462
  deletePersonaFile(dir, picked.type, picked.slug);
364
- console.log(` Removed ${picked.type}s/${picked.slug}.json\n`);
463
+ const dirName = picked.type === "user" ? "users" : picked.type === "business" ? "businesses" : "forms";
464
+ console.log(` Removed ${dirName}/${picked.slug}.json\n`);
365
465
  } else {
366
466
  console.log(" Cancelled.\n");
367
467
  }
@@ -408,7 +508,13 @@ async function fill() {
408
508
 
409
509
  const personas = dump.personas || [];
410
510
  const bizPersonas = dump.businessPersonas || [];
411
- console.log(` Found ${personas.length} user persona(s), ${bizPersonas.length} business persona(s).`);
511
+ const formPersonas = dump.formPersonas || [];
512
+ const counts = [
513
+ `${personas.length} user`,
514
+ `${bizPersonas.length} business`,
515
+ `${formPersonas.length} form`,
516
+ ].join(", ");
517
+ console.log(` Found ${counts} persona(s).`);
412
518
 
413
519
  if (!personas.length && !bizPersonas.length) {
414
520
  console.error("\n No personas found.");
@@ -423,15 +529,29 @@ async function fill() {
423
529
 
424
530
  const userHint = flag("--user");
425
531
  const bizHint = flag("--business");
532
+ const formHint = flag("--form");
426
533
  const { user, biz } = await selectPersonas(dump, userHint, bizHint);
427
534
 
535
+ // Form persona: auto-match by URL, then hint, then interactive
536
+ let form = null;
537
+ if (formPersonas.length) {
538
+ if (formHint) {
539
+ form = formPersonas.find((f) =>
540
+ f.name.toLowerCase().includes(formHint.toLowerCase())
541
+ ) || null;
542
+ }
543
+ if (!form) {
544
+ form = await selectFormPersona(formPersonas, formUrl);
545
+ }
546
+ }
547
+
428
548
  if (!user && !biz) {
429
549
  console.error("\n No matching personas.");
430
550
  browser.disconnect();
431
551
  process.exit(1);
432
552
  }
433
553
 
434
- showPersonaDetails(user, biz);
554
+ showPersonaDetails(user, biz, form);
435
555
 
436
556
  const pages = await browser.pages();
437
557
  const host = new URL(formUrl).host;
@@ -446,15 +566,18 @@ async function fill() {
446
566
  console.log(`\n Opened ${formUrl}`);
447
567
  }
448
568
 
449
- await fillForm(page, formUrl, user, biz);
569
+ await fillForm(page, formUrl, user, biz, form);
450
570
 
451
571
  if (hasFlag("--web")) {
452
572
  await offerSetDefaults(user, biz);
453
573
  } else {
454
- // Save defaults to local dir
455
574
  const dir = findFastformsDir();
456
- if (dir && user && biz) {
457
- saveDefaults(dir, { defaultUser: user.name, defaultBusiness: biz.name });
575
+ if (dir) {
576
+ const patch = {};
577
+ if (user) patch.defaultUser = user.name;
578
+ if (biz) patch.defaultBusiness = biz.name;
579
+ if (form) patch.defaultForm = form.name;
580
+ saveDefaults(dir, patch);
458
581
  }
459
582
  }
460
583
 
package/lib/cdp.js ADDED
@@ -0,0 +1,193 @@
1
+ import WebSocket from "ws";
2
+
3
+ const DEFAULT_TIMEOUT = 30_000;
4
+ const SETTLE_MS = 1000;
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // CDP session — thin wrapper around a single WebSocket to a Chrome target
8
+ // ---------------------------------------------------------------------------
9
+
10
+ class CDPSession {
11
+ constructor(ws) {
12
+ this._ws = ws;
13
+ this._nextId = 0;
14
+ this._pending = new Map();
15
+ this._listeners = new Map();
16
+
17
+ ws.on("message", (raw) => {
18
+ const msg = JSON.parse(raw);
19
+ if ("id" in msg) {
20
+ const cb = this._pending.get(msg.id);
21
+ if (cb) {
22
+ this._pending.delete(msg.id);
23
+ msg.error ? cb.reject(new Error(msg.error.message)) : cb.resolve(msg.result);
24
+ }
25
+ } else if (msg.method) {
26
+ const fns = this._listeners.get(msg.method);
27
+ if (fns) for (const fn of fns) fn(msg.params);
28
+ }
29
+ });
30
+
31
+ ws.on("close", () => {
32
+ for (const { reject } of this._pending.values()) reject(new Error("WebSocket closed"));
33
+ this._pending.clear();
34
+ });
35
+ }
36
+
37
+ send(method, params = {}) {
38
+ const id = ++this._nextId;
39
+ return new Promise((resolve, reject) => {
40
+ this._pending.set(id, { resolve, reject });
41
+ this._ws.send(JSON.stringify({ id, method, params }));
42
+ });
43
+ }
44
+
45
+ on(event, fn) {
46
+ if (!this._listeners.has(event)) this._listeners.set(event, []);
47
+ this._listeners.get(event).push(fn);
48
+ }
49
+
50
+ off(event, fn) {
51
+ const fns = this._listeners.get(event);
52
+ if (!fns) return;
53
+ const idx = fns.indexOf(fn);
54
+ if (idx !== -1) fns.splice(idx, 1);
55
+ }
56
+
57
+ close() {
58
+ this._ws.close();
59
+ }
60
+ }
61
+
62
+ async function openSession(wsUrl) {
63
+ const ws = new WebSocket(wsUrl);
64
+ await new Promise((resolve, reject) => {
65
+ ws.once("open", resolve);
66
+ ws.once("error", reject);
67
+ });
68
+ return new CDPSession(ws);
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Page — mirrors the subset of puppeteer's Page API we actually use
73
+ // ---------------------------------------------------------------------------
74
+
75
+ class Page {
76
+ constructor(info, port) {
77
+ this._info = info;
78
+ this._port = port;
79
+ this._session = null;
80
+ }
81
+
82
+ url() {
83
+ return this._info.url;
84
+ }
85
+
86
+ async _ensureSession() {
87
+ if (!this._session) {
88
+ this._session = await openSession(this._info.webSocketDebuggerUrl);
89
+ }
90
+ return this._session;
91
+ }
92
+
93
+ /**
94
+ * Evaluate a function in the page context.
95
+ * Works the same as puppeteer's page.evaluate — pass a function + args,
96
+ * they get serialized and executed in the browser, result comes back.
97
+ */
98
+ async evaluate(fn, ...args) {
99
+ const session = await this._ensureSession();
100
+ const expression = `(${fn.toString()})(${args.map((a) => JSON.stringify(a)).join(",")})`;
101
+ const { result, exceptionDetails } = await session.send("Runtime.evaluate", {
102
+ expression,
103
+ returnByValue: true,
104
+ awaitPromise: true,
105
+ });
106
+ if (exceptionDetails) {
107
+ throw new Error(
108
+ exceptionDetails.text ||
109
+ exceptionDetails.exception?.description ||
110
+ "evaluate failed"
111
+ );
112
+ }
113
+ return result?.value;
114
+ }
115
+
116
+ async goto(url, opts = {}) {
117
+ const session = await this._ensureSession();
118
+ await session.send("Page.enable");
119
+
120
+ const loaded = new Promise((resolve) => {
121
+ const timer = setTimeout(resolve, opts.timeout || DEFAULT_TIMEOUT);
122
+ const handler = () => {
123
+ clearTimeout(timer);
124
+ session.off("Page.loadEventFired", handler);
125
+ setTimeout(resolve, SETTLE_MS);
126
+ };
127
+ session.on("Page.loadEventFired", handler);
128
+ });
129
+
130
+ await session.send("Page.navigate", { url });
131
+ await loaded;
132
+ this._info.url = url;
133
+ }
134
+
135
+ async bringToFront() {
136
+ const session = await this._ensureSession();
137
+ await session.send("Page.bringToFront");
138
+ }
139
+
140
+ async close() {
141
+ this._dispose();
142
+ try {
143
+ await fetch(`http://127.0.0.1:${this._port}/json/close/${this._info.id}`, { method: "PUT" });
144
+ } catch { /* best effort */ }
145
+ }
146
+
147
+ _dispose() {
148
+ if (this._session) {
149
+ this._session.close();
150
+ this._session = null;
151
+ }
152
+ }
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // connectChrome — returns a browser-like object
157
+ // ---------------------------------------------------------------------------
158
+
159
+ export async function connectChrome(port = 9222) {
160
+ const base = `http://127.0.0.1:${port}`;
161
+
162
+ const res = await fetch(`${base}/json/version`);
163
+ if (!res.ok) throw new Error(`Chrome not responding on port ${port}`);
164
+
165
+ const sessions = [];
166
+
167
+ return {
168
+ async pages() {
169
+ const res = await fetch(`${base}/json/list`);
170
+ const targets = await res.json();
171
+ return targets
172
+ .filter((t) => t.type === "page")
173
+ .map((t) => {
174
+ const p = new Page(t, port);
175
+ sessions.push(p);
176
+ return p;
177
+ });
178
+ },
179
+
180
+ async newPage() {
181
+ const res = await fetch(`${base}/json/new`, { method: "PUT" });
182
+ const target = await res.json();
183
+ const p = new Page(target, port);
184
+ sessions.push(p);
185
+ return p;
186
+ },
187
+
188
+ disconnect() {
189
+ for (const p of sessions) p._dispose();
190
+ sessions.length = 0;
191
+ },
192
+ };
193
+ }
package/lib/fill.js CHANGED
@@ -2,6 +2,14 @@ import { getCustomFact } from "./personas.js";
2
2
 
3
3
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
4
4
 
5
+ /**
6
+ * Look up a value from the form persona's facts first, then fall back.
7
+ * Form persona facts override everything — they're form-specific answers.
8
+ */
9
+ function formFact(form, key) {
10
+ return getCustomFact(form, key);
11
+ }
12
+
5
13
  export async function fillByLabel(page, label, value, opts = {}) {
6
14
  if (!value) return false;
7
15
  return page.evaluate((lbl, val, ta) => {
@@ -55,82 +63,95 @@ export async function clickCheckbox(page, textHint) {
55
63
  }, textHint);
56
64
  }
57
65
 
58
- export function buildNitroData(user, biz) {
66
+ export function buildNitroData(user, biz, form) {
59
67
  const u = user?.profile || {};
60
68
  const b = biz?.profile || {};
61
69
  const fact = (p, k) => getCustomFact(p, k);
70
+ const ff = (k) => formFact(form, k);
62
71
 
63
72
  return {
64
73
  fields: [
65
- { label: "Company / Project name", value: b.productName || b.companyName || b.name || "" },
66
- { label: "Email", value: u.email || "" },
67
- { label: "One-line description", value: b.oneLiner || "" },
68
- { label: "What are you building", value: b.solution || [b.problem, b.solution].filter(Boolean).join("\n\n"), textarea: true },
69
- { label: "Full Name", value: u.fullName || "" },
70
- { label: "Role in the company", value: u.currentRole || "" },
71
- { label: "X Handle", value: fact(user, "x handle") || fact(user, "twitter") || "" },
72
- { label: "LinkedIn username", value: u.linkedIn || "" },
73
- { label: "Telegram", value: fact(user, "telegram") || "" },
74
- { label: "GitHub username", value: u.github || "" },
75
- { label: "Why are you the right founder", value: [u.bio, u.keySkills ? `Key skills: ${u.keySkills}` : "", u.favoriteProjects ? `Notable projects: ${u.favoriteProjects}` : ""].filter(Boolean).join("\n\n"), textarea: true },
76
- { label: "video content or long form writing", value: u.portfolio || "", textarea: true },
77
- { label: "How did the founders meet", value: fact(user, "founders met") || fact(biz, "founders") || `I'm a solo founder building ${b.productName || b.companyName || "this project"} full-time.`, textarea: true },
78
- { label: "Current location of founders", value: u.location || b.location || "" },
79
- { label: "how do you plan to spend", value: fact(biz, "spend") || fact(biz, "500k") || "", textarea: true },
80
- { label: "What problem are you solving", value: b.problem || "", textarea: true },
81
- { label: "closest comparables", value: b.differentiators || "", textarea: true },
82
- { label: "Product Link", value: b.website || "" },
83
- { label: "What changed in the tech or market", value: fact(biz, "timing") || fact(biz, "why now") || b.rawFacts || "", textarea: true },
84
- { label: "Dune Dashboard", value: "" },
85
- { label: "Google Analytics", value: "", textarea: true },
86
- { label: "target segment", value: b.targetUsers || "", textarea: true },
87
- { label: "wedge into the market", value: b.businessModel || b.differentiators || "", textarea: true },
88
- { label: "traction have you achieved", value: b.traction || "", textarea: true },
89
- { label: "Runway at current burn", value: "12" },
90
- { label: "What attracts you the most about Nitro", value: fact(biz, "nitro") || fact(biz, "accelerator") || `The density of high-signal founders and the NYC residency. ${b.productName || "Our product"} is at a stage where concentrated feedback from experienced operators would dramatically accelerate our trajectory.`, textarea: true },
91
- { label: "Pick one of the mentors", value: fact(biz, "mentor") || "", textarea: true },
92
- { label: "What did you get done last week", value: fact(biz, "last week") || fact(user, "last week") || "", textarea: true },
74
+ { label: "Company / Project name", value: ff("company") || b.productName || b.companyName || b.name || "" },
75
+ { label: "Email", value: ff("email") || u.email || "" },
76
+ { label: "One-line description", value: ff("one-line") || b.oneLiner || "" },
77
+ { label: "What are you building", value: ff("building") || b.solution || [b.problem, b.solution].filter(Boolean).join("\n\n"), textarea: true },
78
+ { label: "Full Name", value: ff("full name") || u.fullName || "" },
79
+ { label: "Role in the company", value: ff("role") || u.currentRole || "" },
80
+ { label: "X Handle", value: ff("x handle") || fact(user, "x handle") || fact(user, "twitter") || "" },
81
+ { label: "LinkedIn username", value: ff("linkedin") || u.linkedIn || "" },
82
+ { label: "Telegram", value: ff("telegram") || fact(user, "telegram") || "" },
83
+ { label: "GitHub username", value: ff("github") || u.github || "" },
84
+ { label: "Why are you the right founder", value: ff("right founder") || [u.bio, u.keySkills ? `Key skills: ${u.keySkills}` : "", u.favoriteProjects ? `Notable projects: ${u.favoriteProjects}` : ""].filter(Boolean).join("\n\n"), textarea: true },
85
+ { label: "video content or long form writing", value: ff("video") || ff("writing") || u.portfolio || "", textarea: true },
86
+ { label: "How did the founders meet", value: ff("founders meet") || ff("founders met") || fact(user, "founders met") || fact(biz, "founders") || `I'm a solo founder building ${b.productName || b.companyName || "this project"} full-time.`, textarea: true },
87
+ { label: "Current location of founders", value: ff("location") || u.location || b.location || "" },
88
+ { label: "how do you plan to spend", value: ff("spend") || ff("500k") || fact(biz, "spend") || fact(biz, "500k") || "", textarea: true },
89
+ { label: "What problem are you solving", value: ff("problem") || b.problem || "", textarea: true },
90
+ { label: "closest comparables", value: ff("comparables") || ff("competitors") || b.differentiators || "", textarea: true },
91
+ { label: "Product Link", value: ff("product link") || b.website || "" },
92
+ { label: "What changed in the tech or market", value: ff("timing") || ff("why now") || fact(biz, "timing") || fact(biz, "why now") || b.rawFacts || "", textarea: true },
93
+ { label: "Dune Dashboard", value: ff("dune") || "" },
94
+ { label: "Google Analytics", value: ff("analytics") || "", textarea: true },
95
+ { label: "target segment", value: ff("target") || b.targetUsers || "", textarea: true },
96
+ { label: "wedge into the market", value: ff("wedge") || ff("market") || b.businessModel || b.differentiators || "", textarea: true },
97
+ { label: "traction have you achieved", value: ff("traction") || b.traction || "", textarea: true },
98
+ { label: "Runway at current burn", value: ff("runway") || "12" },
99
+ { label: "What attracts you the most about Nitro", value: ff("attracts") || ff("nitro") || fact(biz, "nitro") || fact(biz, "accelerator") || `The density of high-signal founders and the NYC residency. ${b.productName || "Our product"} is at a stage where concentrated feedback from experienced operators would dramatically accelerate our trajectory.`, textarea: true },
100
+ { label: "Pick one of the mentors", value: ff("mentor") || fact(biz, "mentor") || "", textarea: true },
101
+ { label: "What did you get done last week", value: ff("last week") || fact(biz, "last week") || fact(user, "last week") || "", textarea: true },
93
102
  ],
94
103
  radios: [
95
- { section: "committed full-time", value: "yes" },
96
- { section: "raised funding before", value: "no" },
97
- { section: "currently fundraising", value: "yes" },
98
- { section: "exclusively in Nitro", value: "yes" },
99
- { section: "attend the full 1-month NYC", value: "yes" },
104
+ { section: "committed full-time", value: ff("full-time") || "yes" },
105
+ { section: "raised funding before", value: ff("raised funding") || "no" },
106
+ { section: "currently fundraising", value: ff("fundraising") || "yes" },
107
+ { section: "exclusively in Nitro", value: ff("exclusively") || "yes" },
108
+ { section: "attend the full 1-month NYC", value: ff("attend") || "yes" },
100
109
  ],
101
110
  checkboxes: ["MVP / demo exists"],
102
111
  };
103
112
  }
104
113
 
105
- export async function genericFill(page, user, biz) {
114
+ export async function genericFill(page, user, biz, form) {
106
115
  const u = user?.profile || {};
107
116
  const b = biz?.profile || {};
108
117
  const fact = (p, k) => getCustomFact(p, k);
118
+ const ff = (k) => formFact(form, k);
109
119
 
110
120
  const fieldMap = [
111
- { hints: ["company", "project name", "organization"], value: b.productName || b.companyName || b.name || "" },
112
- { hints: ["email"], value: u.email || "" },
113
- { hints: ["full name", "your name", "first name"], value: u.fullName || "" },
114
- { hints: ["one-line", "one liner", "tagline", "short description"], value: b.oneLiner || "" },
115
- { hints: ["what are you building", "describe your product", "about your project"], value: b.solution || "", textarea: true },
116
- { hints: ["role", "title", "position"], value: u.currentRole || "" },
117
- { hints: ["x handle", "twitter"], value: fact(user, "x handle") || fact(user, "twitter") || "" },
118
- { hints: ["linkedin"], value: u.linkedIn || "" },
119
- { hints: ["telegram"], value: fact(user, "telegram") || "" },
120
- { hints: ["github"], value: u.github || "" },
121
- { hints: ["website", "url", "product link"], value: b.website || u.portfolio || "" },
122
- { hints: ["phone", "tel"], value: u.phone || "" },
123
- { hints: ["location", "city", "where are you based"], value: u.location || b.location || "" },
124
- { hints: ["bio", "about yourself", "tell us about you"], value: u.bio || "", textarea: true },
125
- { hints: ["problem", "what problem"], value: b.problem || "", textarea: true },
126
- { hints: ["solution", "how does it work"], value: b.solution || "", textarea: true },
127
- { hints: ["traction", "progress", "metrics"], value: b.traction || "", textarea: true },
128
- { hints: ["target", "customer", "user"], value: b.targetUsers || "", textarea: true },
129
- { hints: ["business model", "revenue", "monetiz"], value: b.businessModel || "", textarea: true },
130
- { hints: ["differentiator", "competitive", "unique"], value: b.differentiators || "", textarea: true },
131
- { hints: ["why you", "right founder", "why are you"], value: u.bio || "", textarea: true },
121
+ { hints: ["company", "project name", "organization"], value: ff("company") || b.productName || b.companyName || b.name || "" },
122
+ { hints: ["email"], value: ff("email") || u.email || "" },
123
+ { hints: ["full name", "your name", "first name"], value: ff("full name") || u.fullName || "" },
124
+ { hints: ["one-line", "one liner", "tagline", "short description"], value: ff("one-liner") || b.oneLiner || "" },
125
+ { hints: ["what are you building", "describe your product", "about your project"], value: ff("building") || b.solution || "", textarea: true },
126
+ { hints: ["role", "title", "position"], value: ff("role") || u.currentRole || "" },
127
+ { hints: ["x handle", "twitter"], value: ff("x handle") || fact(user, "x handle") || fact(user, "twitter") || "" },
128
+ { hints: ["linkedin"], value: ff("linkedin") || u.linkedIn || "" },
129
+ { hints: ["telegram"], value: ff("telegram") || fact(user, "telegram") || "" },
130
+ { hints: ["github"], value: ff("github") || u.github || "" },
131
+ { hints: ["website", "url", "product link"], value: ff("website") || b.website || u.portfolio || "" },
132
+ { hints: ["phone", "tel"], value: ff("phone") || u.phone || "" },
133
+ { hints: ["location", "city", "where are you based"], value: ff("location") || u.location || b.location || "" },
134
+ { hints: ["bio", "about yourself", "tell us about you"], value: ff("bio") || u.bio || "", textarea: true },
135
+ { hints: ["problem", "what problem"], value: ff("problem") || b.problem || "", textarea: true },
136
+ { hints: ["solution", "how does it work"], value: ff("solution") || b.solution || "", textarea: true },
137
+ { hints: ["traction", "progress", "metrics"], value: ff("traction") || b.traction || "", textarea: true },
138
+ { hints: ["target", "customer", "user"], value: ff("target") || b.targetUsers || "", textarea: true },
139
+ { hints: ["business model", "revenue", "monetiz"], value: ff("business model") || b.businessModel || "", textarea: true },
140
+ { hints: ["differentiator", "competitive", "unique"], value: ff("differentiator") || b.differentiators || "", textarea: true },
141
+ { hints: ["why you", "right founder", "why are you"], value: ff("right founder") || u.bio || "", textarea: true },
132
142
  ];
133
143
 
144
+ // Append any form persona facts that don't already map to standard fields
145
+ if (form?.customFacts?.length) {
146
+ const usedKeys = new Set(fieldMap.flatMap((f) => f.hints));
147
+ for (const cf of form.customFacts) {
148
+ if (!cf.enabled || !cf.value) continue;
149
+ const keyLower = cf.key.toLowerCase();
150
+ if ([...usedKeys].some((h) => keyLower.includes(h) || h.includes(keyLower))) continue;
151
+ fieldMap.push({ hints: [cf.key], value: cf.value, textarea: cf.value.length > 100 });
152
+ }
153
+ }
154
+
134
155
  let filled = 0, skipped = 0;
135
156
  for (const f of fieldMap) {
136
157
  if (!f.value) { skipped++; continue; }
@@ -146,12 +167,16 @@ export async function genericFill(page, user, biz) {
146
167
  return { filled, skipped };
147
168
  }
148
169
 
149
- export async function fillForm(page, formUrl, user, biz) {
170
+ export async function fillForm(page, formUrl, user, biz, form) {
150
171
  console.log(`\n Filling form...\n`);
172
+ if (form) {
173
+ const fp = form.profile || {};
174
+ if (fp.organization) console.log(` Form: ${fp.organization}${fp.purpose ? ` — ${fp.purpose}` : ""}`);
175
+ }
151
176
  await sleep(2000);
152
177
 
153
178
  if (formUrl.includes("nitroacc.xyz")) {
154
- const data = buildNitroData(user, biz);
179
+ const data = buildNitroData(user, biz, form);
155
180
  let filled = 0, skipped = 0;
156
181
 
157
182
  for (const f of data.fields) {
@@ -180,7 +205,7 @@ export async function fillForm(page, formUrl, user, biz) {
180
205
 
181
206
  console.log(`\n Filled ${filled}, skipped ${skipped} (empty persona fields).`);
182
207
  } else {
183
- const { filled, skipped } = await genericFill(page, user, biz);
208
+ const { filled, skipped } = await genericFill(page, user, biz, form);
184
209
  console.log(`\n Filled ${filled}, skipped ${skipped} (empty or no match).`);
185
210
  }
186
211
 
package/lib/local.js CHANGED
@@ -87,6 +87,23 @@ function normalizeBusiness(raw) {
87
87
  };
88
88
  }
89
89
 
90
+ function normalizeForm(raw) {
91
+ if (!raw) return null;
92
+ const { name, facts, urls, ...rest } = raw;
93
+ return {
94
+ name: name || "unknown form",
95
+ urls: urls || (rest.url ? [rest.url] : []),
96
+ profile: {
97
+ organization: rest.organization || rest.org || name || "",
98
+ purpose: rest.purpose || "",
99
+ notes: rest.notes || "",
100
+ deadline: rest.deadline || "",
101
+ requirements: rest.requirements || "",
102
+ },
103
+ customFacts: factsToArray(facts),
104
+ };
105
+ }
106
+
90
107
  function factsToArray(facts) {
91
108
  if (!facts || typeof facts !== "object") return [];
92
109
  return Object.entries(facts).map(([key, value], i) => ({
@@ -125,7 +142,9 @@ export function loadLocalPersonas(dir) {
125
142
  if (legacy) businessPersonas = [normalizeBusiness(legacy)];
126
143
  }
127
144
 
128
- return { personas, businessPersonas };
145
+ const formPersonas = loadDir(join(dir, "forms"), normalizeForm);
146
+
147
+ return { personas, businessPersonas, formPersonas };
129
148
  }
130
149
 
131
150
  // ---------------------------------------------------------------------------
@@ -146,8 +165,17 @@ export function saveBusinessPersona(dir, data) {
146
165
  return slug;
147
166
  }
148
167
 
168
+ export function saveFormPersona(dir, data) {
169
+ const subdir = ensureDir(join(dir, "forms"));
170
+ const slug = slugify(data.name || "form");
171
+ writeJson(join(subdir, `${slug}.json`), data);
172
+ return slug;
173
+ }
174
+
175
+ const TYPE_DIRS = { user: "users", business: "businesses", form: "forms" };
176
+
149
177
  export function deletePersonaFile(dir, type, slug) {
150
- const subdir = type === "user" ? "users" : "businesses";
178
+ const subdir = TYPE_DIRS[type] || type;
151
179
  const path = join(dir, subdir, `${slug}.json`);
152
180
  if (existsSync(path)) unlinkSync(path);
153
181
  }
@@ -157,7 +185,7 @@ export function deletePersonaFile(dir, type, slug) {
157
185
  // ---------------------------------------------------------------------------
158
186
 
159
187
  export function listPersonaFiles(dir, type) {
160
- const subdir = join(dir, type === "user" ? "users" : "businesses");
188
+ const subdir = join(dir, TYPE_DIRS[type] || type);
161
189
  if (!existsSync(subdir)) return [];
162
190
  return readdirSync(subdir)
163
191
  .filter((f) => f.endsWith(".json"))
@@ -215,3 +243,16 @@ export function businessTemplate() {
215
243
  facts: {},
216
244
  };
217
245
  }
246
+
247
+ export function formTemplate() {
248
+ return {
249
+ name: "",
250
+ urls: [],
251
+ organization: "",
252
+ purpose: "",
253
+ notes: "",
254
+ deadline: "",
255
+ requirements: "",
256
+ facts: {},
257
+ };
258
+ }
package/lib/personas.js CHANGED
@@ -1,8 +1,8 @@
1
- import puppeteer from "puppeteer-core";
2
- import { readFileSync, writeFileSync, existsSync } from "fs";
1
+ import { readFileSync, writeFileSync } from "fs";
3
2
  import { join } from "path";
4
3
  import { homedir } from "os";
5
4
  import { createInterface } from "readline";
5
+ import { connectChrome } from "./cdp.js";
6
6
 
7
7
  const FASTFORMS_URL = "https://293-fastforms.vercel.app/";
8
8
  const PERSONA_URL = "https://293-fastforms.vercel.app/persona";
@@ -28,7 +28,7 @@ function ask(prompt) {
28
28
  }
29
29
 
30
30
  export async function connectToChrome(port = 9222) {
31
- return puppeteer.connect({ browserURL: `http://127.0.0.1:${port}` });
31
+ return connectChrome(port);
32
32
  }
33
33
 
34
34
  export async function pullPersonas(browser) {
@@ -92,7 +92,7 @@ function printPersonaSummary(label, persona, profileKeys) {
92
92
  if (facts.length) console.log(` Custom facts: ${facts.length}`);
93
93
  }
94
94
 
95
- export function showPersonaDetails(user, biz) {
95
+ export function showPersonaDetails(user, biz, form) {
96
96
  printPersonaSummary("User persona", user, [
97
97
  ["fullName", "Name"],
98
98
  ["email", "Email"],
@@ -108,29 +108,36 @@ export function showPersonaDetails(user, biz) {
108
108
  ["website", "Website"],
109
109
  ["category", "Category"],
110
110
  ]);
111
+ if (form) {
112
+ console.log(`\n Form persona: ${form.name}`);
113
+ const fp = form.profile || {};
114
+ if (fp.organization) console.log(` Organization: ${fp.organization}`);
115
+ if (fp.purpose) console.log(` Purpose: ${fp.purpose}`);
116
+ if (fp.deadline) console.log(` Deadline: ${fp.deadline}`);
117
+ if (fp.notes) console.log(` Notes: ${String(fp.notes).slice(0, 80)}${String(fp.notes).length > 80 ? "..." : ""}`);
118
+ const facts = (form.customFacts || []).filter((f) => f.enabled !== false && f.value);
119
+ if (facts.length) console.log(` Form-specific answers: ${facts.length}`);
120
+ }
111
121
  }
112
122
 
113
123
  // ---------------------------------------------------------------------------
114
- // Interactive persona selection — supports multiple personas from any source
124
+ // Interactive persona selection
115
125
  // ---------------------------------------------------------------------------
116
126
 
117
127
  async function pickFromList(label, list, hint, keys, defaultName) {
118
128
  if (!list?.length) return null;
119
129
  if (list.length === 1) return list[0];
120
130
 
121
- // Try hint-based match
122
131
  if (hint) {
123
132
  const match = pickPersona(list, hint, keys);
124
133
  if (match) return match;
125
134
  }
126
135
 
127
- // Try default
128
136
  if (defaultName) {
129
137
  const match = list.find((p) => p.name === defaultName);
130
138
  if (match) return match;
131
139
  }
132
140
 
133
- // Interactive selection
134
141
  console.log(`\n ${label}:\n`);
135
142
  list.forEach((p, i) => {
136
143
  const detail = keys.map((k) => k.split(".").reduce((o, s) => o?.[s], p)).filter(Boolean)[0] || "";
@@ -142,6 +149,42 @@ async function pickFromList(label, list, hint, keys, defaultName) {
142
149
  return list[0];
143
150
  }
144
151
 
152
+ export function matchFormByUrl(formPersonas, targetUrl) {
153
+ if (!formPersonas?.length || !targetUrl) return null;
154
+ const lower = targetUrl.toLowerCase();
155
+ for (const fp of formPersonas) {
156
+ for (const u of (fp.urls || [])) {
157
+ if (u && lower.includes(u.toLowerCase())) return fp;
158
+ }
159
+ const orgSlug = (fp.name || "").toLowerCase().replace(/[^a-z0-9]/g, "");
160
+ if (orgSlug && lower.includes(orgSlug)) return fp;
161
+ }
162
+ return null;
163
+ }
164
+
165
+ export async function selectFormPersona(formPersonas, targetUrl) {
166
+ if (!formPersonas?.length) return null;
167
+
168
+ const autoMatch = matchFormByUrl(formPersonas, targetUrl);
169
+ if (autoMatch) {
170
+ console.log(` Auto-matched form persona: ${autoMatch.name}`);
171
+ return autoMatch;
172
+ }
173
+
174
+ if (formPersonas.length === 1) return formPersonas[0];
175
+
176
+ console.log(`\n Form personas:\n`);
177
+ formPersonas.forEach((f, i) => {
178
+ const org = f.profile?.organization || "";
179
+ console.log(` ${i + 1}. ${f.name}${org ? ` (${org})` : ""}`);
180
+ });
181
+ console.log(` ${formPersonas.length + 1}. (none — skip form persona)`);
182
+ const ans = await ask(`\n Pick form persona [1-${formPersonas.length + 1}]: `);
183
+ const idx = Number(ans) - 1;
184
+ if (idx >= 0 && idx < formPersonas.length) return formPersonas[idx];
185
+ return null;
186
+ }
187
+
145
188
  export async function selectPersonas(dump, userHint, bizHint) {
146
189
  const config = loadConfig();
147
190
  const personas = dump.personas || [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1dolinski/fastforms",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Fill any form fast. Manage personas on the web, fill forms from your terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,7 @@
13
13
  "README.md"
14
14
  ],
15
15
  "dependencies": {
16
- "puppeteer-core": "^24.39.1"
16
+ "ws": "^8.18.0"
17
17
  },
18
18
  "engines": {
19
19
  "node": ">=18"
@@ -27,7 +27,7 @@
27
27
  "forms",
28
28
  "automation",
29
29
  "chrome",
30
- "puppeteer",
30
+ "cdp",
31
31
  "personas"
32
32
  ]
33
33
  }