webhands 0.0.0 → 0.1.7

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/src/errors.ts ADDED
@@ -0,0 +1,121 @@
1
+ import {
2
+ isControllerError,
3
+ MissingBrowserBinaryError,
4
+ MissingStealthDependencyError,
5
+ MissingProfileError,
6
+ AttachNotChromiumError,
7
+ AttachNoContextError,
8
+ NoLiveServerError,
9
+ SessionAlreadyActiveError,
10
+ type ControllerError,
11
+ type ControllerErrorCode,
12
+ } from '@webhands/core';
13
+
14
+ /**
15
+ * Map a TYPED `core` error condition into the user-facing message + the EXACT
16
+ * command to fix it (PRD story 17).
17
+ *
18
+ * `core` OWNS the typed conditions (a `ControllerError` with a stable `code`,
19
+ * raised by the transports — see `packages/core/src/errors.ts`); the CLI OWNS
20
+ * the user-facing message text. The CLI never re-DETECTS a missing binary or a
21
+ * missing profile (no second `stat`, no message-string match): it branches on
22
+ * the machine-readable `code` and composes the fix command from the structured
23
+ * context the error already carries (`browser`, `profile`, `profileDir`, ...).
24
+ *
25
+ * The result is fed to incur's `c.error({code, message, ...})` so the failure
26
+ * surfaces in the structured output envelope with the SAME `code` an agent can
27
+ * branch on, and a `message` whose final line is a copy-pasteable fix command.
28
+ */
29
+ export interface MappedError {
30
+ /** The machine-readable `core` error code, surfaced unchanged to the agent. */
31
+ readonly code: ControllerErrorCode;
32
+ /** The full user-facing message, ending in the exact fix command. */
33
+ readonly message: string;
34
+ }
35
+
36
+ /**
37
+ * Compose the EXACT fix command for a typed `core` error. Returned alongside
38
+ * the message so a test can assert the precise command, and so the wording
39
+ * lives in ONE place. `binary` is the CLI binary name (`incur` passes it to the
40
+ * handler as `c.name`), so the suggested command always matches how the user
41
+ * invoked the tool.
42
+ */
43
+ export function fixCommandFor(error: ControllerError, binary: string): string {
44
+ switch (error.code) {
45
+ case 'missing-browser-binary':
46
+ // Playwright ships its own browser binaries; `playwright install
47
+ // <browser>` is the documented way to download the missing one. We name
48
+ // the specific browser from the typed error rather than a generic hint.
49
+ return `npx playwright install ${(error as MissingBrowserBinaryError).browser}`;
50
+ case 'missing-stealth-dependency':
51
+ // Stealth launch was opted into but the OPTIONAL `patchright` dependency
52
+ // is absent. Name the package from the typed error so the install command
53
+ // is ready to run; we never silently fall back to vanilla Playwright.
54
+ return `pnpm add ${(error as MissingStealthDependencyError).dependency}`;
55
+ case 'missing-profile':
56
+ // A profile is created by the headed `setup-profile` flow (the ONE place
57
+ // a profile dir is created — see core's MissingProfileError). Name the
58
+ // profile so the command is ready to run as-is.
59
+ return `${binary} setup-profile --profile ${(error as MissingProfileError).profile}`;
60
+ case 'attach-not-chromium':
61
+ // attach is Chromium-only; the fix is to start Chromium/Chrome with a
62
+ // remote-debugging port and attach to THAT endpoint.
63
+ return `${binary} attach --endpoint http://127.0.0.1:9222 (start Chromium/Chrome with --remote-debugging-port=9222 first)`;
64
+ case 'attach-no-context':
65
+ // The reached browser has no window/tab to reuse; the fix is to open one.
66
+ return `${binary} attach --endpoint ${(error as AttachNoContextError).endpoint} (open a window/tab in that browser first)`;
67
+ case 'no-live-server':
68
+ // No long-lived session server is running (ADR-0005): a verb is a thin
69
+ // client and has nothing to drive. The fix is to bring one up FIRST with
70
+ // `serve`; we never auto-spawn a browser in v1.
71
+ return `${binary} serve`;
72
+ case 'session-already-active':
73
+ // A session is already live; v1 holds exactly one. The fix is to tear it
74
+ // down before starting another.
75
+ return `${binary} stop`;
76
+ default: {
77
+ // Exhaustiveness guard: a new ControllerErrorCode must add a fix command
78
+ // here rather than silently fall through to a generic message.
79
+ const _never: never = error.code;
80
+ return `Run \`${binary} --help\` (unhandled error code: ${String(_never)}).`;
81
+ }
82
+ }
83
+ }
84
+
85
+ /**
86
+ * If `cause` is a typed `core` error, return the {@link MappedError} (the
87
+ * user-facing message with the exact fix command appended); otherwise return
88
+ * `undefined` so the caller falls back to the generic error path.
89
+ *
90
+ * The message is the typed error's own message (which already states the
91
+ * condition in domain terms) followed by a blank line and a `To fix, run:`
92
+ * block naming the exact command. We keep `core`'s message rather than
93
+ * re-author it, so the condition text has one source of truth; the CLI's job is
94
+ * only to ADD the actionable fix command.
95
+ */
96
+ export function mapControllerError(
97
+ cause: unknown,
98
+ binary: string,
99
+ ): MappedError | undefined {
100
+ if (!isControllerError(cause)) {
101
+ return undefined;
102
+ }
103
+ const fix = fixCommandFor(cause, binary);
104
+ return {
105
+ code: cause.code,
106
+ message: `${cause.message}\n\nTo fix, run:\n ${fix}`,
107
+ };
108
+ }
109
+
110
+ // Re-export the concrete classes so a test importing from the cli package can
111
+ // construct/assert against them without reaching into core directly.
112
+ export {
113
+ MissingBrowserBinaryError,
114
+ MissingStealthDependencyError,
115
+ MissingProfileError,
116
+ AttachNotChromiumError,
117
+ AttachNoContextError,
118
+ NoLiveServerError,
119
+ SessionAlreadyActiveError,
120
+ };
121
+ export type {ControllerError, ControllerErrorCode};
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ import type {Driver} from '@webhands/core';
2
+
3
+ /**
4
+ * The `incur`-based CLI wrapper around `core` (the `webhands`
5
+ * binary). It binds ONE `incur` command per verb (`goto`, `snapshot`, `click`,
6
+ * `type`, `eval`, `wait`, `cookies`) plus `setup-profile`/`launch`/`attach`,
7
+ * each with a zod `args`/`options`/`output` schema, returns the structured
8
+ * TOON/JSON envelope with `cta` next-verb hints, and maps `core`'s typed
9
+ * missing-binary / missing-profile errors to an actionable fix command.
10
+ *
11
+ * Because it is built on `incur`, the same binary is ALSO an MCP server
12
+ * (`--mcp` / `mcp add`) and emits a skills / `--llms` manifest with no bespoke
13
+ * MCP code. The executable entry (`bin.ts`) calls `.serve()`; this module
14
+ * exports the builder + its types so a test (or a host) can drive the CLI
15
+ * programmatically (`createCli().serve(argv, {stdout, exit})` / `cli.fetch`).
16
+ */
17
+
18
+ export {
19
+ createCli,
20
+ CLI_NAME,
21
+ DEFAULT_PROFILE,
22
+ type CliDeps,
23
+ type LaunchPolicy,
24
+ type ServeSession,
25
+ } from './cli.js';
26
+
27
+ export {
28
+ createDefaultSessionProvider,
29
+ type SessionProvider,
30
+ type DefaultSessionProviderOptions,
31
+ } from './session-provider.js';
32
+
33
+ export {mapControllerError, fixCommandFor, type MappedError} from './errors.js';
34
+
35
+ // Re-export the `core` `Driver` seam type so the boundary stays visible from
36
+ // the cli package (it was anchored here by the scaffold).
37
+ export type {Driver};
@@ -0,0 +1,70 @@
1
+ import {
2
+ connectRemoteSession,
3
+ NoLiveServerError,
4
+ readSessionEndpoint,
5
+ type OpenTarget,
6
+ type Session,
7
+ } from '@webhands/core';
8
+
9
+ /**
10
+ * How a CLI verb command obtains a live {@link Session} to run against.
11
+ *
12
+ * This is the ONE seam the verb commands use to reach a browser. It is
13
+ * deliberately a single function (open a session for an {@link OpenTarget})
14
+ * rather than the transports directly, for two reasons:
15
+ *
16
+ * 1. **Testability of the WIRING.** CLI-level tests assert incur wiring
17
+ * (schemas, output envelope, cta, manifest, error text), NOT verb behaviour
18
+ * (that is covered at the `core` seam). A test injects a provider backed by
19
+ * the `core` `StubTransport`, so the command surface can be exercised with no
20
+ * real browser, and a test can inject a provider that THROWS the typed
21
+ * `core` errors to assert the actionable fix-command messages.
22
+ *
23
+ * 2. **The cross-invocation persistence swap point.** Per ADR-0005 a single
24
+ * browser is kept alive between separate CLI invocations behind a long-lived
25
+ * `serve` process; verb commands are THIN CLIENTS of that server. The default
26
+ * provider (below) is now exactly that thin client: it discovers the running
27
+ * server via the endpoint file and returns a {@link connectRemoteSession}
28
+ * proxy that drives the server's already-live page; when NO server is live it
29
+ * raises a typed {@link NoLiveServerError} so the CLI prints "run `serve`
30
+ * first" and exits non-zero, never auto-spawning a browser (ADR-0005:
31
+ * lifecycle is EXPLICIT in v1).
32
+ */
33
+ export type SessionProvider = (target: OpenTarget) => Promise<Session>;
34
+
35
+ /** Overrides for where the default provider discovers the running server (tests pass a temp root). */
36
+ export interface DefaultSessionProviderOptions {
37
+ /** Explicit controller home root. Omit to use `~/.webhands`. */
38
+ readonly root?: string;
39
+ /** Environment to read the home override from. Defaults to `process.env`. */
40
+ readonly env?: NodeJS.ProcessEnv;
41
+ }
42
+
43
+ /**
44
+ * The v1 default {@link SessionProvider}: a THIN CLIENT of the long-lived
45
+ * `serve` process (ADR-0005).
46
+ *
47
+ * It reads the endpoint file the running server advertised under the config dir
48
+ * and returns a {@link connectRemoteSession} proxy that forwards each verb to
49
+ * the server's single live page. There is no per-invocation browser launch
50
+ * here: a verb invocation drives the SAME live page the server holds, which is
51
+ * what makes session state persist across separate CLI processes.
52
+ *
53
+ * The {@link OpenTarget} is intentionally IGNORED for discovery: which browser
54
+ * to launch (`launch`/`attach`, the profile) was decided once, by the `serve`
55
+ * command, when the single session was brought up. A verb does not get to pick
56
+ * a different browser; it just drives the live one. If no server is live the
57
+ * provider raises {@link NoLiveServerError} (mapped by the CLI to "run `serve`
58
+ * first"); it never silently opens a browser.
59
+ */
60
+ export function createDefaultSessionProvider(
61
+ options: DefaultSessionProviderOptions = {},
62
+ ): SessionProvider {
63
+ return async (_target: OpenTarget): Promise<Session> => {
64
+ const endpoint = await readSessionEndpoint(options);
65
+ if (endpoint === undefined) {
66
+ throw new NoLiveServerError();
67
+ }
68
+ return connectRemoteSession(endpoint.url);
69
+ };
70
+ }
package/src/version.ts ADDED
@@ -0,0 +1,12 @@
1
+ import pkg from '../package.json' with {type: 'json'};
2
+
3
+ /**
4
+ * The CLI version, read from this package's `package.json` at build time via a
5
+ * JSON import attribute (`with { type: 'json' }`). Bundled into the emit, so
6
+ * there is no runtime filesystem read; publish-safe because `dist/version.js`
7
+ * resolves `../package.json` to the package root, which npm always ships.
8
+ *
9
+ * Passed into `Cli.create(..., { version })` so `--version`, the help header,
10
+ * and the MCP server version all report the real package version.
11
+ */
12
+ export const VERSION: string = pkg.version;