getobsrv 0.7.1 → 0.8.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 +34 -1
- package/bin/install-skill.js +113 -0
- package/bin/obsrv.js +8 -0
- package/out/cli/args.js +2 -0
- package/out/main/cli.js +2 -0
- package/out/main/index.js +20 -0
- package/package.json +1 -1
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
|
|
@@ -70,7 +102,8 @@ npx -y getobsrv diff http://localhost:5173 --preset laptop-768 --out-dir diffout
|
|
|
70
102
|
`npx -y getobsrv --help` (or `node bin/obsrv.js --help` in a checkout) lists every preset, profile and flag. Diff findings
|
|
71
103
|
are informational (exit 0); CI thresholds are the caller's job. A ready-made
|
|
72
104
|
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)
|
|
105
|
+
fix → re-snap) lives at [skills/obsrv-screens/SKILL.md](skills/obsrv-screens/SKILL.md);
|
|
106
|
+
`obsrv install-skill` copies it into `~/.claude/skills/` so agents find it.
|
|
74
107
|
|
|
75
108
|
### MCP server
|
|
76
109
|
|
|
@@ -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
|
@@ -660,6 +660,7 @@ function registerIpc(ctx) {
|
|
|
660
660
|
});
|
|
661
661
|
electron.ipcMain.handle(IPC.setViewport, (e, width, height, rawDsf) => {
|
|
662
662
|
assertRenderer(e);
|
|
663
|
+
viewportArrived = true;
|
|
663
664
|
const dsf = parseDeviceScaleFactor(rawDsf);
|
|
664
665
|
if (dsf === null) throw new Error("invalid deviceScaleFactor");
|
|
665
666
|
const v = target.setViewport(width, height, dsf);
|
|
@@ -714,6 +715,7 @@ function registerIpc(ctx) {
|
|
|
714
715
|
waiter(report);
|
|
715
716
|
});
|
|
716
717
|
const scrollBoth = async (req) => {
|
|
718
|
+
await awaitViewportStable();
|
|
717
719
|
const base = { x: req.x, y: req.y };
|
|
718
720
|
if (req.selector !== void 0) base.selector = req.selector;
|
|
719
721
|
if (!native.webContents.isDestroyed()) native.webContents.send(IPC.applyScroll, base);
|
|
@@ -786,6 +788,18 @@ function registerIpc(ctx) {
|
|
|
786
788
|
}
|
|
787
789
|
}
|
|
788
790
|
});
|
|
791
|
+
let viewportPending = false;
|
|
792
|
+
let viewportArrived = false;
|
|
793
|
+
const VIEWPORT_ARRIVAL_MS = 600;
|
|
794
|
+
const awaitViewportStable = async () => {
|
|
795
|
+
if (!viewportPending) return;
|
|
796
|
+
viewportPending = false;
|
|
797
|
+
const arrivalDeadline = Date.now() + VIEWPORT_ARRIVAL_MS;
|
|
798
|
+
while (!viewportArrived && Date.now() < arrivalDeadline) {
|
|
799
|
+
await new Promise((r) => setTimeout(r, SETTLE_POLL_MS));
|
|
800
|
+
}
|
|
801
|
+
await settleTarget();
|
|
802
|
+
};
|
|
789
803
|
const SETTLE_POLL_MS = 80;
|
|
790
804
|
const SETTLE_STABLE_READS = 2;
|
|
791
805
|
const SETTLE_BUDGET_MS = 4e3;
|
|
@@ -880,6 +894,10 @@ function registerIpc(ctx) {
|
|
|
880
894
|
navigate: navigateBoth,
|
|
881
895
|
apply: (patch) => {
|
|
882
896
|
if (win.isDestroyed()) return;
|
|
897
|
+
if (patch.presetId !== void 0) {
|
|
898
|
+
viewportPending = true;
|
|
899
|
+
viewportArrived = false;
|
|
900
|
+
}
|
|
883
901
|
if (!rendererReported) {
|
|
884
902
|
if (pendingApplies.length >= MAX_PENDING_APPLIES) {
|
|
885
903
|
if (!warnedPendingOverflow) {
|
|
@@ -894,12 +912,14 @@ function registerIpc(ctx) {
|
|
|
894
912
|
win.webContents.send(IPC.agentApply, patch);
|
|
895
913
|
},
|
|
896
914
|
captureVisible: async () => {
|
|
915
|
+
await awaitViewportStable();
|
|
897
916
|
await settleTarget();
|
|
898
917
|
const image = await win.webContents.capturePage();
|
|
899
918
|
const size = image.getSize();
|
|
900
919
|
return { data: image.toPNG().toString("base64"), width: size.width, height: size.height };
|
|
901
920
|
},
|
|
902
921
|
captureTarget: async () => {
|
|
922
|
+
await awaitViewportStable();
|
|
903
923
|
const settled = await settleTarget();
|
|
904
924
|
const bounds = canvasBounds ?? targetBounds;
|
|
905
925
|
const known = bounds !== null && bounds.width >= 1 && bounds.height >= 1;
|