getobsrv 0.7.2 → 0.9.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 CHANGED
@@ -28,6 +28,38 @@ Both panes stay in lock-step (scroll, navigation) and the 1x pane is fully inter
28
28
  fit), and are shown at true physical size — usually a small, dense render on a
29
29
  desktop monitor, exactly like the phone in your hand.
30
30
 
31
+ ## Quickstart
32
+
33
+ **The desktop app** — download the DMG for your chip from
34
+ [Releases](https://github.com/vibesyemmy/obsrv/releases), drag Obsrv.app to
35
+ Applications, then clear the quarantine flag once (the build is not yet
36
+ notarised, so macOS falsely reports it as "damaged"):
37
+
38
+ ```bash
39
+ xattr -cr /Applications/Obsrv.app
40
+ ```
41
+
42
+ Open it and set your monitor's diagonal in Settings — that one number is what
43
+ makes the target pane render at true physical size.
44
+
45
+ **The CLI, and Claude Code / MCP clients:**
46
+
47
+ ```bash
48
+ npm i -g getobsrv # or use npx -y getobsrv
49
+ obsrv install-skill # teach Claude Code when to use it
50
+ claude mcp add --scope user obsrv -- npx -y getobsrv mcp # give it the tools
51
+ ```
52
+
53
+ `install-skill` copies the [obsrv-screens skill](skills/obsrv-screens/SKILL.md)
54
+ into `~/.claude/skills/` (`--dest` for elsewhere, `--print` to pipe it into
55
+ another agent framework). The skill is what makes an agent reach for Obsrv on
56
+ its own when frontend work needs checking; the MCP registration is what gives
57
+ it the tools to do so. New sessions pick both up.
58
+
59
+ **Both together** — install the app *and* the tools, then flip **Agent control**
60
+ on in the app's toolbar: agent testing now drives the window you are watching
61
+ instead of rendering invisibly.
62
+
31
63
  ## Use
32
64
 
33
65
  ```bash
@@ -47,6 +79,12 @@ middle-button drag, Option+drag or Option+wheel, or switch the toolbar's
47
79
  pixel-exact — the footer says so) and click anywhere in it to jump back to 1:1
48
80
  at that spot.
49
81
 
82
+ The `Both / Target` control beside it hides the native pane so the target render
83
+ takes the whole window — useful for a small mobile preset that would otherwise
84
+ sit in half a window, and for agent captures. The native pane stays loaded while
85
+ hidden, so the URL bar, back/forward and link clicks keep working exactly as
86
+ they do side by side.
87
+
50
88
  ## Agent & CI use
51
89
 
52
90
  The same rendering pipeline runs headless — no window, JSON on stdout, humans
@@ -70,7 +108,8 @@ npx -y getobsrv diff http://localhost:5173 --preset laptop-768 --out-dir diffout
70
108
  `npx -y getobsrv --help` (or `node bin/obsrv.js --help` in a checkout) lists every preset, profile and flag. Diff findings
71
109
  are informational (exit 0); CI thresholds are the caller's job. A ready-made
72
110
  Claude Code skill that wraps the loop (snap matrix → read the PNGs → diff →
73
- fix → re-snap) lives at [skills/obsrv-screens/SKILL.md](skills/obsrv-screens/SKILL.md).
111
+ fix → re-snap) lives at [skills/obsrv-screens/SKILL.md](skills/obsrv-screens/SKILL.md);
112
+ `obsrv install-skill` copies it into `~/.claude/skills/` so agents find it.
74
113
 
75
114
  ### MCP server
76
115
 
@@ -83,7 +122,9 @@ raster density — the PNG comes back as an inline image up to 1.5 MiB),
83
122
  If the desktop app is open with the toolbar's **Agent control** toggle on,
84
123
  `obsrv_snap` drives the *visible* window instead: you watch the URL load and
85
124
  the preset flip, and the agent gets back a capture of the app exactly as you
86
- see it (plus `obsrv_drive` to flip URL/preset/profile directly). Agents can
125
+ see it (plus `obsrv_drive` to flip URL/preset/profile/panes directly `panes:
126
+ 'target'` gives the target render the whole window, which is usually what you
127
+ want before a capture). Agents can
87
128
  also scroll, click, pan and highlight while you watch — a drive session works
88
129
  as a guided demo. A `scroll` reports the offset it actually reached
89
130
  (`scrolled` / `scroller`), finds the inner scroll container on pages whose
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ // `obsrv install-skill` — copy the packaged Claude Code skill into the user's
3
+ // skills directory, so an agent picks up the snap → look → diff → fix loop
4
+ // without being told about it. Plain Node: no Electron, no build needed.
5
+ 'use strict'
6
+
7
+ const { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } = require('node:fs')
8
+ const { homedir } = require('node:os')
9
+ const { join, resolve } = require('node:path')
10
+
11
+ const SKILL_NAME = 'obsrv-screens'
12
+ const source = join(__dirname, '..', 'skills', SKILL_NAME)
13
+
14
+ function usage() {
15
+ return `obsrv install-skill — install the ${SKILL_NAME} skill for Claude Code
16
+
17
+ Usage:
18
+ obsrv install-skill [flags]
19
+
20
+ Flags:
21
+ --dest <dir> Skills directory to install into (default ~/.claude/skills).
22
+ --force Overwrite an existing, different copy.
23
+ --print Write SKILL.md to stdout instead of installing.
24
+ --help Show this message.
25
+
26
+ Installs to <dest>/${SKILL_NAME}/. New Claude Code sessions pick the skill up;
27
+ sessions already running need a restart.`
28
+ }
29
+
30
+ /** Copies a directory tree. Shallow enough for a skill (SKILL.md + optional references). */
31
+ function copyTree(from, to) {
32
+ mkdirSync(to, { recursive: true })
33
+ for (const entry of readdirSync(from)) {
34
+ const src = join(from, entry)
35
+ const dst = join(to, entry)
36
+ if (statSync(src).isDirectory()) copyTree(src, dst)
37
+ else copyFileSync(src, dst)
38
+ }
39
+ }
40
+
41
+ /** True when every file in `from` exists in `to` with identical bytes. */
42
+ function sameTree(from, to) {
43
+ for (const entry of readdirSync(from)) {
44
+ const src = join(from, entry)
45
+ const dst = join(to, entry)
46
+ if (!existsSync(dst)) return false
47
+ if (statSync(src).isDirectory()) {
48
+ if (!statSync(dst).isDirectory() || !sameTree(src, dst)) return false
49
+ } else if (!readFileSync(src).equals(readFileSync(dst))) return false
50
+ }
51
+ return true
52
+ }
53
+
54
+ function main(argv) {
55
+ let dest = join(homedir(), '.claude', 'skills')
56
+ let force = false
57
+
58
+ for (let i = 0; i < argv.length; i++) {
59
+ const flag = argv[i]
60
+ if (flag === '--help' || flag === '-h') {
61
+ console.log(usage())
62
+ return 0
63
+ }
64
+ if (flag === '--print') {
65
+ process.stdout.write(readFileSync(join(source, 'SKILL.md'), 'utf8'))
66
+ return 0
67
+ }
68
+ if (flag === '--force') {
69
+ force = true
70
+ continue
71
+ }
72
+ if (flag === '--dest') {
73
+ const value = argv[++i]
74
+ if (!value) {
75
+ console.error('obsrv install-skill: --dest needs a directory')
76
+ return 2
77
+ }
78
+ dest = resolve(value)
79
+ continue
80
+ }
81
+ console.error(`obsrv install-skill: unknown flag: ${flag}\n\n${usage()}`)
82
+ return 2
83
+ }
84
+
85
+ if (!existsSync(source)) {
86
+ console.error(`obsrv install-skill: the packaged skill is missing (looked in ${source})`)
87
+ return 1
88
+ }
89
+
90
+ const target = join(dest, SKILL_NAME)
91
+ if (existsSync(target)) {
92
+ if (sameTree(source, target)) {
93
+ console.error(`obsrv install-skill: already up to date at ${target}`)
94
+ return 0
95
+ }
96
+ if (!force) {
97
+ console.error(
98
+ `obsrv install-skill: ${target} exists and differs — pass --force to overwrite it, ` +
99
+ 'or --dest to install elsewhere',
100
+ )
101
+ return 1
102
+ }
103
+ }
104
+
105
+ copyTree(source, target)
106
+ console.error(
107
+ `obsrv install-skill: installed to ${target}\n` +
108
+ 'New Claude Code sessions will pick it up; restart any session already running.',
109
+ )
110
+ return 0
111
+ }
112
+
113
+ process.exit(main(process.argv.slice(2)))
package/bin/obsrv.js CHANGED
@@ -20,6 +20,14 @@ if (process.argv[2] === 'mcp') {
20
20
  return
21
21
  }
22
22
 
23
+ // `obsrv install-skill` copies the packaged Claude Code skill into the user's
24
+ // skills directory. Also plain node — it never renders anything.
25
+ if (process.argv[2] === 'install-skill') {
26
+ process.argv.splice(2, 1)
27
+ require('./install-skill.js')
28
+ return
29
+ }
30
+
23
31
  const cliEntry = join(__dirname, '..', 'out', 'main', 'cli.js')
24
32
  if (!existsSync(cliEntry)) {
25
33
  console.error('obsrv: out/main/cli.js is missing — run `npm run build` in the Obsrv repo first')
package/out/cli/args.js CHANGED
@@ -22,6 +22,8 @@ function usage() {
22
22
  Usage:
23
23
  obsrv snap <url> [flags] Render <url> on a target screen; write a PNG, print JSON.
24
24
  obsrv diff <url> [flags] Render <url> at 1x and against a 2x reference; print JSON metrics.
25
+ obsrv mcp Serve the MCP server on stdio (for Claude Code and other clients).
26
+ obsrv install-skill Install the obsrv-screens skill for Claude Code (--help for flags).
25
27
 
26
28
  Shared flags:
27
29
  --preset <id> Screen preset (default ${exports.DEFAULT_PRESET}):
package/out/main/cli.js CHANGED
@@ -53,6 +53,8 @@ function usage() {
53
53
  Usage:
54
54
  obsrv snap <url> [flags] Render <url> on a target screen; write a PNG, print JSON.
55
55
  obsrv diff <url> [flags] Render <url> at 1x and against a 2x reference; print JSON metrics.
56
+ obsrv mcp Serve the MCP server on stdio (for Claude Code and other clients).
57
+ obsrv install-skill Install the obsrv-screens skill for Claude Code (--help for flags).
56
58
 
57
59
  Shared flags:
58
60
  --preset <id> Screen preset (default ${DEFAULT_PRESET}):
package/out/main/index.js CHANGED
@@ -14,6 +14,7 @@ const IPC = {
14
14
  forward: "obsrv:forward",
15
15
  setViewport: "obsrv:set-viewport",
16
16
  setNativeBounds: "obsrv:set-native-bounds",
17
+ setNativeVisible: "obsrv:set-native-visible",
17
18
  setMode: "obsrv:set-mode",
18
19
  sendInput: "obsrv:send-input",
19
20
  getHostInfo: "obsrv:get-host-info",
@@ -26,6 +27,7 @@ const IPC = {
26
27
  hostChanged: "obsrv:host-changed",
27
28
  targetLoading: "obsrv:target-loading",
28
29
  targetNavigating: "obsrv:target-navigating",
30
+ nativeFocused: "obsrv:native-focused",
29
31
  syncScroll: "obsrv:sync-scroll",
30
32
  applyScroll: "obsrv:apply-scroll",
31
33
  scrollResult: "obsrv:scroll-result",
@@ -157,10 +159,13 @@ function parseUiState(raw) {
157
159
  if (typeof profileId !== "string" || profileId.length === 0 || profileId.length > MAX_UI_ID) return null;
158
160
  if (viewMode !== "1:1" && viewMode !== "fit") return null;
159
161
  if (mode !== "url" && mode !== "image") return null;
162
+ const panes = raw.panes ?? "both";
163
+ if (panes !== "both" && panes !== "target") return null;
160
164
  return {
161
165
  presetId,
162
166
  profileId,
163
167
  viewMode,
168
+ panes,
164
169
  mode,
165
170
  targetBounds: parseRect(raw.targetBounds),
166
171
  canvasBounds: parseRect(raw.canvasBounds)
@@ -200,6 +205,7 @@ const CONTROL_COMMANDS = [
200
205
  "setPreset",
201
206
  "setProfile",
202
207
  "setViewMode",
208
+ "setPanes",
203
209
  "captureVisible",
204
210
  // v0.5 drive controls (spec §14 "Drive controls").
205
211
  "scroll",
@@ -244,6 +250,9 @@ function profileApplyError(id) {
244
250
  function viewModeApplyError(v) {
245
251
  return v === "1:1" || v === "fit" ? null : `setViewMode payload must be { mode: '1:1' | 'fit' }`;
246
252
  }
253
+ function panesApplyError(v) {
254
+ return v === "both" || v === "target" ? null : `setPanes payload must be { panes: 'both' | 'target' }`;
255
+ }
247
256
  function pixelExactApplyError(v) {
248
257
  return typeof v === "boolean" ? null : "setPixelExact payload must be { on: boolean }";
249
258
  }
@@ -466,6 +475,12 @@ class ControlServer {
466
475
  const mode = payload.mode;
467
476
  return this.applyAndConfirm({ viewMode: mode }, (s) => s.viewMode === mode);
468
477
  }
478
+ case "setPanes": {
479
+ const err = panesApplyError(payload.panes);
480
+ if (err) return reply(400, { error: err });
481
+ const panes = payload.panes;
482
+ return this.applyAndConfirm({ panes }, (s) => s.panes === panes);
483
+ }
469
484
  case "captureVisible": {
470
485
  const capture = await this.deps.captureVisible();
471
486
  return reply(200, { ok: true, ...capture });
@@ -603,7 +618,7 @@ async function checkForUpdate(current, now) {
603
618
  request.end();
604
619
  });
605
620
  }
606
- const TOOLBAR_H = 44;
621
+ const TOOLBAR_H = 82;
607
622
  const MAX_IMAGE_FILE_BYTES = 64 * 1024 * 1024;
608
623
  const SCROLL_REPLY_TIMEOUT_MS = 1e3;
609
624
  function hostInfo(win) {
@@ -675,13 +690,24 @@ function registerIpc(ctx) {
675
690
  } catch {
676
691
  }
677
692
  });
693
+ let modeIsLive = true;
694
+ let panesShowNative = true;
695
+ const applyNativeVisibility = () => {
696
+ native.setVisible(modeIsLive && panesShowNative);
697
+ };
678
698
  electron.ipcMain.on(IPC.setMode, (e, raw) => {
679
699
  if (!fromRenderer(e)) return;
680
700
  const mode = parseMode(raw);
681
701
  if (!mode) return;
682
- const live = mode === "url";
683
- native.setVisible(live);
684
- bus.setEnabled(live);
702
+ modeIsLive = mode === "url";
703
+ applyNativeVisibility();
704
+ bus.setEnabled(modeIsLive);
705
+ });
706
+ electron.ipcMain.on(IPC.setNativeVisible, (e, raw) => {
707
+ if (!fromRenderer(e)) return;
708
+ if (typeof raw !== "boolean") return;
709
+ panesShowNative = raw;
710
+ applyNativeVisibility();
685
711
  });
686
712
  let rendererDrivesLayout = false;
687
713
  const fallbackLayout = () => {
@@ -766,7 +792,7 @@ function registerIpc(ctx) {
766
792
  settings = s;
767
793
  if (s.agentControl !== wasEnabled) applyAgentControl(s.agentControl);
768
794
  });
769
- const uiState = { presetId: "1080p-24", profileId: "reference", viewMode: "1:1", mode: "url" };
795
+ const uiState = { presetId: "1080p-24", profileId: "reference", viewMode: "1:1", panes: "both", mode: "url" };
770
796
  let targetBounds = null;
771
797
  let canvasBounds = null;
772
798
  const MAX_PENDING_APPLIES = 32;
@@ -1213,6 +1239,9 @@ function boot() {
1213
1239
  if (!win.isDestroyed()) win.webContents.send(IPC.openImagePath, path);
1214
1240
  }
1215
1241
  });
1242
+ native.webContents.on("focus", () => {
1243
+ if (!win.isDestroyed()) win.webContents.send(IPC.nativeFocused);
1244
+ });
1216
1245
  const target = new targetSource.TargetSource();
1217
1246
  target.on("load-error", (err) => {
1218
1247
  if (!win.isDestroyed()) win.webContents.send(IPC.loadError, err);
@@ -1227,7 +1256,7 @@ function boot() {
1227
1256
  const sync = attachSyncBus(native, target, (url) => {
1228
1257
  if (!win.isDestroyed()) win.webContents.send(IPC.urlChanged, url);
1229
1258
  });
1230
- const ctx = { win, native, target, bus, sync };
1259
+ const ctx = { win, native, target, bus, sync, toolbarH: TOOLBAR_H };
1231
1260
  registerIpc(ctx);
1232
1261
  installMenu(ctx);
1233
1262
  win.on("close", () => {
package/out/mcp/server.js CHANGED
@@ -160,6 +160,7 @@ const snapOutputShape = {
160
160
  presetId: zod_1.z.string().optional().describe('Live only: the screen preset selected in the app.'),
161
161
  profileId: zod_1.z.string().optional().describe('Live only: the panel profile selected in the app.'),
162
162
  viewMode: zod_1.z.string().optional().describe("Live only: the app's target-pane view (1:1 or fit)."),
163
+ panes: zod_1.z.string().optional().describe("Live only: 'both' (native pane beside the target) or 'target' (the target render has the whole window)."),
163
164
  width: zod_1.z
164
165
  .number()
165
166
  .optional()
@@ -245,6 +246,10 @@ const driveInputShape = {
245
246
  preset: zod_1.z.enum(PRESET_IDS).optional().describe('Apply this screen preset, exactly as clicking the toolbar would.'),
246
247
  profile: zod_1.z.enum(PROFILE_IDS).optional().describe('Apply this panel profile in the app.'),
247
248
  viewMode: zod_1.z.enum(['1:1', 'fit']).optional().describe("Switch the app's target pane between 1:1 (actual size) and fit."),
249
+ panes: zod_1.z
250
+ .enum(['both', 'target'])
251
+ .optional()
252
+ .describe("Show both panes, or give the target render the whole window ('target'). Solo target is usually what you want before a capture."),
248
253
  pixelExact: zod_1.z.boolean().optional().describe("Toggle the toolbar's pixel-exact checkbox (pins the magnification to the host scale)."),
249
254
  focus: zod_1.z.boolean().optional().describe('true: bring the Obsrv window to the front first, so the user sees what follows.'),
250
255
  reload: zod_1.z.boolean().optional().describe('true: reload both panes (the same action as the toolbar reload).'),
@@ -303,6 +308,7 @@ const driveOutputShape = {
303
308
  presetId: zod_1.z.string(),
304
309
  profileId: zod_1.z.string(),
305
310
  viewMode: zod_1.z.string(),
311
+ panes: zod_1.z.string(),
306
312
  mode: zod_1.z.string().describe("The app's pane mode: 'url' (live page) or 'image' (a dropped design export)."),
307
313
  scrolled: zod_1.z
308
314
  .object({ x: zod_1.z.number(), y: zod_1.z.number() })
@@ -475,6 +481,7 @@ async function liveSnap(app, input, notes) {
475
481
  presetId: status.presetId,
476
482
  profileId: status.profileId,
477
483
  viewMode: status.viewMode,
484
+ panes: status.panes,
478
485
  width,
479
486
  height,
480
487
  settled,
@@ -620,7 +627,7 @@ server.registerTool('obsrv_drive', {
620
627
  `both panes, pan the target pane to a pixel, click the live page, and highlight a rect with a temporary ` +
621
628
  `neutral marker, all while the user watches.\n\n` +
622
629
  `Only the supplied inputs run (none = just read the current state), in this fixed order: focus → url → ` +
623
- `preset → profile → viewMode → pixelExact → reload → back → forward → scroll → panTo → click → highlight → ` +
630
+ `preset → profile → viewMode → panes → pixelExact → reload → back → forward → scroll → panTo → click → highlight → ` +
624
631
  `capture. ` +
625
632
  `The result is the final status: app version, the URL showing, and the selected preset/profile/view. A ` +
626
633
  `click that navigates is reflected in that status — the call waits briefly (up to 2 s) for the commit. A ` +
@@ -664,6 +671,9 @@ server.registerTool('obsrv_drive', {
664
671
  if (input.viewMode !== undefined) {
665
672
  await (0, control_2.controlCall)(live.info, 'setViewMode', { mode: input.viewMode }, LIVE_APPLY_TIMEOUT_MS);
666
673
  }
674
+ if (input.panes !== undefined) {
675
+ await (0, control_2.controlCall)(live.info, 'setPanes', { panes: input.panes }, LIVE_APPLY_TIMEOUT_MS);
676
+ }
667
677
  if (input.pixelExact !== undefined) {
668
678
  await (0, control_2.controlCall)(live.info, 'setPixelExact', { on: input.pixelExact }, LIVE_APPLY_TIMEOUT_MS);
669
679
  }
@@ -7,6 +7,7 @@ const IPC = {
7
7
  forward: "obsrv:forward",
8
8
  setViewport: "obsrv:set-viewport",
9
9
  setNativeBounds: "obsrv:set-native-bounds",
10
+ setNativeVisible: "obsrv:set-native-visible",
10
11
  setMode: "obsrv:set-mode",
11
12
  sendInput: "obsrv:send-input",
12
13
  getHostInfo: "obsrv:get-host-info",
@@ -19,6 +20,7 @@ const IPC = {
19
20
  hostChanged: "obsrv:host-changed",
20
21
  targetLoading: "obsrv:target-loading",
21
22
  targetNavigating: "obsrv:target-navigating",
23
+ nativeFocused: "obsrv:native-focused",
22
24
  openImage: "obsrv:open-image",
23
25
  focusUrl: "obsrv:focus-url",
24
26
  openImagePath: "obsrv:open-image-path",
@@ -57,6 +59,7 @@ const api = {
57
59
  forward: () => electron.ipcRenderer.send(IPC.forward),
58
60
  setViewport: (width, height, deviceScaleFactor) => electron.ipcRenderer.invoke(IPC.setViewport, width, height, deviceScaleFactor),
59
61
  setNativeBounds: (rect) => electron.ipcRenderer.send(IPC.setNativeBounds, rect),
62
+ setNativeVisible: (visible) => electron.ipcRenderer.send(IPC.setNativeVisible, visible),
60
63
  setMode: (mode) => electron.ipcRenderer.send(IPC.setMode, mode),
61
64
  sendInput: (ev) => electron.ipcRenderer.send(IPC.sendInput, ev),
62
65
  getHostInfo: () => electron.ipcRenderer.invoke(IPC.getHostInfo),
@@ -67,6 +70,13 @@ const api = {
67
70
  onLoadError: (cb) => subscribe(IPC.loadError, cb),
68
71
  onHostChanged: (cb) => subscribe(IPC.hostChanged, cb),
69
72
  onTargetLoading: (cb) => subscribe(IPC.targetLoading, cb),
73
+ onNativeFocused: (cb) => {
74
+ const listener = () => cb();
75
+ electron.ipcRenderer.on(IPC.nativeFocused, listener);
76
+ return () => {
77
+ electron.ipcRenderer.removeListener(IPC.nativeFocused, listener);
78
+ };
79
+ },
70
80
  onTargetNavigating: (cb) => {
71
81
  const listener = () => cb();
72
82
  electron.ipcRenderer.on(IPC.targetNavigating, listener);