jevway 0.1.2 → 0.3.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.
Files changed (74) hide show
  1. package/README.md +227 -149
  2. package/SETUP.md +212 -0
  3. package/dist/browser/downloads.js +103 -0
  4. package/dist/browser/frames.js +21 -1
  5. package/dist/browser/snapshot.js +53 -0
  6. package/dist/browser/tab.js +59 -10
  7. package/dist/cli/daemon-call.js +78 -0
  8. package/dist/cli/dispatch.js +76 -0
  9. package/dist/cli/flags.js +152 -0
  10. package/dist/cli/help.js +46 -0
  11. package/dist/cli/inbox-command.js +199 -0
  12. package/dist/cli/journey-command.js +162 -0
  13. package/dist/cli/look-command.js +54 -0
  14. package/dist/cli/main.js +64 -0
  15. package/dist/cli/output.js +27 -0
  16. package/dist/cli/run-command.js +221 -0
  17. package/dist/cli/session-commands.js +132 -0
  18. package/dist/daemon/client.js +175 -0
  19. package/dist/daemon/paths.js +35 -0
  20. package/dist/daemon/protocol.js +28 -0
  21. package/dist/daemon/server.js +339 -0
  22. package/dist/inbox/inbox-file.js +43 -0
  23. package/dist/inbox/inbox-registry.js +33 -0
  24. package/dist/inbox/inbox.js +127 -0
  25. package/dist/inbox/mail-api.js +52 -0
  26. package/dist/inbox/message.js +34 -0
  27. package/dist/jev/replay.js +7 -3
  28. package/dist/jev/retry.js +29 -0
  29. package/dist/jev/secrets.js +4 -3
  30. package/dist/journeys/journey-doc.js +2 -1
  31. package/dist/journeys/journey-session.js +14 -3
  32. package/dist/journeys/render-viewer.js +163 -150
  33. package/dist/mcp/inbox-tools.js +82 -0
  34. package/dist/mcp/instructions.js +27 -0
  35. package/dist/mcp/journey-tools.js +117 -0
  36. package/dist/mcp/results.js +53 -0
  37. package/dist/mcp/server.js +57 -0
  38. package/dist/mcp/task-tools.js +213 -0
  39. package/dist/mcp.js +10 -337
  40. package/dist/notify/desktop.js +40 -0
  41. package/dist/notify/person-messages.js +50 -0
  42. package/dist/output/layout.js +86 -0
  43. package/dist/output/manifest.js +86 -0
  44. package/dist/output/run-folder.js +80 -0
  45. package/dist/recording/describe-step.js +69 -0
  46. package/dist/{task → recording}/recording-page.js +164 -18
  47. package/dist/{task → recording}/recording.js +33 -91
  48. package/dist/recording/run-page.js +155 -0
  49. package/dist/session/session-service.js +213 -0
  50. package/dist/setup/atomic-write.js +13 -0
  51. package/dist/setup/clients.js +180 -0
  52. package/dist/setup/command-output.js +15 -0
  53. package/dist/setup/doctor-command.js +16 -7
  54. package/dist/setup/doctor.js +17 -2
  55. package/dist/setup/env-file.js +22 -0
  56. package/dist/setup/setup-command.js +104 -0
  57. package/dist/setup/setup-wizard.js +216 -0
  58. package/dist/setup/skill-command.js +124 -0
  59. package/dist/setup/terminal-prompter.js +51 -0
  60. package/dist/setup/unified-diff.js +69 -0
  61. package/dist/task/ask-user.js +182 -0
  62. package/dist/task/downloads.js +63 -0
  63. package/dist/task/person-wait.js +104 -0
  64. package/dist/task/present.js +111 -0
  65. package/dist/task/run-task.js +332 -32
  66. package/dist/task/start-task.js +70 -11
  67. package/dist/task/totp.js +124 -0
  68. package/dist/task/upload-files.js +237 -5
  69. package/package.json +10 -8
  70. package/skill/jevway/SKILL.md +54 -15
  71. package/skill/jevway-inbox/SKILL.md +61 -0
  72. package/skill/jevway-inbox/scripts/inbox.mjs +209 -0
  73. package/INSTALL-FOR-AGENTS.md +0 -138
  74. package/dist/cli.js +0 -112
@@ -4,12 +4,18 @@ import { parseArgs } from 'node:util';
4
4
  import { devToolsActivePort, findChrome } from '../browser/chrome.js';
5
5
  import { createLiveAsk, resolveModel } from '../jev/model.js';
6
6
  import { replayDir } from '../jev/replay.js';
7
- import { diagnose, exitCode, liveCheck, planSteps, render } from './doctor.js';
7
+ import { diagnose, doctorJson, exitCode, liveCheck, planSteps, render } from './doctor.js';
8
8
  import { envFilePath, shownEnvFilePath } from './env-file.js';
