letmeknow-cli 0.1.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 ADDED
@@ -0,0 +1,120 @@
1
+ # LetMeKnow
2
+
3
+ LetMeKnow gives an agent a temporary interactive web surface for a human. It is not a localhost proxy and does not read directories. A Node CLI carries newline-delimited JSON between stdin/stdout and a Cloudflare Durable Object over one persistent WebSocket.
4
+
5
+ ## CLI
6
+
7
+ Node 22 or newer is required.
8
+
9
+ ```bash
10
+ npx letmeknow-cli
11
+ ```
12
+
13
+ The deployed service is used by default. Set `LETMEKNOW_URL` for local development:
14
+
15
+ ```bash
16
+ LETMEKNOW_URL=http://localhost:8787 npx letmeknow-cli
17
+ ```
18
+
19
+ stdin contains one compact JSON command per line. stdout contains one JSON event per line. Diagnostics go to stderr.
20
+
21
+ Print the agent-facing skill file without opening a network connection:
22
+
23
+ ```bash
24
+ npx letmeknow-cli --skill > SKILL.md
25
+ ```
26
+
27
+ Start a session:
28
+
29
+ ```json
30
+ {"type":"open","id":"1"}
31
+ ```
32
+
33
+ stdout returns the URL to share with the human:
34
+
35
+ ```json
36
+ {"type":"session","id":"1","url":"https://0123456789abcdef0123.letmeknow.dev/","expires_after_disconnect":600}
37
+ ```
38
+
39
+ There is no initial bundle. Initial resources and later updates are the same `put` command:
40
+
41
+ ```json
42
+ {"type":"put","id":"2","path":"/","content_type":"text/html; charset=utf-8","body":"<h1>Hello</h1>"}
43
+ {"type":"put","id":"3","path":"/app.js","content_type":"text/javascript","body":"document.body.append(' ready')"}
44
+ ```
45
+
46
+ ## Protocol
47
+
48
+ ### Commands: stdin to LetMeKnow
49
+
50
+ - `open` creates the session and emits `session`.
51
+ - `put` stores or replaces an exact pathname.
52
+ - `delete` removes a stored pathname.
53
+ - `response` answers one pending browser request.
54
+ - `close` immediately destroys the session.
55
+
56
+ All commands accept an optional string `id`. Successful `put`, `delete`, `response`, and `close` commands emit a correlated `ack`.
57
+
58
+ A resource supports `status`, `headers`, `content_type`, `encoding`, and `body`. `status` defaults to `200`, `encoding` to `utf8`, and `body` to an empty string. Header values may be strings or string arrays; arrays preserve repeated headers such as `Set-Cookie`. `content_type` overrides any `Content-Type` header. Binary bodies use base64:
59
+
60
+ ```json
61
+ {"type":"put","path":"/logo.png","content_type":"image/png","encoding":"base64","body":"iVBORw0KGgo..."}
62
+ ```
63
+
64
+ Delete a resource:
65
+
66
+ ```json
67
+ {"type":"delete","id":"4","path":"/old.html"}
68
+ ```
69
+
70
+ Destroy everything immediately:
71
+
72
+ ```json
73
+ {"type":"close","id":"5"}
74
+ ```
75
+
76
+ ### Events: LetMeKnow to stdout
77
+
78
+ - `session` contains the public URL and disconnect grace period.
79
+ - `request` describes a browser request that did not match a stored resource.
80
+ - `ack` confirms a command.
81
+ - `error` reports a command or protocol error.
82
+ - `closing` reports explicit destruction.
83
+
84
+ Unknown paths are sent to the producer:
85
+
86
+ ```json
87
+ {"type":"request","id":"cf-request-id","method":"POST","path":"/answer","query":"step=2","headers":{"content-type":"application/x-www-form-urlencoded","hx-request":"true"},"encoding":"utf8","body":"answer=yes"}
88
+ ```
89
+
90
+ Answer with the request event's `id` as `request_id`:
91
+
92
+ ```json
93
+ {"type":"response","id":"6","request_id":"cf-request-id","status":200,"headers":{"content-type":"text/html; charset=utf-8","hx-trigger":"answered"},"body":"<strong>Accepted</strong>"}
94
+ ```
95
+
96
+ Dynamic responses are not stored. Send a separate `put` to serve a path without involving the producer next time.
97
+
98
+ Production sessions use isolated `*.letmeknow.dev` origins, so root-relative links, forms, and asset URLs work normally. Local sessions use `/s/<code>/`; use relative URLs there. Stored paths ignore the URL query when matching. Dynamic events contain pathname and query separately. Methods, forms, cookies, HTMX headers, SPA API requests, status, response headers, and text or binary bodies pass through generically. Browser request bodies use UTF-8 only for recognized textual media types; absent, unrecognized, or invalid UTF-8 bodies use base64. Request, response, and stored-resource bodies are bounded to 1 MiB and are non-streaming; a stalled dynamic browser request body times out after 30 seconds, and a producer response may take up to 5 minutes. Stored and dynamic responses default to `Cache-Control: no-store`; an explicit producer header overrides that default. A session accepts at most 100 stored resources (10 MiB decoded total) and 32 simultaneous dynamic requests.
99
+
100
+ ## Lifecycle
101
+
102
+ The active CLI connection owns the session. The CLI receives an unguessable private reconnect credential in a private WebSocket message, keeps it off protocol stdout, and sends it as the WebSocket subprotocol on reconnect. The reconnect URL contains only the public session code. If the connection drops unexpectedly, it reconnects with that credential during the ten-minute grace period without requiring another `open` command; each connection attempt has a ten-second deadline, and a failed initial attempt exits nonzero while reconnect attempts continue within the grace period. Stored resources remain available for ten minutes, while unknown paths return `503`. Browser traffic does not extend the grace period. After the alarm fires all resources are deleted. A producer connection that never sends `open` is cleaned up after a short deadline. `close` deletes everything immediately.
103
+
104
+ ## Development
105
+
106
+ ```bash
107
+ npm install
108
+ npm run dev
109
+ npm test
110
+ ```
111
+
112
+ The Worker uses one Durable Object per session. The object owns the producer WebSocket, stored resources, pending browser requests, and disconnect alarm. No D1 or R2 binding is required.
113
+
114
+ Deploy with:
115
+
116
+ ```bash
117
+ npm run deploy
118
+ ```
119
+
120
+ Production subdomain URLs require a proxied `*.letmeknow.dev` DNS record and a Worker route for `*.letmeknow.dev/*` in Cloudflare. The apex `letmeknow.dev` remains the control endpoint.
package/SKILL.md ADDED
@@ -0,0 +1,98 @@
1
+ ---
2
+ name: letmeknow
3
+ description: Create a temporary browser surface for a human, serve static resources, and handle interactive HTTP requests through the LetMeKnow NDJSON CLI.
4
+ ---
5
+
6
+ # LetMeKnow
7
+
8
+ Use LetMeKnow when a human needs a temporary web page or interactive UI from an agent. It is not a localhost proxy and does not read files or directories.
9
+
10
+ ## Start the CLI
11
+
12
+ Node.js 22 or newer is required.
13
+
14
+ ```bash
15
+ npx letmeknow-cli
16
+ ```
17
+
18
+ This connects to `https://letmeknow.dev` by default. To use another trusted deployment:
19
+
20
+ ```bash
21
+ LETMEKNOW_URL=http://localhost:8787 npx letmeknow-cli
22
+ ```
23
+
24
+ Run the CLI as a long-lived child process. Write one compact JSON object per line to stdin, keep stdin open while the session is active, and read one JSON event per line from stdout. Read diagnostics from stderr separately. Do not mix stderr into the NDJSON stream.
25
+
26
+ ## Open and publish static content
27
+
28
+ `open` must be the first command. String `id` values are optional; use them to correlate results.
29
+
30
+ ```json
31
+ {"type":"open","id":"open-1"}
32
+ ```
33
+
34
+ Wait for the `session` event and retain its URL:
35
+
36
+ ```json
37
+ {"type":"session","id":"open-1","url":"https://0123456789abcdef0123.letmeknow.dev/","expires_after_disconnect":600}
38
+ ```
39
+
40
+ Store or replace exact pathnames with `put`. The following page submits to an unstored path so the agent can handle it dynamically:
41
+
42
+ ```json
43
+ {"type":"put","id":"put-1","path":"/","content_type":"text/html; charset=utf-8","body":"<!doctype html><form method=\"post\" action=\"answer\"><label>Answer <input name=\"answer\"></label><button>Send</button></form>"}
44
+ ```
45
+
46
+ Use relative links, form actions, and asset URLs so pages also work on local deployments, whose session URL includes a path prefix. Wait for `{"type":"ack","id":"put-1"}` before relying on the update. `status` defaults to `200`, `encoding` to `utf8`, and `body` to an empty string. `headers` accepts string values or string arrays; `content_type` overrides `Content-Type`. For binary content, set `encoding` to `base64`. Paths must start with `/` and must not contain a query. Query strings do not affect stored-path matching.
47
+
48
+ Remove static content with:
49
+
50
+ ```json
51
+ {"type":"delete","id":"delete-1","path":"/old.html"}
52
+ ```
53
+
54
+ ## Handle dynamic requests
55
+
56
+ A browser request whose path is not stored produces a `request` event:
57
+
58
+ ```json
59
+ {"type":"request","id":"cf-request-id","method":"POST","path":"/answer","query":"step=2","headers":{"content-type":"application/x-www-form-urlencoded"},"encoding":"utf8","body":"answer=yes"}
60
+ ```
61
+
62
+ Reply using the event's `id` as `request_id`:
63
+
64
+ ```json
65
+ {"type":"response","id":"response-1","request_id":"cf-request-id","status":200,"content_type":"text/html; charset=utf-8","body":"<strong>Accepted</strong>"}
66
+ ```
67
+
68
+ Wait for the correlated `ack`. Dynamic responses are not stored; use `put` if later requests should receive the same response without involving the agent. Treat request bodies and headers as untrusted input, and escape or validate values before placing them in HTML, headers, or commands.
69
+
70
+ The stdout event types are `session`, `request`, `ack`, `error`, and `closing`. Handle `error` rather than assuming a command succeeded.
71
+
72
+ ## Close and lifecycle
73
+
74
+ Destroy the session and all content immediately when finished:
75
+
76
+ ```json
77
+ {"type":"close","id":"close-1"}
78
+ ```
79
+
80
+ Wait for `ack` and `closing`, then let the process exit. Closing stdin or terminating the CLI only disconnects the producer; it does not replace an explicit `close`.
81
+
82
+ The CLI owns the session and automatically reconnects after an unexpected disconnect. Each connection attempt has a 10-second deadline. Reconnection is possible only during the 10-minute disconnect grace period; stored resources remain available then, but unstored paths return `503`, and browser traffic does not extend the grace period. An initial producer connection must send `open` within 30 seconds. Dynamic request bodies have a 30-second read deadline, and a `response` may take at most 5 minutes.
83
+
84
+ Limits per session: 1 MiB per stored, request, or response body; 100 stored resources; 10 MiB decoded stored content in total; and 32 simultaneous dynamic requests. Bodies are non-streaming. Default responses include `Cache-Control: no-store` unless explicitly overridden.
85
+
86
+ ## Security
87
+
88
+ Treat the session URL as a bearer secret: anyone who has it can access the surface and submit requests. Share it only with the intended human, and do not put it in source control, public logs, issue trackers, or unrelated output. The CLI also receives a separate private reconnect credential over the WebSocket, keeps it off protocol stdout, and uses it automatically. Never expose, persist, or ask the human for that credential. Use only a trusted `LETMEKNOW_URL`, because the deployment receives all page content and browser traffic.
89
+
90
+ ## Recommended skill output
91
+
92
+ The minimal package interface should be:
93
+
94
+ ```bash
95
+ npx letmeknow-cli --skill
96
+ ```
97
+
98
+ It should print this exact `SKILL.md` byte-for-byte to stdout and exit successfully without opening a network connection.
@@ -0,0 +1,299 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync, writeSync } from "node:fs";
4
+ import readline from "node:readline";
5
+
6
+ if (process.argv[2] === "--skill") {
7
+ if (process.argv.length !== 3) {
8
+ process.stderr.write("Usage: npx letmeknow-cli --skill\n");
9
+ process.exit(1);
10
+ }
11
+ writeSync(1, readFileSync(new URL("../SKILL.md", import.meta.url)));
12
+ process.exit(0);
13
+ }
14
+
15
+ const control = new URL(process.env.LETMEKNOW_URL || "https://letmeknow.dev");
16
+ const graceSeconds = 10 * 60;
17
+ const connectionAttemptTimeout = 10_000;
18
+ const maxRetryDelay = 5_000;
19
+ const subprotocolTokenPattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
20
+ let socket;
21
+ let input;
22
+ let credential;
23
+ let sessionUrl;
24
+ let retryTimer;
25
+ let connectionTimer;
26
+ let retryDelay = 100;
27
+ let stdinClosed = false;
28
+ let signalRequested = false;
29
+ let explicitSessionClosed = false;
30
+ let closeCommandAccepted = false;
31
+ let closeCommandSent = false;
32
+ let retryUntil = 0;
33
+ let connected = false;
34
+ let finished = false;
35
+ const queued = [];
36
+
37
+ function endpoint() {
38
+ const url = new URL(control);
39
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
40
+ url.pathname = "/v1/connect";
41
+ url.search = "";
42
+ url.hash = "";
43
+ if (credential && sessionUrl) {
44
+ const publicUrl = new URL(sessionUrl);
45
+ const hostCode = publicUrl.hostname.match(/^([a-f0-9]{20})\.letmeknow\.dev$/);
46
+ const pathCode = publicUrl.pathname.match(/^\/s\/([a-f0-9]{20})(?:\/|$)/);
47
+ const code = hostCode?.[1] || pathCode?.[1];
48
+ if (code) {
49
+ url.searchParams.set("code", code);
50
+ }
51
+ }
52
+ return url;
53
+ }
54
+
55
+ function clearConnectionTimer() {
56
+ if (connectionTimer) clearTimeout(connectionTimer);
57
+ connectionTimer = undefined;
58
+ }
59
+
60
+ function finish(code) {
61
+ if (finished) return;
62
+ finished = true;
63
+ if (retryTimer) clearTimeout(retryTimer);
64
+ clearConnectionTimer();
65
+ input?.close();
66
+ process.exitCode = code;
67
+ }
68
+
69
+ function validCredential(value) {
70
+ return typeof value === "string" && subprotocolTokenPattern.test(value);
71
+ }
72
+
73
+ function validSessionUrl(value) {
74
+ if (typeof value !== "string") return false;
75
+ let url;
76
+ try {
77
+ url = new URL(value);
78
+ } catch {
79
+ return false;
80
+ }
81
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
82
+ if (/^[a-f0-9]{20}\.letmeknow\.dev$/.test(url.hostname)) return true;
83
+ return /^\/s\/[a-f0-9]{20}(?:\/|$)/.test(url.pathname);
84
+ }
85
+
86
+ function isCloseCommand(line) {
87
+ try {
88
+ const packet = JSON.parse(line);
89
+ return packet !== null
90
+ && typeof packet === "object"
91
+ && !Array.isArray(packet)
92
+ && packet.type === "close"
93
+ && (packet.id === undefined || typeof packet.id === "string");
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ function send(line) {
100
+ if (!socket || !connected || socket.readyState !== WebSocket.OPEN) return false;
101
+ try {
102
+ socket.send(line);
103
+ if (isCloseCommand(line)) closeCommandSent = true;
104
+ return true;
105
+ } catch {
106
+ try {
107
+ socket.close();
108
+ } catch {
109
+ // The close event still determines whether reconnect is needed.
110
+ }
111
+ return false;
112
+ }
113
+ }
114
+
115
+ function sendOrQueue(line) {
116
+ if (finished || closeCommandAccepted) return;
117
+ if (isCloseCommand(line)) closeCommandAccepted = true;
118
+ if (!send(line)) queued.push(line);
119
+ }
120
+
121
+ function flush() {
122
+ if (finished) return;
123
+ while (queued.length && !closeCommandSent) {
124
+ if (!send(queued[0])) break;
125
+ queued.shift();
126
+ }
127
+ if (closeCommandSent) queued.length = 0;
128
+ if (!queued.length && stdinClosed && !closeCommandSent && socket && connected) {
129
+ socket.close(1000, "stdin closed");
130
+ }
131
+ }
132
+
133
+ function protocolFailure(message) {
134
+ if (finished) return;
135
+ process.stderr.write(`letmeknow: server protocol error: ${message}\n`);
136
+ const current = socket;
137
+ if (current) {
138
+ try {
139
+ current.close(1000, "protocol error");
140
+ } catch {
141
+ // The process still exits below.
142
+ }
143
+ }
144
+ finish(1);
145
+ }
146
+
147
+ function handleMessage(event) {
148
+ if (finished) return;
149
+ if (typeof event.data !== "string") {
150
+ protocolFailure("binary WebSocket frame");
151
+ return;
152
+ }
153
+ const text = event.data;
154
+ let packet;
155
+ try {
156
+ packet = JSON.parse(text);
157
+ } catch {
158
+ protocolFailure("invalid JSON");
159
+ return;
160
+ }
161
+ if (!packet || typeof packet !== "object" || Array.isArray(packet)) {
162
+ protocolFailure("packet must be a JSON object");
163
+ return;
164
+ }
165
+ if (typeof packet.type !== "string") {
166
+ protocolFailure("packet type is required");
167
+ return;
168
+ }
169
+ if (packet.type === "credential") {
170
+ if (!validCredential(packet.credential)) {
171
+ protocolFailure("invalid credential");
172
+ return;
173
+ }
174
+ credential = packet.credential;
175
+ return;
176
+ }
177
+ if (packet.type === "session") {
178
+ if (!validSessionUrl(packet.url)) {
179
+ protocolFailure("invalid session URL");
180
+ return;
181
+ }
182
+ if (typeof packet.expires_after_disconnect !== "number"
183
+ || !Number.isFinite(packet.expires_after_disconnect)
184
+ || packet.expires_after_disconnect <= 0) {
185
+ protocolFailure("invalid session expiration");
186
+ return;
187
+ }
188
+ sessionUrl = packet.url;
189
+ retryDelay = 100;
190
+ }
191
+ if (packet.type === "closing") explicitSessionClosed = true;
192
+ process.stdout.write(`${text}\n`);
193
+ }
194
+
195
+ function retry() {
196
+ if (finished || signalRequested || explicitSessionClosed || closeCommandSent || (stdinClosed && !queued.length) || Date.now() >= retryUntil) {
197
+ finish(explicitSessionClosed || signalRequested || closeCommandSent || (stdinClosed && !queued.length) ? 0 : 1);
198
+ return;
199
+ }
200
+ retryTimer = setTimeout(() => {
201
+ retryTimer = undefined;
202
+ start();
203
+ }, retryDelay);
204
+ retryDelay = Math.min(retryDelay * 2, maxRetryDelay);
205
+ }
206
+
207
+ function start() {
208
+ if (finished || signalRequested || explicitSessionClosed || closeCommandSent) return;
209
+ const reconnecting = Boolean(credential && sessionUrl);
210
+ const current = socket = reconnecting
211
+ ? new WebSocket(endpoint(), credential)
212
+ : new WebSocket(endpoint());
213
+ connectionTimer = setTimeout(() => {
214
+ if (socket !== current || connected || finished) return;
215
+ clearConnectionTimer();
216
+ process.stderr.write("letmeknow: WebSocket connection attempt timed out\n");
217
+ try {
218
+ current.close();
219
+ } catch {
220
+ // The close event is not available when construction failed.
221
+ }
222
+ socket = undefined;
223
+ connected = false;
224
+ if (reconnecting) retry();
225
+ else finish(1);
226
+ }, connectionAttemptTimeout);
227
+ current.addEventListener("message", handleMessage);
228
+ current.addEventListener("error", () => {
229
+ if (!finished) process.stderr.write("letmeknow: WebSocket connection failed; retrying\n");
230
+ });
231
+ current.addEventListener("open", () => {
232
+ if (socket !== current || finished) return;
233
+ clearConnectionTimer();
234
+ connected = true;
235
+ retryDelay = 100;
236
+ if (reconnecting) retryUntil = 0;
237
+ flush();
238
+ });
239
+ current.addEventListener("close", (event) => {
240
+ if (socket !== current) return;
241
+ clearConnectionTimer();
242
+ connected = false;
243
+ if (finished) return;
244
+ socket = undefined;
245
+ if (closeCommandSent || signalRequested || explicitSessionClosed || (stdinClosed && !queued.length)) {
246
+ finish(0);
247
+ return;
248
+ }
249
+ if (!credential || !sessionUrl) {
250
+ process.stderr.write(`letmeknow: connection closed (${event.code}${event.reason ? `: ${event.reason}` : ""})\n`);
251
+ finish(1);
252
+ return;
253
+ }
254
+ if (!retryUntil) retryUntil = Date.now() + graceSeconds * 1_000;
255
+ retry();
256
+ });
257
+ }
258
+
259
+ input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
260
+ input.on("line", (line) => {
261
+ if (!finished && line.trim() && !closeCommandAccepted) sendOrQueue(line);
262
+ });
263
+ input.on("close", () => {
264
+ if (finished) return;
265
+ stdinClosed = true;
266
+ if (!socket) {
267
+ if (!queued.length) finish(0);
268
+ return;
269
+ }
270
+ if (connected) flush();
271
+ else if (!queued.length) {
272
+ const current = socket;
273
+ clearConnectionTimer();
274
+ try {
275
+ current.close();
276
+ } catch {
277
+ // The process still exits below.
278
+ }
279
+ socket = undefined;
280
+ finish(0);
281
+ }
282
+ });
283
+
284
+ for (const signal of ["SIGINT", "SIGTERM"]) {
285
+ process.on(signal, () => {
286
+ signalRequested = true;
287
+ clearConnectionTimer();
288
+ if (socket) {
289
+ try {
290
+ socket.close(1000, signal);
291
+ } catch {
292
+ // The process still exits below.
293
+ }
294
+ }
295
+ finish(0);
296
+ });
297
+ }
298
+
299
+ start();
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "letmeknow-cli",
3
+ "version": "0.1.0",
4
+ "description": "A temporary interactive web surface for agents.",
5
+ "files": ["bin", "SKILL.md"],
6
+ "type": "module",
7
+ "bin": {
8
+ "letmeknow": "bin/letmeknow.js"
9
+ },
10
+ "engines": {
11
+ "node": ">=22"
12
+ },
13
+ "scripts": {
14
+ "dev": "wrangler dev",
15
+ "deploy": "wrangler deploy",
16
+ "check": "tsc --noEmit && tsc -p tsconfig.test.json --noEmit",
17
+ "test": "npm run check && vitest run && node --test tests/cli.test.js"
18
+ },
19
+ "devDependencies": {
20
+ "@cloudflare/vitest-plugin": "^1.1.0",
21
+ "@cloudflare/workers-types": "^5.20260825.1",
22
+ "@types/node": "^22.15.17",
23
+ "typescript": "^5.9.2",
24
+ "vitest": "^4.1.11",
25
+ "wrangler": "^4.126.0",
26
+ "ws": "^8.21.3"
27
+ }
28
+ }