jevway 0.1.0 → 0.2.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 (52) hide show
  1. package/INSTALL-FOR-AGENTS.md +32 -38
  2. package/README.md +193 -150
  3. package/dist/cli/daemon-call.js +78 -0
  4. package/dist/cli/dispatch.js +74 -0
  5. package/dist/cli/flags.js +152 -0
  6. package/dist/cli/help.js +46 -0
  7. package/dist/cli/journey-command.js +162 -0
  8. package/dist/cli/look-command.js +47 -0
  9. package/dist/cli/main.js +64 -0
  10. package/dist/cli/output.js +27 -0
  11. package/dist/cli/run-command.js +173 -0
  12. package/dist/cli/session-commands.js +132 -0
  13. package/dist/daemon/client.js +175 -0
  14. package/dist/daemon/paths.js +35 -0
  15. package/dist/daemon/protocol.js +28 -0
  16. package/dist/daemon/server.js +320 -0
  17. package/dist/jev/replay.js +7 -3
  18. package/dist/jev/retry.js +29 -0
  19. package/dist/journeys/journey-doc.js +2 -1
  20. package/dist/journeys/journey-session.js +14 -3
  21. package/dist/journeys/render-viewer.js +163 -150
  22. package/dist/mcp/instructions.js +18 -0
  23. package/dist/mcp/journey-tools.js +117 -0
  24. package/dist/mcp/results.js +53 -0
  25. package/dist/mcp/server.js +50 -0
  26. package/dist/mcp/task-tools.js +197 -0
  27. package/dist/mcp.js +12 -337
  28. package/dist/output/layout.js +50 -0
  29. package/dist/output/manifest.js +80 -0
  30. package/dist/output/run-folder.js +38 -0
  31. package/dist/recording/describe-step.js +66 -0
  32. package/dist/{task → recording}/recording-page.js +164 -18
  33. package/dist/{task → recording}/recording.js +33 -91
  34. package/dist/recording/run-page.js +155 -0
  35. package/dist/session/session-service.js +193 -0
  36. package/dist/setup/atomic-write.js +13 -0
  37. package/dist/setup/clients.js +180 -0
  38. package/dist/setup/command-output.js +15 -0
  39. package/dist/setup/doctor-command.js +16 -7
  40. package/dist/setup/doctor.js +17 -2
  41. package/dist/setup/env-file.js +22 -0
  42. package/dist/setup/setup-command.js +104 -0
  43. package/dist/setup/setup-wizard.js +216 -0
  44. package/dist/setup/skill-command.js +101 -0
  45. package/dist/setup/terminal-prompter.js +51 -0
  46. package/dist/setup/unified-diff.js +69 -0
  47. package/dist/task/present.js +90 -0
  48. package/dist/task/run-task.js +74 -11
  49. package/dist/task/start-task.js +21 -5
  50. package/package.json +12 -7
  51. package/skill/jevway/SKILL.md +50 -14
  52. package/dist/cli.js +0 -112
