simframe 0.18.0 → 0.19.1
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 +132 -1101
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +12 -6
- package/native/simframed/Sources/simframed/main.swift +18 -2
- package/package.json +1 -1
- package/scripts/article-md.mjs +111 -45
- package/scripts/bench-hpi.mjs +45 -1
- package/scripts/ci-memory.mjs +33 -6
- package/scripts/demo-gif/README.md +36 -0
- package/scripts/demo-gif/compose.swift +106 -0
- package/scripts/demo-gif/events.example.json +74 -0
- package/scripts/demo-gif/flow.json +6 -0
- package/scripts/smithery/icon.png +0 -0
- package/scripts/smithery-bundle.mjs +80 -0
- package/scripts/smithery-metadata.mjs +47 -0
- package/scripts/sync-server-version.mjs +19 -6
- package/skills/simframe/SKILL.md +7 -0
- package/src/actions.js +339 -33
- package/src/cli.js +46 -27
- package/src/device-state.js +37 -0
- package/src/index.js +144 -12
- package/src/mcp.js +2 -2
- package/src/platform/cdp.js +242 -0
- package/src/platform/index.js +28 -2
- package/src/refs.js +18 -4
- package/src/store.js +39 -0
- package/src/view.js +6 -2
- package/src/wedge.js +154 -2
|
Binary file
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Builds the Smithery bundle for the current version:
|
|
3
|
+
// node scripts/smithery-bundle.mjs -> simframe-<version>.mcpb
|
|
4
|
+
// npx -y @smithery/cli mcp publish ./simframe-<version>.mcpb -n lvlr-xaus/simframe
|
|
5
|
+
//
|
|
6
|
+
// Smithery does not sync from the official MCP Registry, and its web form
|
|
7
|
+
// takes only an HTTPS URL, so a local stdio server reaches it as an MCPB
|
|
8
|
+
// bundle through the CLI. Two things learned publishing 0.18.0, both
|
|
9
|
+
// load-bearing:
|
|
10
|
+
//
|
|
11
|
+
// - Smithery's validator wants an `inputSchema` on every tool it is told
|
|
12
|
+
// about, and Anthropic's `mcpb pack` refuses that key as unknown. So the
|
|
13
|
+
// tool list is taken from the server's own tools/list answer, schemas
|
|
14
|
+
// included, and the archive is a plain zip — a .mcpb is nothing else.
|
|
15
|
+
// - The bundle is the packed npm tarball plus its runtime dependency
|
|
16
|
+
// installed inside it, because a bundle runs from its own directory.
|
|
17
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
25
|
+
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'simframe-mcpb-'));
|
|
26
|
+
const dir = path.join(work, 'pkg');
|
|
27
|
+
fs.mkdirSync(dir);
|
|
28
|
+
|
|
29
|
+
const tarball = execFileSync('npm', ['pack', '--silent', '--pack-destination', work], { cwd: root }).toString().trim();
|
|
30
|
+
execFileSync('tar', ['-xzf', path.join(work, tarball), '-C', dir, '--strip-components=1']);
|
|
31
|
+
execFileSync('npm', ['install', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund', '--silent'], { cwd: dir, stdio: 'inherit' });
|
|
32
|
+
fs.copyFileSync(path.join(root, 'scripts/smithery/icon.png'), path.join(dir, 'icon.png'));
|
|
33
|
+
|
|
34
|
+
// The tool list, with schemas, from the server that ships in the bundle.
|
|
35
|
+
const tools = await new Promise((resolve, reject) => {
|
|
36
|
+
const child = spawn('node', [path.join(dir, 'src/cli.js'), 'mcp'], { cwd: dir, stdio: ['pipe', 'pipe', 'ignore'] });
|
|
37
|
+
let out = '';
|
|
38
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
39
|
+
child.on('close', () => {
|
|
40
|
+
const line = out.split('\n').find((l) => l.includes('"id":2'));
|
|
41
|
+
line ? resolve(JSON.parse(line).result.tools) : reject(new Error('the bundled server did not answer tools/list'));
|
|
42
|
+
});
|
|
43
|
+
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'bundle', version: '0' } } }) + '\n');
|
|
44
|
+
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
|
|
45
|
+
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }) + '\n');
|
|
46
|
+
child.stdin.end();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const firstSentence = (s) => s.trim().split(/(?<=[.!?])\s/)[0].slice(0, 200);
|
|
50
|
+
const manifest = {
|
|
51
|
+
manifest_version: '0.3',
|
|
52
|
+
name: 'simframe',
|
|
53
|
+
display_name: 'simframe',
|
|
54
|
+
version: pkg.version,
|
|
55
|
+
description: 'Eyes, hands and memory for a coding agent driving the iOS Simulator or an Android emulator.',
|
|
56
|
+
long_description: 'simframe reads the simulator screen as a numbered text element map with tap points (accessibility tree + on-device OCR, ~20 ms warm frames instead of screenshots), runs whole tap/type/scroll/assert flows in one call with every step verified against what it did last time, and remembers screens so repeated flows need no model calls. Needs a Mac with Xcode; the first call builds a small Swift daemon from source (~15 s, once). Android emulators are driven with the same tools, without an accessibility tree.',
|
|
57
|
+
author: { name: 'Sadjad Asadi', url: 'https://github.com/lvlrSajjad' },
|
|
58
|
+
repository: { type: 'git', url: 'https://github.com/lvlrSajjad/simframe.git' },
|
|
59
|
+
homepage: 'https://lvlrsajjad.github.io/simframe/',
|
|
60
|
+
documentation: 'https://github.com/lvlrSajjad/simframe#readme',
|
|
61
|
+
support: 'https://github.com/lvlrSajjad/simframe/issues',
|
|
62
|
+
icon: 'icon.png',
|
|
63
|
+
server: { type: 'node', entry_point: 'src/cli.js', mcp_config: { command: 'node', args: ['${__dirname}/src/cli.js', 'mcp'] } },
|
|
64
|
+
tools: tools.map((t) => ({ name: t.name, description: firstSentence(t.description ?? ''), inputSchema: t.inputSchema ?? { type: 'object', properties: {} } })),
|
|
65
|
+
tools_generated: false,
|
|
66
|
+
keywords: pkg.keywords,
|
|
67
|
+
license: 'MIT',
|
|
68
|
+
compatibility: { platforms: ['darwin'], runtimes: { node: '>=18.17' } },
|
|
69
|
+
};
|
|
70
|
+
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
71
|
+
|
|
72
|
+
const out = path.join(root, `simframe-${pkg.version}.mcpb`);
|
|
73
|
+
// One bundle at a time. A stale one from the previous version sitting next to
|
|
74
|
+
// the new one got published in its place once, by a command line that named
|
|
75
|
+
// the old file.
|
|
76
|
+
for (const f of fs.readdirSync(root)) if (/^simframe-.*\.mcpb$/.test(f)) fs.rmSync(path.join(root, f), { force: true });
|
|
77
|
+
execFileSync('zip', ['-qr', out, '.', '-x', '*.DS_Store'], { cwd: dir });
|
|
78
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
79
|
+
console.log(`${path.relative(root, out)}: ${tools.length} tools, ${(fs.statSync(out).size / 1e6).toFixed(1)} MB`);
|
|
80
|
+
console.log(`publish: npx -y @smithery/cli mcp publish ./${path.relative(root, out)} -n lvlr-xaus/simframe`);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Sets the simframe listing's metadata on Smithery — description, links,
|
|
3
|
+
// license and icon — which the bundle publish does not carry.
|
|
4
|
+
// node scripts/smithery-metadata.mjs
|
|
5
|
+
//
|
|
6
|
+
// The API key is the one `npx @smithery/cli` stored when it asked for it;
|
|
7
|
+
// SMITHERY_API_KEY in the environment wins if set. Nothing is printed but
|
|
8
|
+
// the server's answers.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
15
|
+
const server = 'lvlr-xaus/simframe';
|
|
16
|
+
|
|
17
|
+
function apiKey() {
|
|
18
|
+
if (process.env.SMITHERY_API_KEY) return process.env.SMITHERY_API_KEY;
|
|
19
|
+
const dir = process.env.SMITHERY_CONFIG_PATH
|
|
20
|
+
?? (process.platform === 'darwin' ? path.join(os.homedir(), 'Library', 'Application Support', 'smithery') : path.join(os.homedir(), '.config', 'smithery'));
|
|
21
|
+
const file = path.join(dir, 'settings.json');
|
|
22
|
+
const key = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')).apiKey : null;
|
|
23
|
+
if (!key) throw new Error(`no Smithery API key: set SMITHERY_API_KEY or run any \`npx @smithery/cli mcp publish\` once so it stores one in ${file}`);
|
|
24
|
+
return key;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const headers = { Authorization: `Bearer ${apiKey()}` };
|
|
28
|
+
const base = `https://api.smithery.ai/servers/${encodeURIComponent(server)}`;
|
|
29
|
+
|
|
30
|
+
const meta = await fetch(base, {
|
|
31
|
+
method: 'PATCH',
|
|
32
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
33
|
+
body: JSON.stringify({
|
|
34
|
+
displayName: 'simframe',
|
|
35
|
+
description: 'Eyes, hands and memory for a coding agent driving the iOS Simulator or an Android emulator. Reads the screen as text with tap points, runs whole flows in one call with every step verified, and remembers screens so repeated flows need no model calls.',
|
|
36
|
+
homepage: 'https://lvlrsajjad.github.io/simframe/',
|
|
37
|
+
repositoryUrl: 'https://github.com/lvlrSajjad/simframe',
|
|
38
|
+
backlinkUrl: 'https://lvlrsajjad.github.io/simframe/agents-shouldnt-blink.html',
|
|
39
|
+
license: 'MIT',
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
console.log(`metadata: ${meta.status} ${await meta.text()}`);
|
|
43
|
+
|
|
44
|
+
const icon = new FormData();
|
|
45
|
+
icon.append('icon', new Blob([fs.readFileSync(path.join(root, 'scripts/smithery/icon.png'))], { type: 'image/png' }), 'icon.png');
|
|
46
|
+
const up = await fetch(`${base}/icon`, { method: 'PUT', headers, body: icon });
|
|
47
|
+
console.log(`icon: ${up.status} ${await up.text()}`);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Keep server.json's
|
|
2
|
+
// Keep server.json's and the Claude Code plugin's versions in step with
|
|
3
|
+
// package.json's.
|
|
3
4
|
//
|
|
4
5
|
// `npm version` only knows about package.json, and the MCP registry manifest
|
|
5
6
|
// carries the version twice — once at the top level and once inside the package
|
|
@@ -8,7 +9,12 @@
|
|
|
8
9
|
// the workflow's own agreement check.
|
|
9
10
|
//
|
|
10
11
|
// npm runs this as the `version` lifecycle script: after the bump, before the
|
|
11
|
-
// commit. It stages
|
|
12
|
+
// commit. It stages both files so the version commit contains all three.
|
|
13
|
+
//
|
|
14
|
+
// The plugin manifest is the second file for the same reason server.json is:
|
|
15
|
+
// a `version` in .claude-plugin/plugin.json pins every installed copy to it, so
|
|
16
|
+
// a manifest left behind by one release would hold plugin users on the
|
|
17
|
+
// previous release's skill forever, with nothing failing.
|
|
12
18
|
import { execFileSync } from 'node:child_process';
|
|
13
19
|
import fs from 'node:fs';
|
|
14
20
|
import path from 'node:path';
|
|
@@ -30,10 +36,17 @@ server.packages[0].version = pkg.version;
|
|
|
30
36
|
fs.writeFileSync(file, `${JSON.stringify(server, null, 2)}\n`);
|
|
31
37
|
console.log(`server.json ${before.top} / ${before.pkg} -> ${pkg.version} / ${pkg.version}`);
|
|
32
38
|
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
const pluginFile = path.join(ROOT, '.claude-plugin', 'plugin.json');
|
|
40
|
+
const plugin = JSON.parse(fs.readFileSync(pluginFile, 'utf8'));
|
|
41
|
+
const pluginBefore = plugin.version;
|
|
42
|
+
plugin.version = pkg.version;
|
|
43
|
+
fs.writeFileSync(pluginFile, `${JSON.stringify(plugin, null, 2)}\n`);
|
|
44
|
+
console.log(`.claude-plugin/plugin.json ${pluginBefore} -> ${pkg.version}`);
|
|
45
|
+
|
|
46
|
+
// Stage them, so `npm version` commits every file together. Harmless when run
|
|
47
|
+
// with --no-git-tag-version; the files are still correct either way.
|
|
35
48
|
try {
|
|
36
|
-
execFileSync('git', ['add', '--', file], { cwd: ROOT, stdio: 'pipe' });
|
|
49
|
+
execFileSync('git', ['add', '--', file, pluginFile], { cwd: ROOT, stdio: 'pipe' });
|
|
37
50
|
} catch {
|
|
38
|
-
console.log('(could not stage server.json — commit
|
|
51
|
+
console.log('(could not stage server.json and plugin.json — commit them yourself)');
|
|
39
52
|
}
|
package/skills/simframe/SKILL.md
CHANGED
|
@@ -18,6 +18,13 @@ and CV alone. Tapping by label works; screen recognition is thinner, so prefer
|
|
|
18
18
|
naming a device explicitly and re-reading the screen after a step you are unsure
|
|
19
19
|
about.
|
|
20
20
|
|
|
21
|
+
Commands are written here as the CLI (`simframe ui`, `simframe do`), which is
|
|
22
|
+
the cheapest path. If `simframe` is not on your PATH — installed as a Claude Code
|
|
23
|
+
plugin, say — the MCP server is already connected and the screen and flow
|
|
24
|
+
commands are its tools under the same names (`sim_ui`, `sim_do`, `sim_state`,
|
|
25
|
+
`sim_goto`…). Use those; for `doctor` and the other diagnostics, ask the user
|
|
26
|
+
to run `npm install -g simframe`.
|
|
27
|
+
|
|
21
28
|
## The protocol: plan once, execute once, think only when told to
|
|
22
29
|
|
|
23
30
|
The expensive thing in a simulator session is not the tapping. It is you —
|