create-mirinjs 0.1.0-alpha.2 → 0.1.0-alpha.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-mirinjs",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.22",
4
4
  "description": "Scaffold a new mirin desktop app: bun create mirinjs",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,11 +22,13 @@
22
22
  "create-mirinjs": "./src/index.ts"
23
23
  },
24
24
  "exports": {
25
- ".": "./src/scaffold.ts"
25
+ ".": "./src/scaffold.ts",
26
+ "./skill": "./src/skill.ts"
26
27
  },
27
28
  "files": [
28
29
  "src",
29
- "template"
30
+ "template",
31
+ "skill"
30
32
  ],
31
33
  "engines": {
32
34
  "bun": ">=1.2.0"
package/skill/SKILL.md ADDED
@@ -0,0 +1,118 @@
1
+ ---
2
+ name: mirin
3
+ description: Verify and drive a mirin desktop app without a terminal in front of you. Use when working in a project with a mirin.config.ts and you need to answer "did my change actually work?" — booting the app headlessly, reading its structured event stream, capturing a screenshot or UI snapshot, driving a real user flow and asserting on it, or diagnosing a window that never appeared, a blank screen, a renderer exception, or a failing RPC call. Triggers include "run the app", "check the app", "does this still work", "screenshot the app", "why is the window blank", "the app won't start", "test this flow", "click the button and see what happens".
4
+ ---
5
+
6
+ # Verifying a mirin app
7
+
8
+ mirin apps are native desktop apps: a Bun main process, a CEF webview, typed RPC
9
+ between them. `bun run dev` opens a real window and never exits, which means a tool
10
+ that backgrounds it has a pid and no idea whether anything worked.
11
+
12
+ Everything below exists so you never have to guess. **Never report a change as
13
+ working because the code looks right — run `mirin check` and read the result.**
14
+
15
+ ## The loop
16
+
17
+ ```bash
18
+ mirin check # boot once, capture, report, exit non-zero on failure
19
+ mirin check --json # one JSON object on stdout, nothing else
20
+ mirin check --scenario ./check.ts # drive a real user flow and assert, then capture
21
+ mirin doctor # environment/config preflight, without building
22
+ ```
23
+
24
+ `mirin check` runs the real startup path, waits for a window, captures a screenshot
25
+ and an accessibility snapshot, collects every error the app produced, stops the app,
26
+ and **exits non-zero when something went wrong**. Treat the exit code as a test
27
+ result. If the project has a `check` script, `bun run check` is the same thing.
28
+
29
+ Exit 1 means: no window appeared, the inspector never came up, an `error`-level
30
+ event was recorded, or a scenario assertion failed. The `reason` field says which.
31
+
32
+ ## Reading the result
33
+
34
+ In `--json`, every stdout line parses — progress events, then one `result` object.
35
+ Subprocess chatter goes to stderr, so `mirin check --json | jq` is safe.
36
+
37
+ ```jsonc
38
+ {
39
+ "phase": "result",
40
+ "ok": false,
41
+ "reason": "scenario failed at \"typed mutation updates the list\": the todo never reached the list",
42
+ "windows": [{ "id": 1, "name": "main", "title": "…" }],
43
+ "screenshot": "/…/.mirin/dev/<session>/screenshots/….png",
44
+ "snapshot": "RootWebArea …", // the UI as an accessibility tree
45
+ "errors": [ /* full events, with stack traces */ ],
46
+ "scenario": { "steps": [ /* name, ok, ms */ ] }
47
+ }
48
+ ```
49
+
50
+ Read `errors` first — each carries `src` (`main`, `renderer`, `native`, `rpc`), the
51
+ message, and a stack when there is one. `snapshot` is the UI in a few hundred bytes;
52
+ prefer it over the DOM. `screenshot` is a **path** — read the file to look at it.
53
+
54
+ ## When the app already exited
55
+
56
+ Every run writes a session directory, so you can diagnose a crash with file access
57
+ alone — no live process, no terminal.
58
+
59
+ ```bash
60
+ SESSION=$(bun -e 'console.log(JSON.parse(await Bun.file(".mirin/dev/current.json").text()).session)')
61
+
62
+ tail -n 50 "$SESSION/events.jsonl" # the full stream: logs, exceptions, RPC, native events
63
+ cat "$SESSION/session.json" # startup phase timeline — how far did it get?
64
+ cat "$SESSION/exit.json" # post-mortem: exit code, signal, last 50 events
65
+ ls "$SESSION/screenshots"
66
+ ```
67
+
68
+ `session.json` answers "how far did startup get?" (`compile` → `bundle` → `vite` →
69
+ `launch`, each with a status). `exit.json` answers "how did it die?".
70
+
71
+ ## Driving a real flow
72
+
73
+ A scenario clicks, types, and asserts against the running app, then fails the check
74
+ when an assertion does. This is how you verify a *flow*, not just a boot.
75
+
76
+ ```ts
77
+ // check.ts
78
+ import { defineCheck } from "mirinjs/check";
79
+
80
+ export default defineCheck(async (app) => {
81
+ app.step("adding a todo");
82
+ await app.click("text=add a todo"); // CSS, or text= matching the a11y name
83
+ await app.type("ship it");
84
+ await app.click("text=Add");
85
+ await app.waitUntil(
86
+ async () => (await app.evaluate<number>('document.querySelectorAll("li").length')) > 0,
87
+ "the todo never reached the list",
88
+ );
89
+ await app.screenshot("todo-added");
90
+ });
91
+ ```
92
+
93
+ Run it with `mirin check --scenario ./check.ts`. Read
94
+ `reference/scenarios.md` for the full driver API and **two mistakes that produce
95
+ passing checks which verify nothing** — read that file before writing assertions.
96
+
97
+ ## Inspecting a running app
98
+
99
+ While `mirin dev` is up, the same stream plus live state, screenshots, snapshots,
100
+ expression evaluation and synthetic input are available over a token-authenticated
101
+ loopback HTTP server. Use this for exploration; use `check --scenario` for anything
102
+ you want to be repeatable.
103
+
104
+ See `reference/inspector.md` for auth and the full route list.
105
+
106
+ ## Rules
107
+
108
+ - Prefer `mirin check` over `mirin dev`. `dev` never exits; backgrounding it and
109
+ guessing is the failure mode this whole surface exists to remove.
110
+ - Report what the run actually said. If `check` exits 1, say so and quote the
111
+ `reason` — do not describe a change as working against a failing check.
112
+ - The screenshot of a transparent window is composited over an opaque backdrop so
113
+ it is readable; the response sets `composited: true`. It is not pixel-truth.
114
+ - This is a development surface and is off in packaged builds. Do not set
115
+ `devtools.production` to make something work — it ships an endpoint that can
116
+ evaluate arbitrary code in the app.
117
+ - Do not add a test framework to answer "does this still work". A scenario file and
118
+ `mirin check` already are the test.
@@ -0,0 +1,175 @@
1
+ # The inspector: driving a running app over HTTP
2
+
3
+ While `mirin dev` is running, the app serves a token-authenticated loopback HTTP
4
+ server. `mirin dev` prints the URL; the endpoint and token are also on disk.
5
+
6
+ Use this for exploration and one-off questions. For anything you want to be
7
+ repeatable, write a scenario (`reference/scenarios.md`) — it runs the same
8
+ operations through `mirin check` and gates on the result.
9
+
10
+ ## Connecting
11
+
12
+ ```bash
13
+ SESSION=$(bun -e 'console.log(JSON.parse(await Bun.file(".mirin/dev/current.json").text()).session)')
14
+ PORT=$(bun -e "console.log(JSON.parse(await Bun.file('$SESSION/inspector.json').text()).port)")
15
+ TOKEN=$(bun -e "console.log(JSON.parse(await Bun.file('$SESSION/inspector.json').text()).token)")
16
+
17
+ curl -s "http://127.0.0.1:$PORT/?token=$TOKEN" # route index — the surface is self-describing
18
+ ```
19
+
20
+ Every request needs the token, as `?token=…` or `Authorization: Bearer …`.
21
+
22
+ ## Routes
23
+
24
+ | Route | Purpose |
25
+ |---|---|
26
+ | `GET /` | Route index |
27
+ | `GET /health` | Liveness, pid, stream cursor, log file path |
28
+ | `GET /state` | Windows, RPC routes, app-published state, platform |
29
+ | `GET /logs` | The event stream, filterable |
30
+ | `GET /logs/stream` | The same as SSE, with replay |
31
+ | `GET /screenshot` | PNG of a window |
32
+ | `GET /snapshot` | Accessibility tree (default) or DOM HTML |
33
+ | `POST /eval` | Evaluate an expression in a window |
34
+ | `POST /act` | `click` / `type` / `key` / `scroll` / `wait` |
35
+ | `POST /cdp` | Any DevTools-protocol command |
36
+ | `GET /cdp/targets` | Which windows the DevTools bridge is attached to |
37
+
38
+ ## Answering common questions
39
+
40
+ ```bash
41
+ # What is open, what RPC exists, what has the app published?
42
+ curl -s "http://127.0.0.1:$PORT/state?token=$TOKEN"
43
+
44
+ # Just the errors
45
+ curl -s "http://127.0.0.1:$PORT/logs?level=error&token=$TOKEN"
46
+
47
+ # What did the app do since sequence 42? (cursor from a previous lastSeq)
48
+ curl -s "http://127.0.0.1:$PORT/logs?since=42&token=$TOKEN"
49
+
50
+ # Every failed RPC call
51
+ curl -s "http://127.0.0.1:$PORT/logs?type=rpc.error&token=$TOKEN"
52
+
53
+ # The UI, cheaply — a few hundred bytes where the DOM is tens of kilobytes
54
+ curl -s "http://127.0.0.1:$PORT/snapshot?token=$TOKEN"
55
+
56
+ # Watch live (replays from `since` first)
57
+ curl -sN "http://127.0.0.1:$PORT/logs/stream?since=0&token=$TOKEN"
58
+ ```
59
+
60
+ `/logs` accepts `since` (exclusive), `level` (minimum severity), `src`, `window`,
61
+ `type` (prefix match), `contains`, and `limit`. Unknown filter values are dropped
62
+ rather than rejected, so a near-miss still returns logs.
63
+
64
+ ## HTTP traffic
65
+
66
+ Every request and response the page makes is in the stream — method, URL, status,
67
+ type, time to headers, and headers.
68
+
69
+ ```bash
70
+ # everything the app called
71
+ curl -s "http://127.0.0.1:$PORT/logs?type=network&token=$TOKEN"
72
+
73
+ # just the failures
74
+ curl -s "http://127.0.0.1:$PORT/logs?type=network&level=warn&token=$TOKEN"
75
+
76
+ # one endpoint
77
+ curl -s "http://127.0.0.1:$PORT/logs?type=network&contains=/api/todos&token=$TOKEN"
78
+ ```
79
+
80
+ Successful traffic is `debug`, a 4xx is `warn`, a 5xx is `error` — so a failing API
81
+ call fails `mirin check`, and a missing favicon does not.
82
+
83
+ **Bodies are not in the stream** — they are the most likely place for a credential
84
+ to sit, and the stream is plaintext on disk. Each event carries a `requestId`;
85
+ fetch the body when you actually need it:
86
+
87
+ ```bash
88
+ curl -s -X POST "http://127.0.0.1:$PORT/cdp?token=$TOKEN" \
89
+ -H 'content-type: application/json' \
90
+ -d '{"method":"Network.getResponseBody","params":{"requestId":"76030.15"}}'
91
+ ```
92
+
93
+ Headers and URLs are recorded with credential values replaced by `[redacted]`
94
+ (`Authorization`, `Cookie`, anything that looks like a token/key/secret/session, in
95
+ a header or a query parameter). If you need the real value, you are debugging the
96
+ credential itself — read it from the app's own config, not from the stream.
97
+
98
+ ## Raw DevTools protocol
99
+
100
+ `POST /cdp` sends any CDP command, for whatever the routes above do not cover:
101
+
102
+ ```bash
103
+ curl -s -X POST "http://127.0.0.1:$PORT/cdp?token=$TOKEN" \
104
+ -H 'content-type: application/json' \
105
+ -d '{"method":"Performance.getMetrics"}'
106
+ ```
107
+
108
+ Useful ones: `Network.getResponseBody`, `Performance.getMetrics` (heap size, node
109
+ count, layout count — call `Performance.enable` first), `DOM.getDocument`,
110
+ `CSS.getComputedStyleForNode`, `Profiler.*`, `HeapProfiler.*`.
111
+
112
+ Returns `{ ok, method, window, result }`. A protocol error comes back as
113
+ `{ ok: false, error }` — a stale `requestId` or an unsupported domain is
114
+ information, not a crash. Inside a scenario, the same thing is `app.cdp(method,
115
+ params)`.
116
+
117
+ ## Screenshots
118
+
119
+ `GET /screenshot` writes the PNG into the session's `screenshots/` dir and returns
120
+ `{ path, bytes, format, window, composited }`. Read the path — a path costs a few
121
+ dozen bytes where an inlined base64 PNG costs megabytes. `?inline=1` returns raw
122
+ bytes. Also accepts `window`, `format=png|jpeg`, `quality`, `full=1`, and `label`.
123
+
124
+ If the window is transparent (a glass/OSR window), the page has no background of its
125
+ own and a raw capture is alpha 0 — every viewer composites it onto white and it
126
+ reads as blank. Such a page is captured over an opaque backdrop chosen to contrast
127
+ with its text, and the response sets `composited: true`. That image is readable but
128
+ is **not** pixel-truth, and it does not include the native material behind the
129
+ webview. `?backdrop=#rrggbb` forces a colour; `?backdrop=none` opts out.
130
+
131
+ ## Driving
132
+
133
+ ```bash
134
+ curl -s -X POST "http://127.0.0.1:$PORT/act?token=$TOKEN" \
135
+ -H 'content-type: application/json' \
136
+ -d '{"action":"click","selector":"text=Open settings"}'
137
+ ```
138
+
139
+ Actions: `click` (`selector`, `clickCount`), `type` (`text`, optional `selector`),
140
+ `key` (`key`), `scroll` (`deltaY`, `deltaX`), `wait` (`selector`, `timeoutMs`).
141
+ Selectors accept CSS or `text=…` matching the accessible label. Add `?window=2` to
142
+ target a specific window.
143
+
144
+ ```bash
145
+ curl -s -X POST "http://127.0.0.1:$PORT/eval?token=$TOKEN" \
146
+ -H 'content-type: application/json' \
147
+ -d '{"expression":"document.title"}'
148
+ ```
149
+
150
+ ## Making app state visible
151
+
152
+ If you find yourself guessing at main-process state, publish it — it then appears in
153
+ `/state`, in `app.exposed()` inside a scenario, and in `mirin check`'s report
154
+ (`exposed`, printed as `app state:` when non-empty):
155
+
156
+ ```ts
157
+ import { devtools } from "mirinjs";
158
+
159
+ devtools.expose("route", () => currentRoute);
160
+ devtools.event({ type: "order.submitted", msg: id });
161
+ ```
162
+
163
+ This is the highest-leverage thing you can add to a mirin app for a tool's benefit:
164
+ it lifts state that no DOM snapshot could ever show into the same surface as windows
165
+ and logs.
166
+
167
+ ## Security
168
+
169
+ The inspector can evaluate JavaScript and synthesize input, so it is a capability,
170
+ not a read-only view. It binds `127.0.0.1` only, requires the session token, and
171
+ rejects requests whose `Host` header does not name loopback.
172
+
173
+ It is **off in packaged builds**. Do not set `devtools.production` to make something
174
+ work — that ships an endpoint which can run arbitrary code in the app. `mirin
175
+ doctor` warns when it is set.
@@ -0,0 +1,146 @@
1
+ # Scenarios: driving and asserting
2
+
3
+ A scenario is a module that default-exports an async function. `mirin check
4
+ --scenario ./check.ts` boots the app, waits for the first window to settle, runs the
5
+ scenario, then captures — so the screenshot in the report shows the state the run
6
+ ended in, including the state it failed in.
7
+
8
+ The file is loaded *before* the build, so a bad path fails in milliseconds.
9
+
10
+ ```ts
11
+ import { defineCheck } from "mirinjs/check";
12
+
13
+ export default defineCheck(async (app) => {
14
+ app.step("a name for this phase");
15
+ // …drive and assert; throw (or fail an assertion) to fail the check
16
+ });
17
+ ```
18
+
19
+ ## Two traps
20
+
21
+ Both produce a **passing check that verifies nothing**. Both were hit while building
22
+ this surface, on the first real scenario.
23
+
24
+ ### 1. Assert on the thing that changed
25
+
26
+ `expectText` matches anywhere in the accessibility tree — including the input you
27
+ just typed into.
28
+
29
+ ```ts
30
+ await app.click("text=add a todo");
31
+ await app.type("ship it");
32
+ await app.click("text=Add");
33
+ await app.expectText("ship it"); // ✗ passes even when the mutation is broken:
34
+ // the text is still sitting in the input
35
+ ```
36
+
37
+ Assert against the thing the interaction was supposed to change:
38
+
39
+ ```ts
40
+ await app.waitUntil(
41
+ async () =>
42
+ (await app.evaluate<string[]>('[...document.querySelectorAll("li")].map((n) => n.textContent)'))
43
+ .includes("ship it"),
44
+ "the todo never reached the list",
45
+ );
46
+ ```
47
+
48
+ ### 2. Use `waitUntil`, not `assert`, after an interaction
49
+
50
+ An action returns as soon as the input is dispatched. The RPC round trip it
51
+ triggered has *not* landed yet, so a plain `assert` reads the previous state and
52
+ races.
53
+
54
+ ```ts
55
+ await app.click("text=Add");
56
+ const items = await app.evaluate<number>('document.querySelectorAll("li").length');
57
+ app.assert(items > 0, "…"); // ✗ races the round trip
58
+ ```
59
+
60
+ `waitUntil` polls (2s default) and only fails if the condition never holds.
61
+ `expectText` already polls for the same reason. `assert` is for facts that are
62
+ already true — a value you just read, a shape you already have.
63
+
64
+ ## Driver API
65
+
66
+ Every method targets the only window unless you pass `{ window: id }`.
67
+
68
+ ### Drive
69
+
70
+ | Method | Notes |
71
+ |---|---|
72
+ | `click(selector, { clickCount?, window? })` | `clickCount: 3` selects a field's contents so `type` replaces them |
73
+ | `type(text, { selector?, window? })` | Focuses `selector` first when given |
74
+ | `key(name, { window? })` | `Enter`, `Escape`, `Tab`, … |
75
+ | `scroll({ deltaY?, deltaX?, window? })` | |
76
+ | `waitFor(selector, { timeoutMs?, window? })` | Waits for a selector to match |
77
+ | `sleep(ms)` | A guess — prefer `waitFor`/`waitUntil` |
78
+
79
+ Selectors are CSS **or** `text=Save`, which matches an element's accessible label:
80
+ trimmed text, `value`, `aria-label`, `placeholder`, or `title`. Prefer `text=` — it
81
+ matches the names `snapshot()` prints, so you never guess at class attributes.
82
+
83
+ Input is synthesized through the DevTools protocol, not `element.click()`, so a
84
+ scenario exercises the same event path a person's interaction would.
85
+
86
+ ### Observe
87
+
88
+ | Method | Returns |
89
+ |---|---|
90
+ | `snapshot({ format?, window? })` | Accessibility tree (default) or `format: "dom"` for HTML |
91
+ | `evaluate<T>(expression, { window? })` | The expression's value from the page |
92
+ | `screenshot(label?, { window? })` | Path to the PNG; `label` goes into the file name |
93
+ | `windows()` | `{ id, name, url, title }[]` — every window has a name |
94
+ | `exposed()` | Slices the app published with `devtools.expose` |
95
+ | `logs(query?)` | The event stream, filtered like the inspector's `/logs` |
96
+ | `cdp<T>(method, params?, { window? })` | Any DevTools-protocol command |
97
+
98
+ `logs` is how you assert on things the DOM cannot show:
99
+
100
+ ```ts
101
+ const failed = await app.logs({ type: "rpc.error" });
102
+ app.assert(failed.length === 0, `${failed.length} RPC call(s) failed`);
103
+ ```
104
+
105
+ HTTP traffic is in the same stream, so a scenario can assert on what the app called
106
+ and what came back — including the body, fetched on demand:
107
+
108
+ ```ts
109
+ const [call] = await app.logs({ type: "network.response", contains: "/api/todos" });
110
+ app.assert(call !== undefined, "the app never called /api/todos");
111
+ app.assert(call.data.status === 200, `/api/todos returned ${call.data.status}`);
112
+
113
+ const { body } = await app.cdp<{ body: string }>("Network.getResponseBody", {
114
+ requestId: call.data.requestId,
115
+ });
116
+ app.assert(JSON.parse(body).length > 0, "the API returned an empty list");
117
+ ```
118
+
119
+ Bodies are never captured into the stream (it is plaintext on disk), and headers and
120
+ URLs have credential values redacted — so `logs` will not hand you a token.
121
+
122
+ ### Verify
123
+
124
+ | Method | Notes |
125
+ |---|---|
126
+ | `assert(condition, message)` | Right now. Not after an interaction — see trap 2 |
127
+ | `waitUntil(condition, message, { timeoutMs? })` | Polls until truthy; a throwing condition counts as "not yet" |
128
+ | `expectText(text, { timeoutMs?, window? })` | Polls the a11y tree — see trap 1 |
129
+
130
+ ### Report
131
+
132
+ `app.step(name)` names the phase. Steps appear in the report with pass/fail and a
133
+ duration, stream as `step` events in `--json`, and a failure names the step it
134
+ happened in:
135
+
136
+ ```
137
+ ✓ typed query round-trips to the main process (21ms)
138
+ ✗ typed mutation updates the list (11ms)
139
+ → the todo never reached the list (waited 2000ms)
140
+ ```
141
+
142
+ ## Worked examples
143
+
144
+ In the mirin repo: `examples/hello-react/check.ts` (typed query, mutation, push
145
+ events, no failed RPC) and `examples/kitchen-sink/check.ts` (app-shell state via
146
+ `exposed()`, opening a second window and checking it is addressable).
package/src/index.ts CHANGED
@@ -16,8 +16,8 @@ try {
16
16
  console.log(` cd ${target}`);
17
17
  console.log(" bun install");
18
18
  console.log(" bun run dev # launches a native window with HMR + typed RPC");
19
- console.log(" bun run build # packages a standalone .app\n");
20
- console.log("Requires macOS arm64, Bun, and the Xcode command-line tools.");
19
+ console.log(" bun run build # packages a standalone app\n");
20
+ console.log("Requires Bun on macOS arm64, Windows x64/arm64, or Linux x64/arm64.");
21
21
  } catch (err) {
22
22
  console.error(`create-mirinjs: ${err instanceof Error ? err.message : err}`);
23
23
  process.exit(1);
package/src/scaffold.ts CHANGED
@@ -6,13 +6,14 @@
6
6
  import {
7
7
  cpSync,
8
8
  existsSync,
9
- readFileSync,
10
9
  readdirSync,
10
+ readFileSync,
11
11
  renameSync,
12
12
  statSync,
13
13
  writeFileSync,
14
14
  } from "node:fs";
15
15
  import { join } from "node:path";
16
+ import { installSkill } from "./skill.ts";
16
17
 
17
18
  const TEMPLATE_DIR = join(import.meta.dir, "..", "template");
18
19
 
@@ -37,17 +38,23 @@ export function scaffold(targetDir: string, options: ScaffoldOptions = {}): stri
37
38
  const ignore = join(targetDir, "_gitignore");
38
39
  if (existsSync(ignore)) renameSync(ignore, join(targetDir, ".gitignore"));
39
40
 
40
- const replacements: Record<string, string> = {
41
- __APP_NAME__: appName,
42
- __APP_ID__: appId,
43
- __MIRIN_VERSION__: version,
44
- };
41
+ const replacements = [
42
+ ["__APP_NAME__", appName],
43
+ ["__APP_ID__", appId],
44
+ ["__MIRIN_VERSION__", version],
45
+ ] as const;
45
46
  for (const file of walk(targetDir)) applyReplacements(file, replacements);
46
47
 
48
+ // After the replacement pass: the skill has no placeholders and must not be walked.
49
+ installSkill(targetDir);
50
+
47
51
  return appName;
48
52
  }
49
53
 
50
- function applyReplacements(file: string, replacements: Record<string, string>): void {
54
+ function applyReplacements(
55
+ file: string,
56
+ replacements: readonly (readonly [string, string])[],
57
+ ): void {
51
58
  let text: string;
52
59
  try {
53
60
  text = readFileSync(file, "utf8");
@@ -55,7 +62,7 @@ function applyReplacements(file: string, replacements: Record<string, string>):
55
62
  return; // skip binary/unreadable files
56
63
  }
57
64
  let changed = text;
58
- for (const [from, to] of Object.entries(replacements)) {
65
+ for (const [from, to] of replacements) {
59
66
  changed = changed.split(from).join(to);
60
67
  }
61
68
  if (changed !== text) writeFileSync(file, changed);
package/src/skill.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Install the mirin agent skill into a project. Ships in this package because both
3
+ * `bun create mirinjs` (no CLI) and `mirin skill`/`mirin init` need it.
4
+ */
5
+
6
+ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ const SKILL_DIR = join(import.meta.dir, "..", "skill");
10
+
11
+ /** Where a Claude Code project skill lives. */
12
+ const INSTALL_PATH = join(".claude", "skills", "mirin");
13
+
14
+ export interface InstallSkillResult {
15
+ /** Absolute path the skill was written to. */
16
+ path: string;
17
+ /** Relative to the project, for printing. */
18
+ relativePath: string;
19
+ replaced: boolean;
20
+ }
21
+
22
+ /** Copy the skill into `projectDir/.claude/skills/mirin`. An existing install is
23
+ * replaced wholesale, not merged: a stale reference file would read as current. */
24
+ export function installSkill(projectDir: string): InstallSkillResult {
25
+ if (!existsSync(SKILL_DIR)) {
26
+ throw new Error(`mirin skill assets are missing from the package (${SKILL_DIR})`);
27
+ }
28
+
29
+ const path = join(projectDir, INSTALL_PATH);
30
+ const replaced = existsSync(path) && readdirSync(path).length > 0;
31
+ if (replaced) rmSync(path, { recursive: true, force: true });
32
+
33
+ mkdirSync(path, { recursive: true });
34
+ cpSync(SKILL_DIR, path, { recursive: true });
35
+
36
+ return { path, relativePath: INSTALL_PATH, replaced };
37
+ }
@@ -0,0 +1,92 @@
1
+ # AGENTS.md — __APP_NAME__
2
+
3
+ Notes for AI coding tools (and anyone driving this app without a terminal in front
4
+ of them). This app is built with [mirin](https://github.com/Netko-Labs/mirin), which
5
+ ships a development observability surface so you can see what the app is doing
6
+ instead of guessing.
7
+
8
+ If your tool supports skills, the detail below is also installed as one at
9
+ `.claude/skills/mirin/`, loaded on demand rather than kept in context. Refresh it
10
+ after upgrading the CLI with `bunx mirin skill`.
11
+
12
+ ## Commands
13
+
14
+ ```bash
15
+ bun install
16
+ bun run dev # native window with Vite HMR + typed RPC (long-lived, interactive)
17
+ bun run check # boot once, capture a screenshot + UI snapshot, report, exit
18
+ bun run doctor # check the project and environment without building
19
+ bun run build # standalone app in ./build
20
+ ```
21
+
22
+ ## Verifying a change
23
+
24
+ Prefer `bun run check` over `bun run dev`. It runs the same startup path but is
25
+ **not** interactive: it waits for a window, captures a screenshot and an
26
+ accessibility snapshot, collects errors, stops the app, and **exits non-zero if
27
+ anything went wrong**. Treat it like a test.
28
+
29
+ ```bash
30
+ bun run check # human summary
31
+ bun run check --json # one JSON object on stdout
32
+ ```
33
+
34
+ If it fails, `bun run doctor` names environmental causes (missing entry file, port
35
+ in use, unsupported platform) and reports how the previous run ended.
36
+
37
+ ## Seeing what the app did
38
+
39
+ Every run writes a structured event stream to a gitignored session directory. It
40
+ contains main-process logs, renderer console output, uncaught exceptions with stack
41
+ traces, failed network requests, every RPC call, and native window events.
42
+
43
+ ```bash
44
+ # the newest session directory
45
+ SESSION=$(bun -e 'console.log(JSON.parse(await Bun.file(".mirin/dev/current.json").text()).session)')
46
+
47
+ tail -n 50 "$SESSION/events.jsonl" # the stream
48
+ cat "$SESSION/session.json" # startup phase timeline
49
+ cat "$SESSION/exit.json" # post-mortem, after the app exits
50
+ ls "$SESSION/screenshots" # captures
51
+ ```
52
+
53
+ While the app is running, the same stream is queryable over a loopback HTTP
54
+ inspector, along with live window state, a screenshot endpoint, an accessibility
55
+ snapshot of the UI, expression evaluation, and synthetic input (click/type/key).
56
+ `bun run dev` prints its URL; the token is in `$SESSION/inspector.json`.
57
+
58
+ ```bash
59
+ PORT=$(bun -e 'console.log(JSON.parse(await Bun.file(process.argv[2]).text()).port)' "$SESSION/inspector.json")
60
+ TOKEN=$(bun -e 'console.log(JSON.parse(await Bun.file(process.argv[2]).text()).token)' "$SESSION/inspector.json")
61
+
62
+ curl -s "http://127.0.0.1:$PORT/state?token=$TOKEN" # windows, RPC routes, app state
63
+ curl -s "http://127.0.0.1:$PORT/logs?level=error&token=$TOKEN" # just the errors
64
+ curl -s "http://127.0.0.1:$PORT/snapshot?token=$TOKEN" # the UI as an accessibility tree
65
+ curl -s "http://127.0.0.1:$PORT/screenshot?token=$TOKEN" # returns a PNG path to read
66
+ ```
67
+
68
+ `GET /` lists every route. See mirin's `docs/agent-devtools.md` for the full surface.
69
+
70
+ This is a development surface only: it is disabled in packaged builds.
71
+
72
+ ## Making app state visible
73
+
74
+ If you find yourself guessing at internal state, publish it — it then appears in
75
+ `/state` and in `mirin check` output:
76
+
77
+ ```ts
78
+ import { devtools } from "mirinjs";
79
+
80
+ devtools.expose("route", () => currentRoute);
81
+ devtools.event({ type: "order.submitted", data: { id } });
82
+ ```
83
+
84
+ ## Layout
85
+
86
+ - `mirin.config.ts` — the app manifest (windows, ids, devtools settings).
87
+ - `main/` — the Bun main process: RPC handlers, app lifecycle. Runs in a Worker.
88
+ - `ui/` — the React UI, served over the `app://` scheme in builds.
89
+
90
+ The UI and the main process talk over typed RPC, not HTTP. Add a procedure in
91
+ `main/rpc.ts` and call it from `ui/` through the generated client — don't reach for
92
+ `fetch` between them.
@@ -5,12 +5,17 @@ TypeScript + Chromium.
5
5
 
6
6
  ```bash
7
7
  bun install
8
- bun run dev # native window with Vite HMR + typed RPC
9
- bun run build # standalone .app in ./build
8
+ bun run dev # native window with Vite HMR + typed RPC
9
+ bun run check # boot once, capture a screenshot + UI snapshot, exit non-zero on errors
10
+ bun run doctor # check the project and environment without building
11
+ bun run build # standalone app in ./build
10
12
  ```
11
13
 
12
14
  - `mirin.config.ts` — the app manifest (windows, ids).
13
15
  - `main/` — the Bun main process (RPC handlers, app lifecycle).
14
16
  - `ui/` — the React UI, served via the `app://` scheme in builds.
17
+ - `AGENTS.md` — how to inspect a running app (structured logs, screenshots, UI
18
+ snapshots), for AI coding tools and for you.
15
19
 
16
- Requires macOS arm64, Bun, and the Xcode command-line tools.
20
+ Requires Bun on macOS arm64, Windows x64/arm64, or Linux x64/arm64. macOS
21
+ distribution builds also need the Xcode command-line tools for signing.
@@ -1,11 +1,18 @@
1
- import { app } from "mirinjs";
1
+ import { app, devtools } from "mirinjs";
2
2
  import { router } from "./rpc.ts";
3
3
 
4
4
  const mirin = app.serve(router);
5
5
 
6
+ let count = 0;
7
+
8
+ // Publish main-process state to the devtools inspector's `/state`. A tool driving
9
+ // this app can read the page's DOM, but nothing here — the tick counter lives in
10
+ // the Bun process. The getter runs per read, so this is always current.
11
+ // See docs/agent-devtools.md; delete it if you don't want the visibility.
12
+ devtools.expose("app", () => ({ ticks: count, windows: app.windows.all().length }));
13
+
6
14
  app.on("ready", () => {
7
15
  // Push a typed tick to every webview once a second.
8
- let count = 0;
9
16
  setInterval(() => {
10
17
  mirin.rpc.tick.broadcast({ count: ++count });
11
18
  }, 1000);
@@ -4,6 +4,8 @@
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "mirin dev",
7
+ "check": "mirin check",
8
+ "doctor": "mirin doctor",
7
9
  "build": "mirin build"
8
10
  },
9
11
  "dependencies": {
@@ -1,5 +1,5 @@
1
- import { defineConfig } from "vite";
2
1
  import react from "@vitejs/plugin-react";
2
+ import { defineConfig } from "vite";
3
3
 
4
4
  export default defineConfig({
5
5
  root: ".",