9
- const USAGE = `jevway doctor [--plan] [--live] [--browser <source>] [--model <name>]
9
+ export const DOCTOR_USAGE = `Examples:
10
+ jevway doctor
11
+ jevway doctor --json
12
+ jevway doctor --plan --browser launch-headless
13
+
14
+ jevway doctor [--plan | --json] [--live] [--browser <source>] [--model <name>]
10
15
 
11
16
  Checks Node, the API key and the route it selects, the browser source and ffmpeg. Never prints a key.
12
17
  --plan print the setup steps still missing, numbered
18
+ --json print { ready, findings: [{ check, level, message, fix? }] }
13
19
  --live also make ONE real Jev request (costs a fraction of a cent)
14
20
  --browser check this source instead of $JEVWAY_BROWSER (auto, launch, launch-headless or a URL)
15
21
  --model check this model instead of $JEVWAY_MODEL
@@ -26,7 +32,8 @@ const envFileMode = () => {
26
32
  return undefined;
27
33
  }
28
34
  };
29
- const realProbes = (browser, model, fromEnvFile) => ({
35
+ /** The doctor's view of this machine, for one browser source and model. */
36
+ export const realProbes = (browser, model, fromEnvFile) => ({
30
37
  nodeVersion: process.versions.node,
31
38
  browser,
32
39
  model,
@@ -36,7 +43,7 @@ const realProbes = (browser, model, fromEnvFile) => ({
36
43
  devToolsActivePort,
37
44
  answers: (url) => fetch(url, { signal: AbortSignal.timeout(2_000) }).then(() => true, () => false),
38
45
  hasFfmpeg: () => new Promise((resolve) => execFile('ffmpeg', ['-version'], (error) => resolve(!error))),
39
- ...(process.env.JEVWAY_REPLAY?.trim() && { replay: { mode: process.env.JEVWAY_REPLAY.trim(), dir: replayDir() } }),
46
+ ...(process.env.JEVWAY_REPLAY?.trim() && { replay: { mode: process.env.JEVWAY_REPLAY.trim(), dir: process.env.JEVWAY_REPLAY_DIR?.trim() ? replayDir() : '(JEVWAY_REPLAY_DIR is not set)' } }),
40
47
  });
41
48
  /**
42
49
  * `jevway doctor`: prints the report (or, with `--plan`, the steps left) and returns the exit code. `fromEnvFile` is
@@ -52,6 +59,7 @@ export const runDoctor = async (argv, fromEnvFile) => {
52
59
  args: argv,
53
60
  options: {
54
61
  plan: { type: 'boolean' },
62
+ json: { type: 'boolean' },
55
63
  live: { type: 'boolean' },
56
64
  browser: { type: 'string' },
57
65
  model: { type: 'string' },
@@ -60,11 +68,11 @@ export const runDoctor = async (argv, fromEnvFile) => {
60
68
  }));
61
69
  }
62
70
  catch (error) {
63
- console.error(`jevway doctor: ${error.message}\n\n${USAGE}`);
71
+ console.error(`jevway doctor: ${error.message}\n\n${DOCTOR_USAGE}`);
64
72
  return 2;
65
73
  }
66
74
  if (options.help) {
67
- console.log(USAGE);
75
+ console.log(DOCTOR_USAGE);
68
76
  return 0;
69
77
  }
70
78
  const findings = await diagnose(realProbes(options.browser ?? process.env.JEVWAY_BROWSER ?? 'auto', options.model, fromEnvFile));
@@ -76,6 +84,7 @@ export const runDoctor = async (argv, fromEnvFile) => {
76
84
  findings.push(await liveCheck(createLiveAsk(profile), profile.model, [process.env.TYPESAFE_API_KEY, process.env.OPENROUTER_API_KEY]));
77
85
  }
78
86
  // Written before the caller exits: on macOS a piped stdout is asynchronous.
79
- await new Promise((resolve) => process.stdout.write(options.plan ? planSteps(findings) : render(findings), resolve));
87
+ const output = options.json ? `${JSON.stringify(doctorJson(findings), null, 2)}\n` : options.plan ? planSteps(findings) : render(findings);
88
+ await new Promise((resolve) => process.stdout.write(output, resolve));
80
89
  return exitCode(findings);
81
90
  };
@@ -45,8 +45,8 @@ const debuggingUrl = (source) => {
45
45
  return undefined;
46
46
  }
47
47
  };
48
- // Probes over plain HTTP only: opening the DevTools WebSocket would make the user's Chrome ask to allow it.
49
- const browserFinding = async ({ browser, findChrome, devToolsActivePort, answers }) => {
48
+ /** The browser source's state, probed over plain HTTP only: the DevTools WebSocket would make Chrome ask to allow it. */
49
+ export const browserFinding = async ({ browser, findChrome, devToolsActivePort, answers }) => {
50
50
  if (browser === 'launch' || browser === 'launch-headless') {
51
51
  const chrome = findChrome();
52
52
  return chrome
@@ -138,6 +138,21 @@ export const planSteps = (findings) => {
138
138
  ].map(({ level, fix }, index) => `${index + 1}. ${level === 'warn' ? '(optional) ' : ''}${fix}`);
139
139
  return steps.length ? `Steps left:\n${steps.join('\n')}\n` : 'Nothing left to set up: jevway is ready.\n';
140
140
  };
141
+ /**
142
+ * `--json`: the findings for agents, each named by a stable `check` slug, and `ready` when nothing blocks.
143
+ *
144
+ * @example
145
+ * doctorJson(findings) // { ready: false, findings: [{ check: 'browser', level: 'fail', message: '…', fix: '…' }] }
146
+ */
147
+ export const doctorJson = (findings) => ({
148
+ ready: exitCode(findings) === 0,
149
+ findings: findings.map(({ area, level, message, fix }) => ({
150
+ check: area.toLowerCase().replaceAll(' ', '_'),
151
+ level,
152
+ message,
153
+ ...(fix && level !== 'ok' && { fix }),
154
+ })),
155
+ });
141
156
  /**
142
157
  * `--live`: one real request (the one-Choice evidence question, the cheapest the model is asked) to prove the key,
143
158
  * the route and the network. An error message has every key value replaced by `[secret]` before it is shown.
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { parseEnv } from 'node:util';
5
+ import { writeFileAtomic } from './atomic-write.js';
5
6
  // The keys and jevway's own settings; anything else (NODE_OPTIONS, PATH) could change how Node itself runs.
6
7
  const isJevwaySetting = (name) => ['TYPESAFE_API_KEY', 'OPENROUTER_API_KEY', 'CHROME_PATH'].includes(name) || name.startsWith('JEVWAY_');
7
8
  /**
@@ -48,3 +49,24 @@ export const missingKeyMessage = ({ name, apiKeyEnv }, env = process.env) => {
48
49
  ? `No API key: set TYPESAFE_API_KEY (Jev on the TypeSafe API) or OPENROUTER_API_KEY ${where}`
49
50
  : `No ${apiKeyEnv}: ${name} needs it. Set it ${where}`;
50
51
  };
52
+ /**
53
+ * `text` with `name` set to `value`: the first `NAME=` line (or `export NAME=`) is replaced and later ones dropped, a
54
+ * missing name is appended, and `value` undefined removes the line. Every other line is kept as it was.
55
+ *
56
+ * @example
57
+ * setEnvValue('# mine\nTYPESAFE_API_KEY=old\n', 'TYPESAFE_API_KEY', 'new') // '# mine\nTYPESAFE_API_KEY=new\n'
58
+ */
59
+ export const setEnvValue = (text, name, value) => {
60
+ const assigns = new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=`);
61
+ const lines = text === '' ? [] : text.replace(/\n$/, '').split('\n');
62
+ const first = lines.findIndex((line) => assigns.test(line));
63
+ const kept = lines.flatMap((line, index) => {
64
+ if (!assigns.test(line))
65
+ return [line];
66
+ return index === first && value !== undefined ? [`${name}=${value}`] : [];
67
+ });
68
+ const next = first === -1 && value !== undefined ? [...kept, `${name}=${value}`] : kept;
69
+ return next.length ? `${next.join('\n')}\n` : '';
70
+ };
71
+ /** Writes the env file atomically with mode 600, creating its folder; a file other users could read becomes 600. */
72
+ export const writeEnvFile = (path, text) => writeFileAtomic(path, text, 0o600);
@@ -0,0 +1,104 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { homedir } from 'node:os';
3
+ import { parseArgs } from 'node:util';
4
+ import { createLiveAsk, resolveModel } from '../jev/model.js';
5
+ import { CLIENT_NAMES, onPathIn, printedRegistration, renderPrinted } from './clients.js';
6
+ import { print, printError, printJson } from './command-output.js';
7
+ import { realProbes } from './doctor-command.js';
8
+ import { browserFinding, diagnose, exitCode, liveCheck, render } from './doctor.js';
9
+ import { envFilePath, shownEnvFilePath } from './env-file.js';
10
+ import { setupWizard } from './setup-wizard.js';
11
+ import { bundledSkillDir } from './skill-command.js';
12
+ import { terminalPrompter } from './terminal-prompter.js';
13
+ const CLIENT_LIST = CLIENT_NAMES.join('|');
14
+ export const SETUP_USAGE = `Examples:
15
+ jevway setup
16
+ jevway setup --client codex --print
17
+ jevway setup --client cursor --print --json
18
+
19
+ jevway setup interactive: key, browser, MCP clients (needs a terminal)
20
+ jevway setup --client <${CLIENT_LIST}> --print [--json]
21
+ print how to register jevway with that client; changes nothing
22
+
23
+ Examples:
24
+ jevway setup
25
+ jevway setup --client cursor --print
26
+ jevway setup --client codex --print --json # { client, kind, command?, file?, snippet }`;
27
+ const realMachine = () => ({ home: homedir(), env: process.env, onPath: onPathIn() });
28
+ const isClientName = (name) => CLIENT_NAMES.includes(name);
29
+ const liveFinding = async () => {
30
+ try {
31
+ const { profile } = resolveModel({ live: true });
32
+ return await liveCheck(createLiveAsk(profile), profile.model, [process.env.TYPESAFE_API_KEY, process.env.OPENROUTER_API_KEY]);
33
+ }
34
+ catch (error) {
35
+ return { level: 'fail', area: 'Live', message: error.message, fix: 'Run `jevway doctor` for the details' };
36
+ }
37
+ };
38
+ const runCommand = (argv) => new Promise((resolve) => execFile(argv[0], argv.slice(1), { timeout: 60_000 }, (error, stdout, stderr) => resolve({ ok: !error, output: `${stdout}${stderr}`.trim() || (error?.message ?? '') })));
39
+ const realDeps = (fromEnvFile) => ({
40
+ machine: realMachine(),
41
+ envFile: envFilePath(),
42
+ fromEnvFile,
43
+ checkBrowser: (source) => browserFinding(realProbes(source, undefined, fromEnvFile)),
44
+ checkLive: liveFinding,
45
+ run: runCommand,
46
+ doctor: async () => {
47
+ const findings = await diagnose(realProbes(process.env.JEVWAY_BROWSER?.trim() || 'auto', undefined, fromEnvFile));
48
+ return { report: render(findings), ready: exitCode(findings) === 0 };
49
+ },
50
+ skillSource: bundledSkillDir(),
51
+ });
52
+ /** The env-file steps an agent hands to a person, who types the key in their own editor. */
53
+ const envFileSteps = () => {
54
+ const path = shownEnvFilePath(envFilePath());
55
+ const folder = path.replace(/\/env$/, '');
56
+ return `mkdir -p ${folder} && touch ${path} && chmod 600 ${path}, then the person adds TYPESAFE_API_KEY=<key> in their own editor`;
57
+ };
58
+ /**
59
+ * `jevway setup`: with `--client <name> --print`, prints how to register jevway with that client and changes nothing;
60
+ * otherwise the interactive wizard, which needs a terminal and exits 2 without one.
61
+ *
62
+ * @example
63
+ * process.exit(await runSetup(process.argv.slice(3), loadEnvFile()));
64
+ */
65
+ export const runSetup = async (argv, fromEnvFile = []) => {
66
+ let options;
67
+ try {
68
+ ({ values: options } = parseArgs({
69
+ args: argv,
70
+ options: {
71
+ client: { type: 'string' },
72
+ print: { type: 'boolean' },
73
+ json: { type: 'boolean' },
74
+ help: { type: 'boolean', short: 'h' },
75
+ },
76
+ }));
77
+ }
78
+ catch (error) {
79
+ return printError(2, 'usage', `jevway setup: ${error.message}`, `jevway setup --client <${CLIENT_LIST}> --print [--json]`);
80
+ }
81
+ if (options.help) {
82
+ await print(`${SETUP_USAGE}\n`);
83
+ return 0;
84
+ }
85
+ if (options.print || options.client !== undefined || options.json) {
86
+ if (!options.print || !options.client || !isClientName(options.client)) {
87
+ const problem = !options.print ? '--client and --json go with --print' : options.client ? `unknown client "${options.client}"` : '--print needs --client';
88
+ return printError(2, 'usage', `jevway setup: ${problem}`, `jevway setup --client <${CLIENT_LIST}> --print [--json]`);
89
+ }
90
+ const printed = printedRegistration(options.client, realMachine());
91
+ await (options.json ? printJson(printed) : print(renderPrinted(printed)));
92
+ return 0;
93
+ }
94
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
95
+ return printError(2, 'needs_tty', 'jevway setup asks questions and needs a terminal. An agent registers jevway with `jevway setup --client <name> --print` (add --json for the fields), which changes nothing.', `Key: ${envFileSteps()}. Client: jevway setup --client <${CLIENT_LIST}> --print. Check: jevway doctor --json.`);
96
+ }
97
+ const terminal = terminalPrompter();
98
+ try {
99
+ return await setupWizard(terminal.io, realDeps(fromEnvFile));
100
+ }
101
+ finally {
102
+ terminal.close();
103
+ }
104
+ };
@@ -0,0 +1,216 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parseEnv } from 'node:util';
4
+ import { writeFileAtomic } from './atomic-write.js';
5
+ import { detectClients, plannedChange, shownPath } from './clients.js';
6
+ import { setEnvValue, writeEnvFile } from './env-file.js';
7
+ import { installSkill, skillState } from './skill-command.js';
8
+ const KEY_NAMES = ['TYPESAFE_API_KEY', 'OPENROUTER_API_KEY'];
9
+ const KEY_ROUTES = [
10
+ { value: 'TYPESAFE_API_KEY', label: 'TypeSafe (recommended): Jev on the TypeSafe API, with screenshots and Box' },
11
+ { value: 'OPENROUTER_API_KEY', label: 'OpenRouter: Jev without Box, and the other models' },
12
+ ];
13
+ const BROWSERS = [
14
+ { value: 'auto', label: `${'auto'.padEnd(16)}your running Chrome: allow it once at chrome://inspect/#remote-debugging; you see every step` },
15
+ { value: 'launch', label: `${'launch'.padEnd(16)}a throwaway Chrome profile jevway starts and closes, signed in nowhere` },
16
+ { value: 'launch-headless', label: `${'launch-headless'.padEnd(16)}the same, without a window` },
17
+ { value: 'port', label: `${'port'.padEnd(16)}a Chrome you started with --remote-debugging-port=<port> and its own --user-data-dir` },
18
+ ];
19
+ const TRY_ON_CLI = `jevway --url https://en.wikipedia.org/wiki/Main_Page --goal "Open the Wikipedia article about the Rosetta Stone" --values '{"search_query":"Rosetta Stone"}'`;
20
+ const confirm = async (io, question, fallback = false) => {
21
+ const reply = (await io.ask(`${question} ${fallback ? '[Y/n]' : '[y/N]'} `)).trim().toLowerCase();
22
+ return reply ? /^y(es)?$/.test(reply) : fallback;
23
+ };
24
+ const choose = async (io, question, options, fallback = 0) => {
25
+ options.forEach(({ label }, index) => io.say(` ${index + 1}. ${label}`));
26
+ for (let attempt = 0; attempt < 3; attempt++) {
27
+ const reply = (await io.ask(`${question} [${fallback + 1}] `)).trim();
28
+ if (!reply)
29
+ break;
30
+ const chosen = options[Number(reply) - 1] ?? options.find(({ value }) => value === reply);
31
+ if (chosen)
32
+ return chosen.value;
33
+ io.say(`Type a number from 1 to ${options.length}.`);
34
+ }
35
+ return options[fallback].value;
36
+ };
37
+ const formatFinding = ({ level, area, message, fix }) => `${level === 'fail' ? 'FAIL' : level} ${area}: ${message}${level !== 'ok' && fix ? `\n Fix: ${fix}` : ''}`;
38
+ const readEnvText = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : '');
39
+ /**
40
+ * Why `key` cannot be an API key (too short, spaces, quotes or `#`), or undefined when its shape is plausible.
41
+ * The message never repeats the key.
42
+ */
43
+ export const keyShapeProblem = (key) => {
44
+ if (key.length < 16)
45
+ return 'That is too short for an API key. Paste the whole key.';
46
+ if (/[^\x21-\x7e]|["'`#\\]/.test(key))
47
+ return 'An API key has no spaces, quotes or #. Paste just the key.';
48
+ return undefined;
49
+ };
50
+ const keyStep = async (io, { machine, envFile, fromEnvFile, checkLive }) => {
51
+ const shownFile = shownPath(envFile, machine.home);
52
+ io.say('\n1/3 The API key');
53
+ const existing = KEY_NAMES.find((name) => machine.env[name]?.trim());
54
+ if (existing) {
55
+ io.say(`${existing} is already set (from ${fromEnvFile.includes(existing) ? shownFile : 'your environment'}).`);
56
+ if (!(await confirm(io, 'Replace it?')))
57
+ return;
58
+ }
59
+ const name = await choose(io, 'Which route?', KEY_ROUTES);
60
+ let key;
61
+ for (let attempt = 0; attempt < 3 && !key; attempt++) {
62
+ const typed = (await io.askHidden(`Paste your ${name} (hidden as you type), then Enter: `)).trim();
63
+ const problem = keyShapeProblem(typed);
64
+ if (problem)
65
+ io.say(problem);
66
+ else
67
+ key = typed;
68
+ }
69
+ if (!key) {
70
+ io.say(`No key saved. Run jevway setup again, or add ${name}=<key> to ${shownFile} in your own editor.`);
71
+ return;
72
+ }
73
+ if (name === 'OPENROUTER_API_KEY' && !key.startsWith('sk-or-') && !(await confirm(io, 'That does not look like an OpenRouter key (they start sk-or-). Save it anyway?'))) {
74
+ io.say('No key saved.');
75
+ return;
76
+ }
77
+ writeEnvFile(envFile, setEnvValue(readEnvText(envFile), name, key));
78
+ io.say(`Saved ${name} to ${shownFile} (only you can read it; its other lines are kept).`);
79
+ if (machine.env[name]?.trim() && !fromEnvFile.includes(name)) {
80
+ io.say(`${name} is also set in your environment, which wins over the file: remove it there to use the new key.`);
81
+ }
82
+ else {
83
+ machine.env[name] = key;
84
+ if (!fromEnvFile.includes(name))
85
+ fromEnvFile.push(name);
86
+ }
87
+ if (await confirm(io, 'Check the key with one real request now? It costs a fraction of a cent.'))
88
+ io.say(formatFinding(await checkLive()));
89
+ };
90
+ const askPort = async (io) => {
91
+ for (let attempt = 0; attempt < 3; attempt++) {
92
+ const reply = (await io.ask('Port Chrome listens on [9222] ')).trim() || '9222';
93
+ if (/^\d+$/.test(reply) && Number(reply) > 0 && Number(reply) < 65536)
94
+ return `http://127.0.0.1:${reply}`;
95
+ io.say('Type a port number, such as 9222.');
96
+ }
97
+ return 'http://127.0.0.1:9222';
98
+ };
99
+ const browserStep = async (io, { machine, envFile, fromEnvFile, checkBrowser }) => {
100
+ const shownFile = shownPath(envFile, machine.home);
101
+ io.say('\n2/3 The browser jevway drives');
102
+ const current = machine.env.JEVWAY_BROWSER?.trim() || 'auto';
103
+ const currentIndex = BROWSERS.findIndex(({ value }) => value === (current.includes('://') ? 'port' : current));
104
+ let source;
105
+ for (;;) {
106
+ const choice = await choose(io, 'Which browser?', BROWSERS, Math.max(0, currentIndex));
107
+ source = choice === 'port' ? await askPort(io) : choice;
108
+ const finding = await checkBrowser(source);
109
+ io.say(formatFinding(finding));
110
+ if (finding.level !== 'fail' || !(await confirm(io, 'Choose another? (No keeps this one; fix it later.)')))
111
+ break;
112
+ }
113
+ const text = readEnvText(envFile);
114
+ const inFile = parseEnv(text).JEVWAY_BROWSER;
115
+ if (source === 'auto' && inFile !== undefined) {
116
+ writeEnvFile(envFile, setEnvValue(text, 'JEVWAY_BROWSER', undefined));
117
+ io.say(`Removed JEVWAY_BROWSER=${inFile} from ${shownFile}: auto is the default.`);
118
+ }
119
+ else if (source !== 'auto' && inFile !== source) {
120
+ writeEnvFile(envFile, setEnvValue(text, 'JEVWAY_BROWSER', source));
121
+ io.say(`Saved JEVWAY_BROWSER=${source} to ${shownFile}.`);
122
+ }
123
+ const fromShell = machine.env.JEVWAY_BROWSER?.trim() && !fromEnvFile.includes('JEVWAY_BROWSER');
124
+ if (fromShell && machine.env.JEVWAY_BROWSER !== source) {
125
+ io.say(`JEVWAY_BROWSER=${machine.env.JEVWAY_BROWSER} is set in your environment, which wins over the file.`);
126
+ }
127
+ else if (source === 'auto') {
128
+ delete machine.env.JEVWAY_BROWSER;
129
+ }
130
+ else {
131
+ machine.env.JEVWAY_BROWSER = source;
132
+ }
133
+ };
134
+ // Registers jevway with one client after showing exactly what changes; true when it is registered.
135
+ const register = async (io, { machine, run }, client) => {
136
+ const change = plannedChange(client, machine);
137
+ if (change.kind === 'manual') {
138
+ io.say(`${client.label}: ${change.reason}. Add this to ${change.shownFile} yourself:\n${change.snippet}`);
139
+ return false;
140
+ }
141
+ if (change.kind === 'command') {
142
+ io.say(`${client.label}: setup will run\n ${change.command}`);
143
+ if (!(await confirm(io, 'Run it?')))
144
+ return false;
145
+ const result = await run(change.argv);
146
+ io.say(result.ok ? `${client.label}: registered.` : `${client.label}: the command failed:\n${result.output.trim()}`);
147
+ return result.ok;
148
+ }
149
+ io.say(`${client.label}: setup will change ${change.shownFile}:\n${change.diff}`);
150
+ if (!(await confirm(io, `Write this change to ${change.shownFile}?`)))
151
+ return false;
152
+ if (readEnvText(change.file) !== (change.before ?? '')) {
153
+ io.say(`${client.label}: ${change.shownFile} changed meanwhile; nothing written. Run jevway setup again.`);
154
+ return false;
155
+ }
156
+ writeFileAtomic(change.file, change.next, change.before === undefined ? 0o644 : statSync(change.file).mode & 0o777);
157
+ io.say(`${client.label}: registered in ${change.shownFile}.`);
158
+ return true;
159
+ };
160
+ const offerSkill = async (io, { machine, skillSource }, client) => {
161
+ if (client.hasSkillBundled?.(machine))
162
+ return;
163
+ const folder = client.skillsDir(machine);
164
+ const shown = shownPath(join(folder, 'jevway'), machine.home);
165
+ const state = skillState(skillSource, folder);
166
+ if (state === 'same')
167
+ return io.say(`${client.label}: the jevway skill is already in ${shown}.`);
168
+ const question = state === 'different'
169
+ ? `Replace the jevway skill in ${shown} with this version's?`
170
+ : `Copy the jevway skill to ${shown}? It tells the agent when to hand a browser goal to jevway.`;
171
+ if (!(await confirm(io, question)))
172
+ return;
173
+ installSkill(skillSource, folder, { force: true });
174
+ io.say(`${client.label}: skill copied to ${shown}.`);
175
+ };
176
+ const clientsStep = async (io, deps) => {
177
+ io.say('\n3/3 Your MCP clients');
178
+ const found = detectClients(deps.machine);
179
+ if (!found.length) {
180
+ io.say('No Claude Code, Codex, Cursor or OpenCode found. For another MCP client: jevway setup --client other --print');
181
+ return [];
182
+ }
183
+ const registered = [];
184
+ for (const { client, configured } of found) {
185
+ if (configured)
186
+ io.say(`${client.label}: jevway is already registered; skipped.`);
187
+ if (configured || (await register(io, deps, client)))
188
+ registered.push(client.label);
189
+ await offerSkill(io, deps, client);
190
+ }
191
+ return registered;
192
+ };
193
+ /**
194
+ * The interactive setup: the key (hidden, into the env file only), the browser (tested before it is saved), and each
195
+ * MCP client found (every change shown, made only on a yes), then the doctor's report and what to try first. Returns
196
+ * 0 when the doctor finds jevway ready, 1 otherwise.
197
+ *
198
+ * @example
199
+ * process.exitCode = await setupWizard(terminal.io, realDeps);
200
+ */
201
+ export const setupWizard = async (io, deps) => {
202
+ io.say(`jevway setup: the API key, the browser, then your MCP clients. Ctrl-C stops at any point; every file is either left as it was or written whole.`);
203
+ await keyStep(io, deps);
204
+ await browserStep(io, deps);
205
+ const registered = await clientsStep(io, deps);
206
+ io.say('\nChecking the setup (jevway doctor):');
207
+ const { report, ready } = await deps.doctor();
208
+ io.say(report.trimEnd());
209
+ if (!ready)
210
+ io.say('Try first: fix the FAIL lines above (`jevway doctor --plan` lists them), then run `jevway doctor` again.');
211
+ else if (registered.length)
212
+ io.say(`Try first: restart ${registered.join(' or ')} and ask it to "use jevway to open the Wikipedia article about the Rosetta Stone".`);
213
+ else
214
+ io.say(`Try first: ${TRY_ON_CLI}`);
215
+ return ready ? 0 : 1;
216
+ };
@@ -0,0 +1,124 @@
1
+ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { parseArgs } from 'node:util';
5
+ import { print, printError, printJson } from './command-output.js';
6
+ export const SKILL_USAGE = `Examples:
7
+ jevway skill
8
+ jevway skill --install ~/.claude/skills
9
+
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
+
13
+ Examples:
14
+ jevway skill --install ~/.claude/skills
15
+ jevway skill --install ~/.codex/skills
16
+
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');
26
+ /** Every file under `dir`, as sorted paths relative to it. */
27
+ const filesIn = (dir) => readdirSync(dir, { recursive: true, withFileTypes: true })
28
+ .filter((entry) => entry.isFile())
29
+ .map((entry) => relative(dir, join(entry.parentPath, entry.name)))
30
+ .sort();
31
+ /** True when `target` holds exactly the files of `source`, byte for byte (following a symlinked `target`). */
32
+ const sameFolder = (source, target) => {
33
+ try {
34
+ const files = filesIn(source);
35
+ return (files.join('\n') === filesIn(target).join('\n') &&
36
+ files.every((file) => readFileSync(join(source, file)).equals(readFileSync(join(target, file)))));
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ };
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);
45
+ if (!existsSync(path) && !lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink())
46
+ return 'missing';
47
+ return sameFolder(source, path) ? 'same' : 'different';
48
+ };
49
+ /**
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.
53
+ *
54
+ * @example
55
+ * installSkill(bundledSkillDir(), '/Users/ada/.claude/skills', { force: false })
56
+ * // { status: 'installed', path: '/Users/ada/.claude/skills/jevway', files: ['SKILL.md'] }
57
+ */
58
+ export const installSkill = (source, parent, { force, name = 'jevway' }) => {
59
+ const path = join(parent, name);
60
+ const files = filesIn(source);
61
+ const state = skillState(source, parent, name);
62
+ if (state === 'same')
63
+ return { status: 'unchanged', path, files };
64
+ if (state === 'different' && !force)
65
+ return { status: 'exists', path };
66
+ const present = state === 'different';
67
+ mkdirSync(parent, { recursive: true });
68
+ const incoming = join(parent, `.${name}-${process.pid}.incoming`);
69
+ cpSync(source, incoming, { recursive: true });
70
+ if (present)
71
+ rmSync(path, { recursive: true, force: true });
72
+ renameSync(incoming, path);
73
+ return { status: present ? 'replaced' : 'installed', path, files };
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
+ };
88
+ /**
89
+ * `jevway skill`: prints the bundled SKILL.md, or with `--install <dir>` copies the skill folder into `<dir>/jevway`.
90
+ *
91
+ * @example
92
+ * process.exit(await runSkill(process.argv.slice(3)));
93
+ */
94
+ export const runSkill = async (argv) => {
95
+ let options;
96
+ try {
97
+ ({ values: options } = parseArgs({
98
+ args: argv,
99
+ options: { install: { type: 'string' }, name: { type: 'string' }, force: { type: 'boolean' }, help: { type: 'boolean', short: 'h' } },
100
+ }));
101
+ }
102
+ catch (error) {
103
+ return printError(2, 'usage', `jevway skill: ${error.message}`, 'jevway skill --install <dir> [--force]');
104
+ }
105
+ if (options.help) {
106
+ await print(`${SKILL_USAGE}\n`);
107
+ return 0;
108
+ }
109
+ if (options.install === undefined) {
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'));
115
+ return 0;
116
+ }
117
+ const result = installSkills(options.install, { force: Boolean(options.force) });
118
+ if (result.status === 'exists') {
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`);
120
+ }
121
+ const [{ name: _name, ...jevway }] = result.skills;
122
+ await printJson({ ok: true, ...jevway, skills: result.skills });
123
+ return 0;
124
+ };
@@ -0,0 +1,51 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { Writable } from 'node:stream';
3
+ /**
4
+ * The real terminal for setup. Hidden answers are read through readline with its echo sent to a muted stream, and
5
+ * readline keeps no history, so a key is never drawn on screen nor kept after the question. Ctrl-C (or Ctrl-D) exits
6
+ * 130 at once: setup writes every file whole, so nothing is left half-written.
7
+ */
8
+ export const terminalPrompter = () => {
9
+ let muted = false;
10
+ let closing = false;
11
+ const echo = new Writable({
12
+ write(chunk, encoding, done) {
13
+ if (!muted)
14
+ process.stdout.write(chunk, encoding);
15
+ done();
16
+ },
17
+ });
18
+ Object.defineProperty(echo, 'columns', { get: () => process.stdout.columns });
19
+ const rl = createInterface({ input: process.stdin, output: echo, terminal: true, historySize: 0 });
20
+ const stop = () => {
21
+ if (closing)
22
+ return;
23
+ muted = false;
24
+ process.stdout.write('\nStopped. Every file is as it was before, or written whole.\n');
25
+ process.exit(130);
26
+ };
27
+ rl.on('SIGINT', stop);
28
+ rl.on('close', stop);
29
+ const io = {
30
+ say: (text) => void process.stdout.write(`${text}\n`),
31
+ ask: (question) => rl.question(question),
32
+ askHidden: async (question) => {
33
+ process.stdout.write(question);
34
+ muted = true;
35
+ try {
36
+ return await rl.question('');
37
+ }
38
+ finally {
39
+ muted = false;
40
+ process.stdout.write('\n');
41
+ }
42
+ },
43
+ };
44
+ return {
45
+ io,
46
+ close: () => {
47
+ closing = true;
48
+ rl.close();
49
+ },
50
+ };
51
+ };