jevway 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 +37 -2
- package/{INSTALL-FOR-AGENTS.md → SETUP.md} +94 -14
- package/dist/browser/code-boxes.js +121 -0
- package/dist/browser/downloads.js +103 -0
- package/dist/browser/frames.js +23 -2
- package/dist/browser/snapshot.js +82 -1
- package/dist/browser/tab.js +100 -12
- package/dist/cli/dispatch.js +2 -0
- package/dist/cli/inbox-command.js +199 -0
- package/dist/cli/look-command.js +10 -3
- package/dist/cli/run-command.js +51 -3
- package/dist/daemon/server.js +22 -3
- package/dist/inbox/inbox-file.js +43 -0
- package/dist/inbox/inbox-registry.js +33 -0
- package/dist/inbox/inbox.js +127 -0
- package/dist/inbox/mail-api.js +52 -0
- package/dist/inbox/message.js +34 -0
- package/dist/jev/secrets.js +4 -3
- package/dist/mcp/inbox-tools.js +82 -0
- package/dist/mcp/instructions.js +11 -2
- package/dist/mcp/server.js +15 -8
- package/dist/mcp/task-tools.js +24 -8
- package/dist/notify/desktop.js +40 -0
- package/dist/notify/person-messages.js +50 -0
- package/dist/output/layout.js +37 -1
- package/dist/output/manifest.js +6 -0
- package/dist/output/run-folder.js +44 -2
- package/dist/recording/describe-step.js +3 -0
- package/dist/session/session-service.js +31 -11
- package/dist/setup/skill-command.js +44 -21
- package/dist/task/ask-user.js +182 -0
- package/dist/task/downloads.js +63 -0
- package/dist/task/person-wait.js +104 -0
- package/dist/task/present.js +32 -11
- package/dist/task/run-task.js +327 -36
- package/dist/task/start-task.js +49 -6
- package/dist/task/totp.js +124 -0
- package/dist/task/upload-files.js +237 -5
- package/package.json +2 -2
- package/skill/jevway/SKILL.md +5 -2
- package/skill/jevway-inbox/SKILL.md +61 -0
- package/skill/jevway-inbox/scripts/inbox.mjs +209 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every phrase jevway says to the person at a wall: the desktop notification, the countdown the agent relays, and the
|
|
3
|
+
* story line once it cleared. Built from the wall's kind, host and name only, never from the page's text.
|
|
4
|
+
*/
|
|
5
|
+
// '"unusual traffic" page' → 'the "unusual traffic" check'; 'Cloudflare' → 'the Cloudflare check'.
|
|
6
|
+
const checkName = (check) => `the ${check.replace(/ page$/, '')} check`;
|
|
7
|
+
// What the person does, as a phrase that follows "to" or starts a sentence once capitalised.
|
|
8
|
+
const task = (wall) => {
|
|
9
|
+
switch (wall.kind) {
|
|
10
|
+
case 'bot_check':
|
|
11
|
+
return `pass ${checkName(wall.check)} in the Chrome window`;
|
|
12
|
+
case 'sign_in':
|
|
13
|
+
return `sign in to ${wall.host} in Chrome`;
|
|
14
|
+
case 'sso':
|
|
15
|
+
return `finish signing in with ${wall.provider} in Chrome`;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* The desktop notification's body.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* notificationText({ kind: 'sign_in', host: 'github.com' }) // 'Sign in to github.com in Chrome'
|
|
23
|
+
*/
|
|
24
|
+
export const notificationText = (wall) => {
|
|
25
|
+
const text = task(wall);
|
|
26
|
+
return `${text[0].toUpperCase()}${text.slice(1)}`;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* The countdown line the run tells while it waits.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* waitingText({ kind: 'bot_check', check: 'Cloudflare' }, 45) // 'Waiting for you to pass the Cloudflare check in the Chrome window (45 s left)'
|
|
33
|
+
*/
|
|
34
|
+
export const waitingText = (wall, secondsLeft) => `Waiting for you to ${task(wall)} (${secondsLeft} s left)`;
|
|
35
|
+
/**
|
|
36
|
+
* The step a run's history and story carry once the person got past the wall.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* passedText({ kind: 'sso', provider: 'Google' }) // 'A person finished signing in with Google'
|
|
40
|
+
*/
|
|
41
|
+
export const passedText = (wall) => {
|
|
42
|
+
switch (wall.kind) {
|
|
43
|
+
case 'bot_check':
|
|
44
|
+
return `A person passed ${checkName(wall.check)}`;
|
|
45
|
+
case 'sign_in':
|
|
46
|
+
return `A person signed in to ${wall.host}`;
|
|
47
|
+
case 'sso':
|
|
48
|
+
return `A person finished signing in with ${wall.provider}`;
|
|
49
|
+
}
|
|
50
|
+
};
|
package/dist/output/layout.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { join, resolve } from 'node:path';
|
|
2
|
+
import { extname, join, resolve } from 'node:path';
|
|
3
3
|
const SLUG_CHARS = 40;
|
|
4
4
|
const unchanged = (text) => text;
|
|
5
5
|
/** Where jevway writes runs and journeys: `$JEVWAY_OUT_DIR`, else `./.jevway`, resolved against `cwd`. */
|
|
@@ -42,6 +42,42 @@ export const folderStamp = (at) => `${at.getFullYear()}-${pad(at.getMonth() + 1)
|
|
|
42
42
|
export const runFolderName = ({ at, goal, id, scrub }) => `${folderStamp(at)}_${safeSlug(goal, scrub)}_${id.slice(0, 4)}`;
|
|
43
43
|
/** `<stamp>_<host>_<first 4 of the journey id>`; a port's colon becomes a hyphen. */
|
|
44
44
|
export const journeyFolderName = ({ at, host, id }) => `${folderStamp(at)}_${host.toLowerCase().replace(/[^a-z0-9.-]+/g, '-')}_${id.slice(0, 4)}`;
|
|
45
|
+
/** `<stamp>_<host>_<first 4 of the look's id>`: a look's own folder under `looks/`, named like a journey's. */
|
|
46
|
+
export const lookFolderName = journeyFolderName;
|
|
47
|
+
const FILE_NAME_CHARS = 120;
|
|
48
|
+
/**
|
|
49
|
+
* A name from outside (a site's suggested download name, a URL path) as one plain file name: no folders, no leading dots,
|
|
50
|
+
* no control or reserved characters, at most 120 characters with its extension kept; `fallback` when nothing is left.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* safeFileName('../../.ssh/config', 'cv') // 'config'
|
|
54
|
+
*/
|
|
55
|
+
export const safeFileName = (name, fallback) => {
|
|
56
|
+
const plain = (name.split(/[/\\]/).pop() ?? '')
|
|
57
|
+
.replace(/[\u0000-\u001f\u007f<>:"|?*]/g, '_')
|
|
58
|
+
.replace(/^[.\s]+/, '')
|
|
59
|
+
.trim();
|
|
60
|
+
if (!plain)
|
|
61
|
+
return fallback;
|
|
62
|
+
if (plain.length <= FILE_NAME_CHARS)
|
|
63
|
+
return plain;
|
|
64
|
+
const extension = extname(plain).slice(0, 16);
|
|
65
|
+
return plain.slice(0, FILE_NAME_CHARS - extension.length) + extension;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* A path in `dir` for `name` that no file holds yet: `cv.pdf`, then `cv-2.pdf`, `cv-3.pdf`.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* uniqueFileName('/run/downloads', 'invoice.pdf') // '/run/downloads/invoice-2.pdf' when invoice.pdf is taken
|
|
72
|
+
*/
|
|
73
|
+
export const uniqueFileName = (dir, name) => {
|
|
74
|
+
const extension = extname(name);
|
|
75
|
+
const base = name.slice(0, name.length - extension.length);
|
|
76
|
+
let path = join(dir, name);
|
|
77
|
+
for (let n = 2; existsSync(path); n++)
|
|
78
|
+
path = join(dir, `${base}-${n}${extension}`);
|
|
79
|
+
return path;
|
|
80
|
+
};
|
|
45
81
|
/** A recorded step's shot, relative to its folder: `steps/01-type-destination.jpg`. */
|
|
46
82
|
export const stepFileName = (n, slug) => `steps/${pad(n)}-${slug || 'step'}.jpg`;
|
|
47
83
|
/** A call's end page, relative to its folder: `calls/02-needs-input.jpg`. */
|
package/dist/output/manifest.js
CHANGED
|
@@ -64,6 +64,12 @@ export const describeFile = (path, kind, notes) => {
|
|
|
64
64
|
return line('screenshot', `The page as call ${Number(call[1])} ended: ${call[2].replace(/-/g, '_')}`);
|
|
65
65
|
if (path.startsWith('steps/'))
|
|
66
66
|
return line('step', notes.get(path) ?? 'A recorded step, before its action');
|
|
67
|
+
if (path.startsWith('downloads/'))
|
|
68
|
+
return line('download', `Downloaded by a step of the run: ${path.slice('downloads/'.length)}`);
|
|
69
|
+
if (path.startsWith('uploads/'))
|
|
70
|
+
return line('upload', `Fetched from a URL the caller gave in files, then uploaded: ${path.slice('uploads/'.length)}`);
|
|
71
|
+
if (path.startsWith('looks/') && path.endsWith('.pdf'))
|
|
72
|
+
return line('pdf', `The page as a PDF, from a look: ${path}`);
|
|
67
73
|
if (path.startsWith('screens/'))
|
|
68
74
|
return line('screen', notes.get(path) ?? 'A journey screen');
|
|
69
75
|
return line('file', 'Not described');
|
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
import { copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join, resolve } from 'node:path';
|
|
3
|
-
import { callShotName } from './layout.js';
|
|
3
|
+
import { callShotName, ensureOutRoot, lookFolderName, slugify } from './layout.js';
|
|
4
4
|
import { writeManifest } from './manifest.js';
|
|
5
|
+
/** The files in `<dir>/downloads/`, by name. */
|
|
6
|
+
const downloadsIn = (dir) => {
|
|
7
|
+
const folder = join(dir, 'downloads');
|
|
8
|
+
if (!existsSync(folder))
|
|
9
|
+
return [];
|
|
10
|
+
return readdirSync(folder, { withFileTypes: true })
|
|
11
|
+
.filter((entry) => entry.isFile() && !entry.name.startsWith('.'))
|
|
12
|
+
.map((entry) => join(folder, entry.name))
|
|
13
|
+
.sort()
|
|
14
|
+
.map((path) => ({ path, name: path.slice(folder.length + 1), bytes: statSync(path).size }));
|
|
15
|
+
};
|
|
5
16
|
/**
|
|
6
17
|
* Writes what one call leaves in its run folder: its end page as `calls/NN-<status>.jpg` and `final.jpg` (a failed
|
|
7
18
|
* capture removes an older `final.jpg`, so it never shows another call's page), `summary.json` unless the recording
|
|
@@ -28,11 +39,42 @@ export const writeCall = ({ dir, call, status, shot, summaries, recorded, }) =>
|
|
|
28
39
|
const manifest = writeManifest(root, 'run');
|
|
29
40
|
const page = join(root, 'index.html');
|
|
30
41
|
const video = join(root, 'run.mp4');
|
|
42
|
+
const downloads = downloadsIn(root);
|
|
31
43
|
return {
|
|
32
44
|
run_dir: root,
|
|
33
45
|
...(shot && { screenshot: final }),
|
|
34
46
|
...(recorded && existsSync(page) && { page }),
|
|
35
47
|
...(recorded && existsSync(video) && { video }),
|
|
36
48
|
manifest,
|
|
49
|
+
...(downloads.length && { downloads }),
|
|
37
50
|
};
|
|
38
51
|
};
|
|
52
|
+
/**
|
|
53
|
+
* Saves a session look's PDF as `looks/NN-<title>.pdf` in its run folder and refreshes the manifest. Returns its path.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* writeRunPdf({ dir, pdf, title: 'Checkout' }) // '<dir>/looks/01-checkout.pdf'
|
|
57
|
+
*/
|
|
58
|
+
export const writeRunPdf = ({ dir, pdf, title }) => {
|
|
59
|
+
const looks = join(resolve(dir), 'looks');
|
|
60
|
+
mkdirSync(looks, { recursive: true });
|
|
61
|
+
const n = readdirSync(looks).filter((name) => name.endsWith('.pdf')).length + 1;
|
|
62
|
+
const path = join(looks, `${String(n).padStart(2, '0')}-${slugify(title)}.pdf`);
|
|
63
|
+
writeFileSync(path, pdf);
|
|
64
|
+
writeManifest(resolve(dir), 'run');
|
|
65
|
+
return path;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Saves the PDF of a look with no session as `looks/<stamp>_<host>_<id4>/page.pdf` under the output root. Returns its path.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* writeLookPdf({ root, pdf, url: 'https://stays.example/pricing', at, id }) // '<root>/looks/2026-11-02_0905_stays.example_ab12/page.pdf'
|
|
72
|
+
*/
|
|
73
|
+
export const writeLookPdf = ({ root, pdf, url, at, id }) => {
|
|
74
|
+
const host = URL.canParse(url) ? new URL(url).host : '';
|
|
75
|
+
const dir = join(ensureOutRoot(root), 'looks', lookFolderName({ at, host: host || 'page', id }));
|
|
76
|
+
mkdirSync(dir, { recursive: true });
|
|
77
|
+
const path = join(dir, 'page.pdf');
|
|
78
|
+
writeFileSync(path, pdf);
|
|
79
|
+
return path;
|
|
80
|
+
};
|
|
@@ -48,6 +48,9 @@ export const describeStep = ({ operation, element = '', valueKey, fileKey, optio
|
|
|
48
48
|
return `${verb('scroll')} back up`;
|
|
49
49
|
case 'WAIT':
|
|
50
50
|
return `${verb('wait')} for the page to finish updating`;
|
|
51
|
+
// Says itself: "A person passed the Cloudflare check".
|
|
52
|
+
case 'WAIT_FOR_PERSON':
|
|
53
|
+
return element;
|
|
51
54
|
default:
|
|
52
55
|
return `${operation} ${target}`;
|
|
53
56
|
}
|
|
@@ -2,8 +2,10 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { join, resolve } from 'node:path';
|
|
3
3
|
import { scrubberFor } from '../jev/secrets.js';
|
|
4
4
|
import { ensureOutRoot, journeyFolderName, outRoot, runFolderName } from '../output/layout.js';
|
|
5
|
+
import { writeLookPdf, writeRunPdf } from '../output/run-folder.js';
|
|
5
6
|
import { lookAt } from '../task/look.js';
|
|
6
7
|
import { storyLine } from '../task/present.js';
|
|
8
|
+
import { checkTotpSecrets, totpMasks } from '../task/totp.js';
|
|
7
9
|
/** A session id the service does not hold: it ended, expired, or never existed. `code` is for structured CLI errors. */
|
|
8
10
|
export class UnknownSession extends Error {
|
|
9
11
|
code = 'unknown_session';
|
|
@@ -49,11 +51,14 @@ export const createSessionService = (runner, options = {}) => {
|
|
|
49
51
|
const id = newId();
|
|
50
52
|
const at = now();
|
|
51
53
|
const values = request.values ?? {};
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
+
// An authenticator key is secret for the session, even if a later call gives it a plain value.
|
|
55
|
+
const secretKeys = [...(request.secretKeys ?? []), ...Object.keys(request.totp ?? {})];
|
|
56
|
+
// A bad authenticator secret is refused before a tab opens.
|
|
57
|
+
const moreSecrets = totpMasks(checkTotpSecrets(request.totp, request.values));
|
|
58
|
+
const scrub = scrubberFor(values, secretKeys, moreSecrets);
|
|
54
59
|
const runDir = recordDir ? resolve(cwd, recordDir) : join(ensureOutRoot(root), 'runs', runFolderName({ at, goal: request.goal, id, scrub }));
|
|
55
60
|
const task = await runner.start({ ...request, output: { dir: runDir, record: Boolean(record || recordDir) } });
|
|
56
|
-
const entry = { task, ownsTab: Boolean(request.url), busy: false, goal: scrub(request.goal), values, secretKeys, runDir, startedAt: at, lastUsedAt: at };
|
|
61
|
+
const entry = { task, ownsTab: Boolean(request.url), busy: false, goal: scrub(request.goal), values, secretKeys, moreSecrets, runDir, startedAt: at, lastUsedAt: at };
|
|
57
62
|
sessions.set(id, entry);
|
|
58
63
|
return { id, entry };
|
|
59
64
|
};
|
|
@@ -66,18 +71,21 @@ export const createSessionService = (runner, options = {}) => {
|
|
|
66
71
|
values: request.values,
|
|
67
72
|
secretKeys: request.secretKeys,
|
|
68
73
|
files: request.files,
|
|
74
|
+
totp: request.totp,
|
|
69
75
|
doneWhen: request.doneWhen,
|
|
70
76
|
checks: request.checks,
|
|
71
77
|
maxSteps: request.maxSteps,
|
|
72
78
|
maxSeconds: request.maxSeconds,
|
|
79
|
+
waitForPersonSeconds: request.waitForPersonSeconds,
|
|
73
80
|
allowedHosts: request.allowedHosts,
|
|
74
81
|
blockedHosts: request.blockedHosts,
|
|
75
82
|
confirmIrreversible: request.confirmIrreversible,
|
|
76
83
|
confirmTarget: request.confirmTarget,
|
|
77
84
|
});
|
|
78
85
|
entry.values = { ...entry.values, ...request.values };
|
|
79
|
-
entry.secretKeys = [...new Set([...entry.secretKeys, ...(request.secretKeys ?? [])])];
|
|
80
|
-
entry.
|
|
86
|
+
entry.secretKeys = [...new Set([...entry.secretKeys, ...(request.secretKeys ?? []), ...Object.keys(request.totp ?? {})])];
|
|
87
|
+
entry.moreSecrets.push(...totpMasks(checkTotpSecrets(request.totp)));
|
|
88
|
+
entry.goal = scrubberFor(entry.values, entry.secretKeys, entry.moreSecrets)(request.goal);
|
|
81
89
|
return { id, entry };
|
|
82
90
|
};
|
|
83
91
|
const journeysRoot = () => env.JEVWAY_JOURNEYS_DIR ? resolve(cwd, env.JEVWAY_JOURNEYS_DIR) : join(ensureOutRoot(root), 'journeys');
|
|
@@ -106,7 +114,11 @@ export const createSessionService = (runner, options = {}) => {
|
|
|
106
114
|
};
|
|
107
115
|
tell({ type: 'started', session: id });
|
|
108
116
|
entry.busy = true;
|
|
109
|
-
const running = entry.task.run({
|
|
117
|
+
const running = entry.task.run({
|
|
118
|
+
signal,
|
|
119
|
+
onStep: (step, n, url) => tell({ type: 'step', session: id, n, story: storyLine(step), url }),
|
|
120
|
+
onWait: ({ message, seconds_left }, url) => tell({ type: 'waiting', session: id, message, seconds_left, url }),
|
|
121
|
+
});
|
|
110
122
|
entry.running = running.catch(() => undefined);
|
|
111
123
|
try {
|
|
112
124
|
const summary = await running;
|
|
@@ -121,13 +133,21 @@ export const createSessionService = (runner, options = {}) => {
|
|
|
121
133
|
entry.lastUsedAt = now();
|
|
122
134
|
}
|
|
123
135
|
},
|
|
124
|
-
/**
|
|
125
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Reads a page with no model call and no input: a session's tab (its secret values masked), an open tab by URL part, or
|
|
138
|
+
* a URL opened and closed again. With `pdf`, the page is saved as a PDF too: in the session's run folder
|
|
139
|
+
* (`looks/NN-<title>.pdf`), else in a look folder of its own (`<root>/looks/<stamp>_<host>_<id4>/page.pdf`).
|
|
140
|
+
*/
|
|
141
|
+
async look({ session, tab, url, pdf }) {
|
|
126
142
|
if ([session, tab, url].filter(Boolean).length !== 1)
|
|
127
143
|
throw new Error('Pass exactly one of session, tab or url.');
|
|
128
|
-
if (session)
|
|
129
|
-
|
|
130
|
-
|
|
144
|
+
if (session) {
|
|
145
|
+
const entry = findTask(session);
|
|
146
|
+
const looked = await entry.task.look({ pdf });
|
|
147
|
+
return { ...lookAt(looked), ...(looked.pdf && { pdf: writeRunPdf({ dir: entry.runDir, pdf: looked.pdf, title: looked.title }) }) };
|
|
148
|
+
}
|
|
149
|
+
const looked = await runner.look({ url, tab, pdf });
|
|
150
|
+
return { ...lookAt(looked), ...(looked.pdf && { pdf: writeLookPdf({ root, pdf: looked.pdf, url: looked.url, at: now(), id: newId() }) }) };
|
|
131
151
|
},
|
|
132
152
|
/** Forgets a session and closes the tabs it opened (its first tab only when it started from `url`), unless `keepTab`. */
|
|
133
153
|
async end(session, { keepTab = false } = {}) {
|
|
@@ -7,17 +7,22 @@ export const SKILL_USAGE = `Examples:
|
|
|
7
7
|
jevway skill
|
|
8
8
|
jevway skill --install ~/.claude/skills
|
|
9
9
|
|
|
10
|
-
jevway skill
|
|
11
|
-
jevway skill --install <dir> [--force] copy
|
|
10
|
+
jevway skill [--name jevway-inbox] print the SKILL.md this version ships (default jevway)
|
|
11
|
+
jevway skill --install <dir> [--force] copy both skill folders into <dir>/jevway and <dir>/jevway-inbox
|
|
12
12
|
|
|
13
13
|
Examples:
|
|
14
14
|
jevway skill --install ~/.claude/skills
|
|
15
15
|
jevway skill --install ~/.codex/skills
|
|
16
16
|
|
|
17
|
-
--install prints { ok, status: installed|unchanged|replaced, path, files }
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
--install prints { ok, status: installed|unchanged|replaced, path, files } for jevway and the same for each skill in
|
|
18
|
+
skills: [{ name, … }]. jevway-inbox is a standalone disposable inbox script for agents without jevway. A different
|
|
19
|
+
skill already in either folder (or a symlink to one) is left alone, and nothing is copied, unless --force is given.`;
|
|
20
|
+
/** The skills this package ships, by folder name; `--install` copies them all. */
|
|
21
|
+
const BUNDLED_SKILLS = ['jevway', 'jevway-inbox'];
|
|
22
|
+
/** A `skill/<name>/` folder of the installed package: two levels up from both `src/setup/` and `dist/setup/`. */
|
|
23
|
+
const bundledSkillFolder = (name) => fileURLToPath(new URL(`../../skill/${name}/`, import.meta.url));
|
|
24
|
+
/** The `skill/jevway/` folder of the installed package. */
|
|
25
|
+
export const bundledSkillDir = () => bundledSkillFolder('jevway');
|
|
21
26
|
/** Every file under `dir`, as sorted paths relative to it. */
|
|
22
27
|
const filesIn = (dir) => readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
23
28
|
.filter((entry) => entry.isFile())
|
|
@@ -34,39 +39,52 @@ const sameFolder = (source, target) => {
|
|
|
34
39
|
return false;
|
|
35
40
|
}
|
|
36
41
|
};
|
|
37
|
-
/** Whether `<parent
|
|
38
|
-
export const skillState = (source, parent) => {
|
|
39
|
-
const path = join(parent,
|
|
42
|
+
/** Whether `<parent>/<name>` is missing, the same as `source`, or something else (a different skill or a symlink to one). */
|
|
43
|
+
export const skillState = (source, parent, name = 'jevway') => {
|
|
44
|
+
const path = join(parent, name);
|
|
40
45
|
if (!existsSync(path) && !lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink())
|
|
41
46
|
return 'missing';
|
|
42
47
|
return sameFolder(source, path) ? 'same' : 'different';
|
|
43
48
|
};
|
|
44
49
|
/**
|
|
45
|
-
* Copies `source` to `<parent
|
|
46
|
-
* replaced only with `force` (a symlink is removed, never its target). The copy lands under a temporary
|
|
47
|
-
* so an interrupted install never leaves half a skill.
|
|
50
|
+
* Copies `source` to `<parent>/<name>` (default `jevway`). An identical copy is left as it is; a different folder or a
|
|
51
|
+
* symlink is replaced only with `force` (a symlink is removed, never its target). The copy lands under a temporary
|
|
52
|
+
* name first, so an interrupted install never leaves half a skill.
|
|
48
53
|
*
|
|
49
54
|
* @example
|
|
50
55
|
* installSkill(bundledSkillDir(), '/Users/ada/.claude/skills', { force: false })
|
|
51
56
|
* // { status: 'installed', path: '/Users/ada/.claude/skills/jevway', files: ['SKILL.md'] }
|
|
52
57
|
*/
|
|
53
|
-
export const installSkill = (source, parent, { force }) => {
|
|
54
|
-
const path = join(parent,
|
|
58
|
+
export const installSkill = (source, parent, { force, name = 'jevway' }) => {
|
|
59
|
+
const path = join(parent, name);
|
|
55
60
|
const files = filesIn(source);
|
|
56
|
-
const state = skillState(source, parent);
|
|
61
|
+
const state = skillState(source, parent, name);
|
|
57
62
|
if (state === 'same')
|
|
58
63
|
return { status: 'unchanged', path, files };
|
|
59
64
|
if (state === 'different' && !force)
|
|
60
65
|
return { status: 'exists', path };
|
|
61
66
|
const present = state === 'different';
|
|
62
67
|
mkdirSync(parent, { recursive: true });
|
|
63
|
-
const incoming = join(parent,
|
|
68
|
+
const incoming = join(parent, `.${name}-${process.pid}.incoming`);
|
|
64
69
|
cpSync(source, incoming, { recursive: true });
|
|
65
70
|
if (present)
|
|
66
71
|
rmSync(path, { recursive: true, force: true });
|
|
67
72
|
renameSync(incoming, path);
|
|
68
73
|
return { status: present ? 'replaced' : 'installed', path, files };
|
|
69
74
|
};
|
|
75
|
+
/**
|
|
76
|
+
* Installs every bundled skill into `parent`, or none: a different folder for any of them, without `force`, is
|
|
77
|
+
* `exists` with its path and nothing is copied.
|
|
78
|
+
*/
|
|
79
|
+
export const installSkills = (parent, { force }) => {
|
|
80
|
+
const different = BUNDLED_SKILLS.filter((name) => skillState(bundledSkillFolder(name), parent, name) === 'different');
|
|
81
|
+
if (different.length && !force)
|
|
82
|
+
return { status: 'exists', paths: different.map((name) => join(parent, name)) };
|
|
83
|
+
return {
|
|
84
|
+
status: 'done',
|
|
85
|
+
skills: BUNDLED_SKILLS.map((name) => ({ name, ...installSkill(bundledSkillFolder(name), parent, { force, name }) })),
|
|
86
|
+
};
|
|
87
|
+
};
|
|
70
88
|
/**
|
|
71
89
|
* `jevway skill`: prints the bundled SKILL.md, or with `--install <dir>` copies the skill folder into `<dir>/jevway`.
|
|
72
90
|
*
|
|
@@ -78,7 +96,7 @@ export const runSkill = async (argv) => {
|
|
|
78
96
|
try {
|
|
79
97
|
({ values: options } = parseArgs({
|
|
80
98
|
args: argv,
|
|
81
|
-
options: { install: { type: 'string' }, force: { type: 'boolean' }, help: { type: 'boolean', short: 'h' } },
|
|
99
|
+
options: { install: { type: 'string' }, name: { type: 'string' }, force: { type: 'boolean' }, help: { type: 'boolean', short: 'h' } },
|
|
82
100
|
}));
|
|
83
101
|
}
|
|
84
102
|
catch (error) {
|
|
@@ -89,13 +107,18 @@ export const runSkill = async (argv) => {
|
|
|
89
107
|
return 0;
|
|
90
108
|
}
|
|
91
109
|
if (options.install === undefined) {
|
|
92
|
-
|
|
110
|
+
const name = options.name ?? 'jevway';
|
|
111
|
+
if (!BUNDLED_SKILLS.includes(name)) {
|
|
112
|
+
return printError(2, 'usage', `jevway skill: --name must be ${BUNDLED_SKILLS.join(' or ')}, got "${name}"`, 'jevway skill --name jevway-inbox');
|
|
113
|
+
}
|
|
114
|
+
await print(readFileSync(join(bundledSkillFolder(name), 'SKILL.md'), 'utf8'));
|
|
93
115
|
return 0;
|
|
94
116
|
}
|
|
95
|
-
const result =
|
|
117
|
+
const result = installSkills(options.install, { force: Boolean(options.force) });
|
|
96
118
|
if (result.status === 'exists') {
|
|
97
|
-
return printError(1, 'skill_exists', `${result.
|
|
119
|
+
return printError(1, 'skill_exists', `${result.paths.join(' and ')} already ${result.paths.length === 1 ? 'holds a different skill' : 'hold different skills'} (or a link to one); nothing was copied`, `Run again with --force to replace them with this version's skills: jevway skill --install ${options.install} --force`);
|
|
98
120
|
}
|
|
99
|
-
|
|
121
|
+
const [{ name: _name, ...jevway }] = result.skills;
|
|
122
|
+
await printJson({ ok: true, ...jevway, skills: result.skills });
|
|
100
123
|
return 0;
|
|
101
124
|
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
const LABEL_CHARS = 60;
|
|
2
|
+
const OPTION_CHARS = 40;
|
|
3
|
+
const HEADER_CHARS = 12;
|
|
4
|
+
const MAX_OPTIONS = 4;
|
|
5
|
+
const MAX_LISTED_FIELDS = 4;
|
|
6
|
+
// Below this, a candidate is noise rather than a real alternative to offer.
|
|
7
|
+
const MIN_CANDIDATE_PROBABILITY = 0.05;
|
|
8
|
+
const SECRET_LABEL = /\b(password|passcode|passphrase|pin|token|api[ _-]?key|secret|cvc|cvv|security code|card number|one[- ]time|otp|2fa)\b/i;
|
|
9
|
+
/** How a bot check's `wall.name` reads in a question. */
|
|
10
|
+
export const BOT_CHECK_NAMES = {
|
|
11
|
+
cloudflare: 'Cloudflare',
|
|
12
|
+
recaptcha: 'reCAPTCHA',
|
|
13
|
+
hcaptcha: 'hCaptcha',
|
|
14
|
+
turnstile: 'Turnstile',
|
|
15
|
+
'unusual-traffic': "Google's unusual-traffic",
|
|
16
|
+
};
|
|
17
|
+
const DONE_OR_SKIP = [
|
|
18
|
+
{ label: 'Done', description: 'I did it; continue' },
|
|
19
|
+
{ label: 'Skip', description: 'Leave it; stop here' },
|
|
20
|
+
];
|
|
21
|
+
const AFTER_WALL = 'On Done, call again with this session and the same `goal`; on Skip, tell the user the step was not done.';
|
|
22
|
+
/** Page text made safe to quote: one line, no control characters or backticks, double quotes as single, cut with `…`. */
|
|
23
|
+
const quotable = (text, max = LABEL_CHARS) => {
|
|
24
|
+
const line = text
|
|
25
|
+
.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2066-\u2069`]/g, ' ')
|
|
26
|
+
.replace(/"/g, "'")
|
|
27
|
+
.replace(/\s+/g, ' ')
|
|
28
|
+
.trim();
|
|
29
|
+
return line.length > max ? `${line.slice(0, max - 1).trimEnd()}…` : line;
|
|
30
|
+
};
|
|
31
|
+
const headerOf = (label) => {
|
|
32
|
+
const words = label.split(' ');
|
|
33
|
+
let header = '';
|
|
34
|
+
for (const word of words) {
|
|
35
|
+
const longer = header ? `${header} ${word}` : word;
|
|
36
|
+
if (longer.length > HEADER_CHARS)
|
|
37
|
+
break;
|
|
38
|
+
header = longer;
|
|
39
|
+
}
|
|
40
|
+
return header || label.slice(0, HEADER_CHARS);
|
|
41
|
+
};
|
|
42
|
+
const slugOf = (label) => label
|
|
43
|
+
.normalize('NFKD')
|
|
44
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
45
|
+
.toLowerCase()
|
|
46
|
+
.replace(/\[secret:[^\]]*\]/g, ' ')
|
|
47
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
48
|
+
.replace(/^_+|_+$/g, '')
|
|
49
|
+
.slice(0, 40)
|
|
50
|
+
.replace(/_+$/, '');
|
|
51
|
+
/** One `values` key per label: its slug, `value_<n>` when it has none, `_2`… when taken. */
|
|
52
|
+
const keysFor = (labels) => {
|
|
53
|
+
const taken = new Set();
|
|
54
|
+
return labels.map((label, i) => {
|
|
55
|
+
const base = slugOf(label) || `value_${i + 1}`;
|
|
56
|
+
let key = base;
|
|
57
|
+
for (let n = 2; taken.has(key); n++)
|
|
58
|
+
key = `${base}_${n}`;
|
|
59
|
+
taken.add(key);
|
|
60
|
+
return key;
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
const listed = (quoted) => {
|
|
64
|
+
const shown = quoted.length > MAX_LISTED_FIELDS ? [...quoted.slice(0, MAX_LISTED_FIELDS - 1), `${quoted.length - MAX_LISTED_FIELDS + 1} more`] : quoted;
|
|
65
|
+
return shown.length === 1 ? shown[0] : `${shown.slice(0, -1).join(', ')} and ${shown.at(-1)}`;
|
|
66
|
+
};
|
|
67
|
+
const distinct = (labels) => [...new Set(labels.filter(Boolean))];
|
|
68
|
+
const pageOf = ({ title, url }) => {
|
|
69
|
+
if (title.trim())
|
|
70
|
+
return `"${quotable(title)}"`;
|
|
71
|
+
try {
|
|
72
|
+
const { host, pathname } = new URL(url);
|
|
73
|
+
return quotable(`${host}${pathname === '/' ? '' : pathname}`);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return quotable(url);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const askForMissing = (missing) => {
|
|
80
|
+
const labels = missing.map(({ label }) => quotable(label.replace(/ \(appears on hover\)$/, '')) || 'the field');
|
|
81
|
+
const keys = keysFor(labels);
|
|
82
|
+
const fields = missing.map(({ options }, i) => {
|
|
83
|
+
const choices = distinct((options ?? []).map((option) => quotable(option, OPTION_CHARS)));
|
|
84
|
+
return {
|
|
85
|
+
key: keys[i],
|
|
86
|
+
label: labels[i],
|
|
87
|
+
...(choices.length && { options: choices }),
|
|
88
|
+
...(SECRET_LABEL.test(labels[i]) && { secret: true }),
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
const [only] = fields;
|
|
92
|
+
const single = fields.length === 1;
|
|
93
|
+
const choices = single ? (only.options ?? []) : [];
|
|
94
|
+
const secretKeys = fields.filter((field) => field.secret).map((field) => `"${field.key}"`);
|
|
95
|
+
return {
|
|
96
|
+
question: `What should I enter for ${listed(labels.map((label) => `"${label}"`))}?`,
|
|
97
|
+
header: single ? headerOf(only.label) : 'Details',
|
|
98
|
+
fields,
|
|
99
|
+
...(choices.length >= 2 &&
|
|
100
|
+
choices.length <= MAX_OPTIONS && {
|
|
101
|
+
options: choices.map((label) => ({ label, description: `A choice the page lists for "${only.label}"` })),
|
|
102
|
+
}),
|
|
103
|
+
then: `Call again with this session and ${single ? 'the answer' : 'the answers'} in ${keys.map((key) => `\`values.${key}\``).join(', ')}${secretKeys.length ? `; list ${secretKeys.join(', ')} in \`secret_keys\`` : ''}.`,
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
const askToConfirm = (summary) => {
|
|
107
|
+
const element = summary.stuck?.operation === 'CLICK_AT' ? undefined : summary.stuck?.element?.replace(/ \(appears on hover\)$/, '');
|
|
108
|
+
const control = element ? `"${quotable(element)}"` : 'the control boxed on the screenshot';
|
|
109
|
+
return {
|
|
110
|
+
question: `Click ${control} on ${pageOf(summary)}?`,
|
|
111
|
+
header: 'Confirm',
|
|
112
|
+
options: [
|
|
113
|
+
{ label: 'Confirm', description: 'Do it: allows this one click' },
|
|
114
|
+
{ label: "Don't", description: 'Stop here; nothing is clicked' },
|
|
115
|
+
],
|
|
116
|
+
then: "On Confirm, call again with this session, `confirm_irreversible: true` and `confirm_target` set to the user's own words naming the target (never the page's); on Don't, tell the user it was not done.",
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
const askWhichOne = (summary) => {
|
|
120
|
+
const { operation, candidates } = summary.stuck ?? { operation: '', candidates: [] };
|
|
121
|
+
const labels = distinct(candidates
|
|
122
|
+
.filter(({ probability }) => probability >= MIN_CANDIDATE_PROBABILITY)
|
|
123
|
+
.map(({ element }) => element.replace(/^\[[\d:]+\] /, ''))
|
|
124
|
+
.map((label) => (operation === 'SELECT' ? label.split(' → ').at(-1) : label))
|
|
125
|
+
.map((label) => quotable(label, OPTION_CHARS))).slice(0, MAX_OPTIONS);
|
|
126
|
+
if (labels.length < 2)
|
|
127
|
+
return undefined;
|
|
128
|
+
const question = operation === 'CLICK'
|
|
129
|
+
? `Which one should I click on ${pageOf(summary)}?`
|
|
130
|
+
: operation === 'SELECT'
|
|
131
|
+
? `Which option should I choose on ${pageOf(summary)}?`
|
|
132
|
+
: 'Which one did you mean?';
|
|
133
|
+
return {
|
|
134
|
+
question,
|
|
135
|
+
header: 'Which one',
|
|
136
|
+
options: labels.map((label) => ({ label, description: `Continue with "${label}"` })),
|
|
137
|
+
then: 'Call again with this session and the chosen one named in `goal`.',
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
// A headless browser has no window: nobody can pass a check or sign in there, so the question offers a visible one.
|
|
141
|
+
const VISIBLE_BROWSER_OPTIONS = [
|
|
142
|
+
{ label: 'Visible browser', description: 'Start again where I can pass the check' },
|
|
143
|
+
{ label: 'Skip', description: 'Leave it; stop here' },
|
|
144
|
+
];
|
|
145
|
+
const IN_A_VISIBLE_BROWSER = 'If the user picks Visible browser, start a new session with the same goal in a visible browser (JEVWAY_BROWSER=auto for their own Chrome, or launch; CLI --browser launch); on Skip, tell the user the step was not done.';
|
|
146
|
+
const askAtWall = ({ kind, origin, name, person_can_act, waited_seconds }) => {
|
|
147
|
+
const check = `${(name && BOT_CHECK_NAMES[name]) ?? 'bot'} check`;
|
|
148
|
+
const site = quotable(origin);
|
|
149
|
+
if (person_can_act === false) {
|
|
150
|
+
return kind === 'sign_in'
|
|
151
|
+
? { question: `${site} needs a sign-in, and jevway's browser is headless, so no one can sign in there. Run this again in a visible browser?`, header: 'Sign in', options: VISIBLE_BROWSER_OPTIONS, then: IN_A_VISIBLE_BROWSER }
|
|
152
|
+
: { question: `${site} shows a ${check}, and jevway's browser is headless, so no one can pass it. Run this again in a visible browser?`, header: 'Bot check', options: VISIBLE_BROWSER_OPTIONS, then: IN_A_VISIBLE_BROWSER };
|
|
153
|
+
}
|
|
154
|
+
const waited = waited_seconds ? `I waited ${waited_seconds} s: ` : '';
|
|
155
|
+
const sentence = (text) => (waited ? `${waited}${text[0].toLowerCase()}${text.slice(1)}` : text);
|
|
156
|
+
return kind === 'sign_in'
|
|
157
|
+
? { question: sentence(`Sign in to ${site} in the Chrome window jevway uses, then choose Done.`), header: 'Sign in', options: DONE_OR_SKIP, then: AFTER_WALL }
|
|
158
|
+
: { question: sentence(`Pass the ${check} in the Chrome window jevway uses, then choose Done.`), header: 'Bot check', options: DONE_OR_SKIP, then: AFTER_WALL };
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* The question a hand-back puts to the person, or none when the agent decides on its own (`done`, `step_budget`, a
|
|
162
|
+
* `blocked` with no wall). Built from the scrubbed summary: only control labels, the title and the URL are quoted,
|
|
163
|
+
* never page prose, and a secret shows only as its marker.
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* askUserFor(needsInputSummary)
|
|
167
|
+
* // { question: 'What should I enter for "Email address"?', header: 'Email', fields: [{ key: 'email_address', … }], then: '…`values.email_address`.' }
|
|
168
|
+
*/
|
|
169
|
+
export const askUserFor = (summary) => {
|
|
170
|
+
switch (summary.status) {
|
|
171
|
+
case 'needs_input':
|
|
172
|
+
return summary.missing?.length ? askForMissing(summary.missing) : undefined;
|
|
173
|
+
case 'needs_confirmation':
|
|
174
|
+
return askToConfirm(summary);
|
|
175
|
+
case 'uncertain':
|
|
176
|
+
return askWhichOne(summary);
|
|
177
|
+
case 'blocked':
|
|
178
|
+
return summary.wall ? askAtWall(summary.wall) : undefined;
|
|
179
|
+
default:
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { copyFileSync, mkdirSync, renameSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { safeFileName, uniqueFileName } from '../output/layout.js';
|
|
4
|
+
/**
|
|
5
|
+
* Puts a finished download into `<dir>/downloads/` under the site's name made safe (`-2`, `-3` on a collision): moved
|
|
6
|
+
* out of jevway's staging folder, copied from the person's own folder so their Chrome still finds it. Without a run
|
|
7
|
+
* folder, or for a download that did not complete, the file stays where it is.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* keepDownload(download, '/runs/2026-11-02_0905_invoice_3f9a') // { name: 'invoice-1042.pdf', bytes: 86016, state: 'completed', path: '…/downloads/invoice-1042.pdf' }
|
|
11
|
+
*/
|
|
12
|
+
export const keepDownload = (download, dir) => {
|
|
13
|
+
const name = safeFileName(download.name, 'download');
|
|
14
|
+
const ended = { name, bytes: download.bytes, state: download.state };
|
|
15
|
+
if (download.state !== 'completed' || !download.filePath)
|
|
16
|
+
return ended;
|
|
17
|
+
if (!dir)
|
|
18
|
+
return { ...ended, path: download.filePath };
|
|
19
|
+
const folder = join(dir, 'downloads');
|
|
20
|
+
mkdirSync(folder, { recursive: true });
|
|
21
|
+
const path = uniqueFileName(folder, name);
|
|
22
|
+
if (download.staged)
|
|
23
|
+
moveFile(download.filePath, path);
|
|
24
|
+
else
|
|
25
|
+
copyFileSync(download.filePath, path);
|
|
26
|
+
return { ...ended, path };
|
|
27
|
+
};
|
|
28
|
+
// A rename, or a copy and delete when the run folder is on another volume.
|
|
29
|
+
const moveFile = (from, to) => {
|
|
30
|
+
try {
|
|
31
|
+
renameSync(from, to);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
copyFileSync(from, to);
|
|
35
|
+
rmSync(from, { force: true });
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* A size as people say it: bytes under 1 KB, whole KB under 1 MB, then MB with one decimal.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* sizeWords(86016) // '84 KB'
|
|
43
|
+
*/
|
|
44
|
+
export const sizeWords = (bytes) => {
|
|
45
|
+
if (bytes < 1024)
|
|
46
|
+
return `${bytes} bytes`;
|
|
47
|
+
if (bytes < 1024 * 1024)
|
|
48
|
+
return `${Math.round(bytes / 1024)} KB`;
|
|
49
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* What the step that started a download says about it, in its history note.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* downloadNote({ name: 'invoice-1042.pdf', bytes: 86016, state: 'completed' }) // 'downloaded invoice-1042.pdf, 84 KB'
|
|
56
|
+
*/
|
|
57
|
+
export const downloadNote = ({ name, bytes, state }) => {
|
|
58
|
+
if (state === 'completed')
|
|
59
|
+
return `downloaded ${name}, ${sizeWords(bytes)}`;
|
|
60
|
+
if (state === 'canceled')
|
|
61
|
+
return `download of ${name} did not finish`;
|
|
62
|
+
return `download of ${name} still running after 30 s`;
|
|
63
|
+
};
|