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.
@@ -0,0 +1,6 @@
1
+ [
2
+ { "tap": "Accessibility" },
3
+ { "tap": "Display & Text Size" },
4
+ { "tap": "Larger Text" },
5
+ { "assert": { "value": "Larger Text" } }
6
+ ]
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 version in step with package.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 server.json so the version commit contains both files.
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
- // Stage it, so `npm version` commits both files together. Harmless when run
34
- // with --no-git-tag-version; the file is still correct either way.
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 it yourself)');
51
+ console.log('(could not stage server.json and plugin.json — commit them yourself)');
39
52
  }
@@ -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 —