@@ -0,0 +1,180 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { delimiter, join } from 'node:path';
3
+ import { envFilePath, shownEnvFilePath } from './env-file.js';
4
+ import { unifiedDiff } from './unified-diff.js';
5
+ const SERVER = { command: 'npx', args: ['-y', '-p', 'jevway', 'jevway-mcp'] };
6
+ const xdgConfig = ({ home, env }) => env.XDG_CONFIG_HOME?.trim() || join(home, '.config');
7
+ const claudeFolder = ({ home, env }) => env.CLAUDE_CONFIG_DIR?.trim() || join(home, '.claude');
8
+ const claudeJson = (machine) => machine.env.CLAUDE_CONFIG_DIR?.trim() ? join(machine.env.CLAUDE_CONFIG_DIR.trim(), '.claude.json') : join(machine.home, '.claude.json');
9
+ const codexFolder = ({ home, env }) => env.CODEX_HOME?.trim() || join(home, '.codex');
10
+ const openCodeFolder = (machine) => join(xdgConfig(machine), 'opencode');
11
+ const readText = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : undefined);
12
+ const readJson = (path) => {
13
+ try {
14
+ const value = JSON.parse(readText(path) ?? '');
15
+ return value && typeof value === 'object' ? value : undefined;
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ };
21
+ // A file that is not plain JSON (JSONC with comments) still counts when it names a jevway key.
22
+ const namesJevway = (path, container) => {
23
+ const parsed = readJson(path);
24
+ if (parsed)
25
+ return Boolean(parsed[container]?.jevway);
26
+ return /"jevway"\s*:/.test(readText(path) ?? '');
27
+ };
28
+ const hasClaudePlugin = (machine) => Object.keys(readJson(join(claudeFolder(machine), 'plugins', 'installed_plugins.json'))?.plugins ?? {}).some((id) => id.startsWith('jevway@'));
29
+ const openCodeFile = (machine) => {
30
+ const json = join(openCodeFolder(machine), 'opencode.json');
31
+ const jsonc = join(openCodeFolder(machine), 'opencode.jsonc');
32
+ return !existsSync(json) && existsSync(jsonc) ? jsonc : json;
33
+ };
34
+ /** The clients setup registers jevway with, in the order it offers them. */
35
+ export const CLIENTS = [
36
+ {
37
+ name: 'claude-code',
38
+ label: 'Claude Code',
39
+ binary: 'claude',
40
+ markers: (machine) => [claudeFolder(machine), claudeJson(machine)],
41
+ skillsDir: (machine) => join(claudeFolder(machine), 'skills'),
42
+ hasSkillBundled: hasClaudePlugin,
43
+ isConfigured: (machine) => Boolean(readJson(claudeJson(machine))?.mcpServers?.jevway) || hasClaudePlugin(machine),
44
+ registration: { kind: 'command', argv: ['claude', 'mcp', 'add', 'jevway', '-s', 'user', '--', SERVER.command, ...SERVER.args] },
45
+ },
46
+ {
47
+ name: 'codex',
48
+ label: 'Codex',
49
+ binary: 'codex',
50
+ markers: (machine) => [codexFolder(machine)],
51
+ skillsDir: (machine) => join(codexFolder(machine), 'skills'),
52
+ isConfigured: (machine) => /^\s*\[mcp_servers\.(?:jevway|"jevway")\]/m.test(readText(join(codexFolder(machine), 'config.toml')) ?? ''),
53
+ registration: {
54
+ kind: 'command',
55
+ argv: ['codex', 'mcp', 'add', 'jevway', '--', SERVER.command, ...SERVER.args],
56
+ writes: {
57
+ file: (machine) => join(codexFolder(machine), 'config.toml'),
58
+ snippet: `[mcp_servers.jevway]\ncommand = "${SERVER.command}"\nargs = [${SERVER.args.map((arg) => `"${arg}"`).join(', ')}]\n`,
59
+ },
60
+ },
61
+ },
62
+ {
63
+ name: 'cursor',
64
+ label: 'Cursor',
65
+ binary: 'cursor',
66
+ markers: ({ home }) => [join(home, '.cursor')],
67
+ skillsDir: ({ home }) => join(home, '.cursor', 'skills'),
68
+ isConfigured: ({ home }) => namesJevway(join(home, '.cursor', 'mcp.json'), 'mcpServers'),
69
+ registration: { kind: 'config', file: ({ home }) => join(home, '.cursor', 'mcp.json'), container: 'mcpServers', entry: SERVER },
70
+ },
71
+ {
72
+ name: 'opencode',
73
+ label: 'OpenCode',
74
+ binary: 'opencode',
75
+ markers: (machine) => [openCodeFolder(machine)],
76
+ skillsDir: (machine) => join(openCodeFolder(machine), 'skills'),
77
+ isConfigured: (machine) => namesJevway(openCodeFile(machine), 'mcp'),
78
+ registration: {
79
+ kind: 'config',
80
+ file: openCodeFile,
81
+ container: 'mcp',
82
+ entry: { type: 'local', command: [SERVER.command, ...SERVER.args], enabled: true },
83
+ newFile: { $schema: 'https://opencode.ai/config.json' },
84
+ },
85
+ },
86
+ ];
87
+ /** Names `--client` accepts: the known clients, and `other` for any stdio MCP client. */
88
+ export const CLIENT_NAMES = [...CLIENTS.map(({ name }) => name), 'other'];
89
+ /**
90
+ * Whether `binary` is an executable file in one of `PATH`'s folders. Scans the folders itself, never a shell, so a
91
+ * shell function or alias of the same name does not count.
92
+ */
93
+ export const onPathIn = (path = process.env.PATH ?? '') => (binary) => path
94
+ .split(delimiter)
95
+ .filter(Boolean)
96
+ .some((folder) => (process.platform === 'win32' ? ['.exe', '.cmd', ''] : ['']).some((extension) => existsSync(join(folder, binary + extension))));
97
+ /** `path` with the home folder written as `~`, for messages. */
98
+ export const shownPath = (path, home) => (path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path);
99
+ /**
100
+ * The clients installed on this machine (command on PATH or config present), and whether each already has a
101
+ * jevway server.
102
+ */
103
+ export const detectClients = (machine) => CLIENTS.filter((client) => machine.onPath(client.binary) || client.markers(machine).some(existsSync)).map((client) => ({
104
+ client,
105
+ configured: client.isConfigured(machine),
106
+ }));
107
+ const indentOf = (text) => /^([ \t]+)\S/m.exec(text)?.[1] ?? ' ';
108
+ const snippetFor = ({ container, entry }) => JSON.stringify({ [container]: { jevway: entry } }, null, 2);
109
+ /**
110
+ * What setup would do to register jevway with `client`: run its `mcp add` command, or add only the `jevway` entry to
111
+ * its JSON config (the file's own indent kept, shown as a unified diff). A file that is not plain JSON, or whose server
112
+ * list is not an object, is never edited: the change is `manual`, with the snippet to add by hand.
113
+ *
114
+ * @example
115
+ * plannedChange(cursor, machine)
116
+ * // { kind: 'edit', file: '/Users/ada/.cursor/mcp.json', shownFile: '~/.cursor/mcp.json', next: '{…}', diff: '--- …' }
117
+ */
118
+ export const plannedChange = (client, machine) => {
119
+ const { registration } = client;
120
+ if (registration.kind === 'command')
121
+ return { kind: 'command', argv: registration.argv, command: registration.argv.join(' ') };
122
+ const file = registration.file(machine);
123
+ const shownFile = shownPath(file, machine.home);
124
+ const before = readText(file);
125
+ const manual = (reason) => ({ kind: 'manual', file, shownFile, reason, snippet: snippetFor(registration) });
126
+ let config;
127
+ try {
128
+ config = before?.trim() ? JSON.parse(before) : { ...registration.newFile };
129
+ }
130
+ catch {
131
+ return manual(`${shownFile} is not plain JSON (comments or trailing commas), so setup leaves it alone`);
132
+ }
133
+ if (!config || typeof config !== 'object' || Array.isArray(config))
134
+ return manual(`${shownFile} does not hold a JSON object`);
135
+ const servers = config[registration.container] ?? {};
136
+ if (typeof servers !== 'object' || Array.isArray(servers))
137
+ return manual(`"${registration.container}" in ${shownFile} is not an object`);
138
+ const next = `${JSON.stringify({ ...config, [registration.container]: { ...servers, jevway: registration.entry } }, null, before ? indentOf(before) : 2)}\n`;
139
+ return { kind: 'edit', file, shownFile, before, next, diff: unifiedDiff(before, next, shownFile) };
140
+ };
141
+ /**
142
+ * The exact command or config snippet that registers jevway with a client, and where it goes; for `other`, the
143
+ * server's command and args.
144
+ *
145
+ * @example
146
+ * printedRegistration('codex', machine)
147
+ * // { client: 'codex', kind: 'command', command: 'codex mcp add jevway -- npx …', file: '~/.codex/config.toml', snippet: '[mcp_servers.jevway]…' }
148
+ */
149
+ export const printedRegistration = (name, machine) => {
150
+ const client = CLIENTS.find((candidate) => candidate.name === name);
151
+ if (!client)
152
+ return { client: 'other', kind: 'config', snippet: JSON.stringify(SERVER, null, 2) };
153
+ const { registration } = client;
154
+ if (registration.kind === 'command') {
155
+ const command = registration.argv.join(' ');
156
+ return registration.writes
157
+ ? { client: client.name, kind: 'command', command, file: shownPath(registration.writes.file(machine), machine.home), snippet: registration.writes.snippet }
158
+ : { client: client.name, kind: 'command', command, snippet: command };
159
+ }
160
+ return { client: client.name, kind: 'config', file: shownPath(registration.file(machine), machine.home), snippet: snippetFor(registration) };
161
+ };
162
+ /**
163
+ * `printedRegistration` as text: a `#` line saying what to do and where, then the command or snippet.
164
+ *
165
+ * @example
166
+ * renderPrinted(printedRegistration('cursor', machine))
167
+ * // '# Cursor: merge into "mcpServers" in ~/.cursor/mcp.json (keep the other servers)\n{\n "mcpServers": …'
168
+ */
169
+ export const renderPrinted = ({ client, kind, command, file, snippet }, env = process.env) => {
170
+ const label = CLIENTS.find(({ name }) => name === client)?.label;
171
+ const keyNote = `# The key stays in ${shownEnvFilePath(envFilePath(env))}, which jevway reads itself; never put it in this config.`;
172
+ if (!label)
173
+ return `# Any stdio MCP client: add a server named "jevway" that runs\n${snippet}\n${keyNote}\n`;
174
+ if (kind === 'command') {
175
+ const alternative = file && snippet !== command ? `# or add this to ${file} yourself:\n${snippet}` : '';
176
+ return `# ${label}: run\n${command}\n${alternative}${keyNote}\n`;
177
+ }
178
+ const container = Object.keys(JSON.parse(snippet))[0];
179
+ return `# ${label}: merge into "${container}" in ${file} (keep the other servers)\n${snippet}\n${keyNote}\n`;
180
+ };
@@ -0,0 +1,15 @@
1
+ /** Writes `text` to stdout and waits until it is flushed: on macOS a piped stdout is asynchronous. */
2
+ export const print = (text) => new Promise((resolve) => process.stdout.write(text, () => resolve()));
3
+ /** Prints `value` as indented JSON on stdout. */
4
+ export const printJson = (value) => print(`${JSON.stringify(value, null, 2)}\n`);
5
+ /**
6
+ * Prints the structured error agents read (`{ ok: false, error: { code, message, hint } }`) on stdout and returns
7
+ * `exitCode`, so a command can `return printError(…)`.
8
+ *
9
+ * @example
10
+ * return printError(2, 'usage', 'jevway skill: --install needs a folder', 'jevway skill --install ~/.claude/skills');
11
+ */
12
+ export const printError = async (exitCode, code, message, hint) => {
13
+ await printJson({ ok: false, error: { code, message, hint } });
14
+ return exitCode;
15
+ };
@@ -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
+ };