@proagentstore/cli 0.1.4 → 0.1.6

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.
@@ -33,6 +33,7 @@ Usage:
33
33
  Endpoints:
34
34
  GET /health
35
35
  GET /capabilities
36
+ GET /tasks
36
37
  POST /tasks
37
38
  GET /tasks/:id
38
39
  POST /tasks/:id/approve
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync } from "node:fs";
2
3
  import { createRequire } from "node:module";
3
- import { mkdirSync } from "node:fs";
4
- import { dirname, join } from "node:path";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
  import { RunnerStore } from "./store.js";
7
7
  const CAPABILITIES = [
@@ -11,7 +11,7 @@ const CAPABILITIES = [
11
11
  "file.upload",
12
12
  "human.approval",
13
13
  ];
14
- const APPROVAL_REQUIRED_TASKS = new Set(["browser.open"]);
14
+ const APPROVAL_REQUIRED_TASKS = new Set(["browser.open", "job.apply_basic"]);
15
15
  const require = createRequire(import.meta.url);
16
16
  export class RunnerInputError extends Error {
17
17
  status = 400;
@@ -32,8 +32,8 @@ export class LocalRunner {
32
32
  brainPlacement: "pags",
33
33
  runnerRole: "tool-executor",
34
34
  capabilities: CAPABILITIES,
35
- taskTypes: ["echo", "browser.open"],
36
- approvalRequiredFor: ["browser.open"],
35
+ taskTypes: ["echo", "browser.open", "job.apply_basic"],
36
+ approvalRequiredFor: [...APPROVAL_REQUIRED_TASKS],
37
37
  };
38
38
  }
39
39
  createTask(request) {
@@ -160,8 +160,36 @@ export class LocalRunner {
160
160
  title: await page.title(),
161
161
  };
162
162
  }
163
+ if (task.type === "job.apply_basic") {
164
+ return this.applyToBasicJob(task.input);
165
+ }
163
166
  throw new Error(`Unknown task type: ${task.type}`);
164
167
  }
168
+ async applyToBasicJob(input) {
169
+ const job = normalizeJobApplicationInput(input);
170
+ const context = await this.getBrowserContext();
171
+ const page = context.pages()[0] || (await context.newPage());
172
+ await page.goto(job.url, { waitUntil: "domcontentloaded", timeout: 30_000 });
173
+ const filledFields = await fillBasicApplicationForm(page, job);
174
+ const beforeSubmitUrl = page.url();
175
+ const navigation = page.waitForNavigation({
176
+ waitUntil: "domcontentloaded",
177
+ timeout: 15_000,
178
+ }).catch(() => null);
179
+ await submitApplicationForm(page);
180
+ await navigation;
181
+ await page.waitForLoadState("domcontentloaded", { timeout: 5_000 }).catch(() => undefined);
182
+ return {
183
+ taskType: "job.apply_basic",
184
+ submitted: true,
185
+ beforeSubmitUrl,
186
+ finalUrl: page.url(),
187
+ title: await page.title(),
188
+ fieldsFilled: filledFields,
189
+ resumeFile: basename(job.resumePath),
190
+ visibleText: (await page.locator("body").innerText({ timeout: 5_000 }).catch(() => "")).slice(0, 2_000),
191
+ };
192
+ }
165
193
  requireTask(id) {
166
194
  const task = this.store.getTask(id);
167
195
  if (!task)
@@ -202,9 +230,164 @@ export class LocalRunner {
202
230
  }
203
231
  }
204
232
  }
233
+ export function normalizeJobApplicationInput(input) {
234
+ const url = stringValue(input.url);
235
+ if (!url || !/^https?:\/\//.test(url)) {
236
+ throw new RunnerInputError("job.apply_basic requires an http(s) url");
237
+ }
238
+ const resumePath = resolve(stringValue(input.resumePath));
239
+ if (!resumePath || !existsSync(resumePath)) {
240
+ throw new RunnerInputError("job.apply_basic requires an existing resumePath");
241
+ }
242
+ const candidate = isRecord(input.candidate) ? input.candidate : {};
243
+ const fullName = stringValue(candidate.fullName);
244
+ const email = stringValue(candidate.email);
245
+ if (!fullName)
246
+ throw new RunnerInputError("candidate.fullName is required");
247
+ if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
248
+ throw new RunnerInputError("candidate.email must be a valid email address");
249
+ }
250
+ return {
251
+ url,
252
+ resumePath,
253
+ candidate: {
254
+ fullName,
255
+ email,
256
+ phone: optionalString(candidate.phone),
257
+ location: optionalString(candidate.location),
258
+ linkedin: optionalString(candidate.linkedin),
259
+ portfolio: optionalString(candidate.portfolio),
260
+ workAuthorization: optionalString(candidate.workAuthorization),
261
+ },
262
+ coverNote: optionalString(input.coverNote),
263
+ };
264
+ }
265
+ async function fillBasicApplicationForm(page, job) {
266
+ const filled = [];
267
+ const candidate = job.candidate;
268
+ const fillSpecs = [
269
+ ["fullName", candidate.fullName, [
270
+ 'input[name="fullName"]',
271
+ 'input[name="full_name"]',
272
+ 'input[name="name"]',
273
+ 'input[autocomplete="name"]',
274
+ ]],
275
+ ["email", candidate.email, [
276
+ 'input[name="email"]',
277
+ 'input[type="email"]',
278
+ 'input[autocomplete="email"]',
279
+ ]],
280
+ ["phone", candidate.phone, [
281
+ 'input[name="phone"]',
282
+ 'input[name="mobile"]',
283
+ 'input[autocomplete="tel"]',
284
+ ]],
285
+ ["location", candidate.location, [
286
+ 'input[name="location"]',
287
+ 'input[autocomplete="address-level2"]',
288
+ 'input[autocomplete="address-level1"]',
289
+ ]],
290
+ ["linkedin", candidate.linkedin, [
291
+ 'input[name="linkedin"]',
292
+ 'input[name="linkedIn"]',
293
+ 'input[placeholder*="LinkedIn" i]',
294
+ ]],
295
+ ["portfolio", candidate.portfolio, [
296
+ 'input[name="portfolio"]',
297
+ 'input[name="website"]',
298
+ 'input[placeholder*="Portfolio" i]',
299
+ 'input[placeholder*="Website" i]',
300
+ ]],
301
+ ["coverNote", job.coverNote, [
302
+ 'textarea[name="coverNote"]',
303
+ 'textarea[name="cover_letter"]',
304
+ 'textarea[name="coverLetter"]',
305
+ 'textarea[placeholder*="cover" i]',
306
+ 'textarea[placeholder*="message" i]',
307
+ ]],
308
+ ];
309
+ for (const [name, value, selectors] of fillSpecs) {
310
+ if (value && await fillFirst(page, selectors, value))
311
+ filled.push(name);
312
+ }
313
+ if (candidate.workAuthorization && await selectOrFillFirst(page, [
314
+ 'select[name="workAuthorization"]',
315
+ 'select[name="work_authorization"]',
316
+ 'select[name*="authorization" i]',
317
+ ], candidate.workAuthorization)) {
318
+ filled.push("workAuthorization");
319
+ }
320
+ const resumeInput = page.locator('input[type="file"][name*="resume" i], input[type="file"][name*="cv" i], input[type="file"]').first();
321
+ if (await resumeInput.count() === 0) {
322
+ throw new Error("No resume upload field found on the application form");
323
+ }
324
+ await resumeInput.setInputFiles(job.resumePath);
325
+ filled.push("resume");
326
+ return filled;
327
+ }
328
+ async function fillFirst(page, selectors, value) {
329
+ for (const selector of selectors) {
330
+ const locator = page.locator(selector).first();
331
+ if (await locator.count() === 0)
332
+ continue;
333
+ try {
334
+ await locator.fill(value, { timeout: 5_000 });
335
+ return true;
336
+ }
337
+ catch {
338
+ /* try next selector */
339
+ }
340
+ }
341
+ return false;
342
+ }
343
+ async function selectOrFillFirst(page, selectors, value) {
344
+ for (const selector of selectors) {
345
+ const locator = page.locator(selector).first();
346
+ if (await locator.count() === 0)
347
+ continue;
348
+ try {
349
+ await locator.selectOption({ label: value }, { timeout: 5_000 });
350
+ return true;
351
+ }
352
+ catch {
353
+ try {
354
+ await locator.fill(value, { timeout: 5_000 });
355
+ return true;
356
+ }
357
+ catch {
358
+ /* try next selector */
359
+ }
360
+ }
361
+ }
362
+ return false;
363
+ }
364
+ async function submitApplicationForm(page) {
365
+ const submit = page.locator('form button[type="submit"], form input[type="submit"], button:has-text("Submit"), input[type="submit"]').first();
366
+ if (await submit.count() > 0) {
367
+ await submit.click({ timeout: 10_000 });
368
+ return;
369
+ }
370
+ const form = page.locator("form").first();
371
+ if (await form.count() === 0)
372
+ throw new Error("No application form found to submit");
373
+ await form.evaluate((node) => {
374
+ const htmlForm = node;
375
+ if (typeof htmlForm.requestSubmit === "function")
376
+ htmlForm.requestSubmit();
377
+ else
378
+ htmlForm.submit();
379
+ });
380
+ }
205
381
  function isRecord(value) {
206
382
  return typeof value === "object" && value !== null && !Array.isArray(value);
207
383
  }
384
+ function stringValue(value) {
385
+ return String(value || "").trim();
386
+ }
387
+ function optionalString(value) {
388
+ const text = stringValue(value);
389
+ return text || undefined;
390
+ }
208
391
  function normalizeCreateTaskRequest(request) {
209
392
  if (!isRecord(request) || typeof request.type !== "string" || !request.type.trim()) {
210
393
  throw new RunnerInputError("task type required");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",