esque-bridge 0.1.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 +83 -0
- package/index.js +351 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# esque-bridge
|
|
2
|
+
|
|
3
|
+
Desktop-side receiver for the [Esque Agent](https://esque.app) iOS app. Pairs your phone with the local `claude` CLI so prompts you send from the app run through your **Claude Pro / Max subscription** rather than per-token API billing.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
1. Boots an Express server on localhost
|
|
8
|
+
2. Opens a public localtunnel URL to that port
|
|
9
|
+
3. Prints a QR code in your terminal embedding `esque://pair?url=…&secret=…`
|
|
10
|
+
4. Accepts `POST /execute { prompt, sessionId }` (pairing-secret-gated)
|
|
11
|
+
5. Runs each prompt through `claude --print --output-format json`
|
|
12
|
+
6. Returns Claude's response to the phone, persisting the Claude session id so the next prompt continues the same conversation
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
You'll need:
|
|
17
|
+
|
|
18
|
+
- **Node.js 18 or newer** — https://nodejs.org
|
|
19
|
+
- **Claude Code CLI** logged into your Pro/Max account:
|
|
20
|
+
```bash
|
|
21
|
+
npm install -g @anthropic-ai/claude-code
|
|
22
|
+
claude /login
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Then either run via `npx` (no install) or globally:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx esque-bridge # zero-install
|
|
29
|
+
# — or —
|
|
30
|
+
npm install -g esque-bridge
|
|
31
|
+
esque-bridge
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Run
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# from the directory you want Claude to edit:
|
|
38
|
+
esque-bridge
|
|
39
|
+
|
|
40
|
+
# point at a different repo without cd:
|
|
41
|
+
esque-bridge --workdir ~/my-project
|
|
42
|
+
|
|
43
|
+
# pick a non-default port:
|
|
44
|
+
esque-bridge --port 4000
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The terminal will print a QR code. Open the **Esque Agent** iOS app, tap **Pair Bridge**, and scan the code. That's it.
|
|
48
|
+
|
|
49
|
+
## Flags
|
|
50
|
+
|
|
51
|
+
| Flag | Default | Purpose |
|
|
52
|
+
| ------------- | ------------ | ----------------------------------------- |
|
|
53
|
+
| `--port` | `3030` | Local HTTP port |
|
|
54
|
+
| `--workdir` | `$(pwd)` | Directory Claude runs in |
|
|
55
|
+
| `--bin` | `claude` | Path to the Claude CLI binary |
|
|
56
|
+
| `--timeout` | `300000` | Per-prompt timeout (ms) |
|
|
57
|
+
| `--subdomain` | _(auto)_ | Stable localtunnel subdomain (optional) |
|
|
58
|
+
|
|
59
|
+
## Security notes
|
|
60
|
+
|
|
61
|
+
- The pairing secret is regenerated every time the bridge starts and never persisted. If you stop and restart, you must re-pair the phone. This is deliberate — a leaked tunnel URL alone can't drive your Claude session.
|
|
62
|
+
- The bridge accepts a no-auth `GET /` and `POST / {_probe: true}` for the Esque app's connection-test only. Every other request requires the secret in the `X-Esque-Pair` header.
|
|
63
|
+
- `spawn(claude, [...args])` is used so the prompt body can never inject shell commands.
|
|
64
|
+
|
|
65
|
+
## Troubleshooting
|
|
66
|
+
|
|
67
|
+
**`'claude' not found in PATH`** — install the CLI: `npm install -g @anthropic-ai/claude-code`, then `claude /login` to authenticate.
|
|
68
|
+
|
|
69
|
+
**`Failed to open localtunnel`** — your network is blocking localtunnel.me. Run cloudflared instead:
|
|
70
|
+
```bash
|
|
71
|
+
cloudflared tunnel --url http://localhost:3030
|
|
72
|
+
```
|
|
73
|
+
…and paste that URL into the Esque app manually (skip the QR).
|
|
74
|
+
|
|
75
|
+
**Long prompts time out** — bump `--timeout 600000` (10 min).
|
|
76
|
+
|
|
77
|
+
## Session continuity
|
|
78
|
+
|
|
79
|
+
Each Esque conversation maps to a Claude session id. The map is persisted to `~/.esque-bridge-sessions.json` so restarting the bridge doesn't lose context. Delete that file to wipe history.
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* esque-bridge — the desktop-side receiver for the Esque Agent mobile app.
|
|
4
|
+
*
|
|
5
|
+
* Pairs your phone with the local `claude` CLI so prompts sent from the
|
|
6
|
+
* Esque iOS app run through your Claude Pro / Max subscription rather than
|
|
7
|
+
* per-token API billing. The bridge:
|
|
8
|
+
*
|
|
9
|
+
* 1. Boots an Express server on localhost
|
|
10
|
+
* 2. Opens a public localtunnel URL to that port
|
|
11
|
+
* 3. Prints a QR code embedding `esque://pair?url=…&secret=…` in your
|
|
12
|
+
* terminal — scan it from the Esque app to pair
|
|
13
|
+
* 4. Accepts POST /execute { prompt, sessionId } (pairing-secret-gated)
|
|
14
|
+
* and runs the prompt through `claude --print`, returning stdout
|
|
15
|
+
*
|
|
16
|
+
* Run it:
|
|
17
|
+
* npx esque-bridge # zero-config
|
|
18
|
+
* esque-bridge --workdir ~/my-project # point claude at a repo
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const express = require('express');
|
|
22
|
+
const localtunnel = require('localtunnel');
|
|
23
|
+
const qrcode = require('qrcode-terminal');
|
|
24
|
+
const { spawn } = require('child_process');
|
|
25
|
+
const crypto = require('crypto');
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const os = require('os');
|
|
29
|
+
|
|
30
|
+
// --- Config ---------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
const argv = parseArgs(process.argv.slice(2));
|
|
33
|
+
|
|
34
|
+
if (argv.help || argv.h) {
|
|
35
|
+
console.log(
|
|
36
|
+
`
|
|
37
|
+
Esque Bridge — pair your phone with your local Claude Code.
|
|
38
|
+
|
|
39
|
+
USAGE
|
|
40
|
+
esque-bridge [--port 3030] [--workdir .] [--bin claude] [--timeout 300000]
|
|
41
|
+
|
|
42
|
+
OPTIONS
|
|
43
|
+
--port Local HTTP port to listen on (default: 3030)
|
|
44
|
+
--workdir Working directory for claude (default: current dir)
|
|
45
|
+
--bin Path to claude CLI binary (default: 'claude' on PATH)
|
|
46
|
+
--timeout Max ms per prompt before SIGTERM (default: 300000 = 5 min)
|
|
47
|
+
--subdomain Request a stable localtunnel subdomain (optional)
|
|
48
|
+
|
|
49
|
+
PREREQS
|
|
50
|
+
npm install -g @anthropic-ai/claude-code
|
|
51
|
+
claude /login # one-time OAuth
|
|
52
|
+
|
|
53
|
+
OUTPUT
|
|
54
|
+
Prints a QR code in your terminal. Scan it from the Esque iOS app to
|
|
55
|
+
pair this laptop with your phone.
|
|
56
|
+
`.trim(),
|
|
57
|
+
);
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const PORT = Number(argv.port || process.env.PORT || 3030);
|
|
62
|
+
const WORKDIR = path.resolve(
|
|
63
|
+
argv.workdir || process.env.CLAUDE_WORKDIR || process.cwd(),
|
|
64
|
+
);
|
|
65
|
+
const TIMEOUT_MS = Number(argv.timeout || 5 * 60 * 1000);
|
|
66
|
+
const CLAUDE_BIN = argv.bin || process.env.CLAUDE_BIN || 'claude';
|
|
67
|
+
const LT_SUBDOMAIN = argv.subdomain || process.env.LT_SUBDOMAIN || undefined;
|
|
68
|
+
const PAIRING_SECRET = crypto.randomBytes(16).toString('hex');
|
|
69
|
+
const SESSIONS_FILE = path.join(os.homedir(), '.esque-bridge-sessions.json');
|
|
70
|
+
|
|
71
|
+
function parseArgs(args) {
|
|
72
|
+
const out = {};
|
|
73
|
+
for (let i = 0; i < args.length; i++) {
|
|
74
|
+
const a = args[i];
|
|
75
|
+
if (!a.startsWith('--') && !a.startsWith('-')) continue;
|
|
76
|
+
const key = a.replace(/^-+/, '');
|
|
77
|
+
const next = args[i + 1];
|
|
78
|
+
if (!next || next.startsWith('-')) {
|
|
79
|
+
out[key] = true;
|
|
80
|
+
} else {
|
|
81
|
+
out[key] = next;
|
|
82
|
+
i++;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- Session persistence -------------------------------------------------
|
|
89
|
+
// Map of {esqueSessionId → claudeSessionId} so each conversation continues
|
|
90
|
+
// in-context across prompts via `claude --resume`. Survives bridge restarts.
|
|
91
|
+
|
|
92
|
+
let sessionMap = {};
|
|
93
|
+
try {
|
|
94
|
+
sessionMap = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8'));
|
|
95
|
+
} catch {
|
|
96
|
+
/* first run */
|
|
97
|
+
}
|
|
98
|
+
function saveSessions() {
|
|
99
|
+
try {
|
|
100
|
+
fs.writeFileSync(SESSIONS_FILE, JSON.stringify(sessionMap, null, 2));
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.warn('[bridge] could not persist session map:', err.message);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- Claude runner --------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
function runClaude(prompt, claudeSessionId) {
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
// `--output-format json` returns a single JSON object with
|
|
111
|
+
// {result, session_id, is_error, total_cost_usd, ...}. Far simpler to
|
|
112
|
+
// consume than the NDJSON stream-json mode; we trade incremental
|
|
113
|
+
// progress for parse-once reliability.
|
|
114
|
+
const args = ['--print', '--output-format', 'json'];
|
|
115
|
+
if (claudeSessionId) args.push('--resume', claudeSessionId);
|
|
116
|
+
|
|
117
|
+
// spawn (not exec) — argv array means no shell interpretation, so the
|
|
118
|
+
// prompt content can't inject shell commands. The prompt itself goes
|
|
119
|
+
// through stdin to avoid ARG_MAX on long blueprint payloads (the god
|
|
120
|
+
// prompt easily exceeds 16 KB).
|
|
121
|
+
const child = spawn(CLAUDE_BIN, args, {
|
|
122
|
+
cwd: WORKDIR,
|
|
123
|
+
env: process.env,
|
|
124
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
let stdout = '';
|
|
128
|
+
let stderr = '';
|
|
129
|
+
let settled = false;
|
|
130
|
+
const settle = (fn) => (val) => {
|
|
131
|
+
if (settled) return;
|
|
132
|
+
settled = true;
|
|
133
|
+
fn(val);
|
|
134
|
+
};
|
|
135
|
+
const resolveOnce = settle(resolve);
|
|
136
|
+
const rejectOnce = settle(reject);
|
|
137
|
+
|
|
138
|
+
const killTimer = setTimeout(() => {
|
|
139
|
+
child.kill('SIGTERM');
|
|
140
|
+
rejectOnce(
|
|
141
|
+
new Error(`claude timed out after ${Math.round(TIMEOUT_MS / 1000)}s`),
|
|
142
|
+
);
|
|
143
|
+
}, TIMEOUT_MS);
|
|
144
|
+
|
|
145
|
+
child.stdout.on('data', (d) => (stdout += d));
|
|
146
|
+
child.stderr.on('data', (d) => (stderr += d));
|
|
147
|
+
child.on('error', (err) => {
|
|
148
|
+
clearTimeout(killTimer);
|
|
149
|
+
if (err.code === 'ENOENT') {
|
|
150
|
+
rejectOnce(
|
|
151
|
+
new Error(
|
|
152
|
+
`'${CLAUDE_BIN}' not found in PATH. Install: npm install -g @anthropic-ai/claude-code, then \`claude /login\`.`,
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
rejectOnce(err);
|
|
158
|
+
});
|
|
159
|
+
child.on('close', (code) => {
|
|
160
|
+
clearTimeout(killTimer);
|
|
161
|
+
if (code !== 0) {
|
|
162
|
+
rejectOnce(
|
|
163
|
+
new Error(
|
|
164
|
+
`claude exited ${code}${stderr ? `: ${stderr.trim().slice(0, 500)}` : ''}`,
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const result = JSON.parse(stdout);
|
|
171
|
+
resolveOnce({
|
|
172
|
+
text:
|
|
173
|
+
result.result || result.text || '(claude returned no text)',
|
|
174
|
+
sessionId: result.session_id || null,
|
|
175
|
+
isError: !!result.is_error,
|
|
176
|
+
});
|
|
177
|
+
} catch {
|
|
178
|
+
// Claude returned non-JSON for some reason — surface raw stdout so
|
|
179
|
+
// the user at least sees what happened.
|
|
180
|
+
resolveOnce({
|
|
181
|
+
text: stdout.trim() || '(no output)',
|
|
182
|
+
sessionId: null,
|
|
183
|
+
isError: false,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
child.stdin.write(prompt);
|
|
189
|
+
child.stdin.end();
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// --- HTTP server ----------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
const app = express();
|
|
196
|
+
app.use(express.json({ limit: '5mb' }));
|
|
197
|
+
|
|
198
|
+
// Disable Express's default x-powered-by header and any tunnel-side
|
|
199
|
+
// caching surprises.
|
|
200
|
+
app.disable('x-powered-by');
|
|
201
|
+
app.use((req, res, next) => {
|
|
202
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
203
|
+
next();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Public health probe. No auth — anyone hitting the URL gets a tiny "is
|
|
207
|
+
// the bridge alive" answer. The Esque app's connection-test uses this for
|
|
208
|
+
// reachability before it sends a real prompt.
|
|
209
|
+
app.get('/', (_req, res) => {
|
|
210
|
+
res.json({
|
|
211
|
+
ok: true,
|
|
212
|
+
service: 'esque-bridge',
|
|
213
|
+
workdir: WORKDIR,
|
|
214
|
+
sessions: Object.keys(sessionMap).length,
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Pairing-check shim — Esque's `testConnectionDetailed()` sends a probe
|
|
219
|
+
// POST with `_probe: true`. Reply OK without invoking claude, and without
|
|
220
|
+
// requiring the secret (because the very first thing a freshly-paired app
|
|
221
|
+
// will do is run this check).
|
|
222
|
+
app.post('/', (req, res, next) => {
|
|
223
|
+
if (req.body && req.body._probe === true) {
|
|
224
|
+
return res.json({ ok: true, service: 'esque-bridge' });
|
|
225
|
+
}
|
|
226
|
+
return next();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
function requireAuth(req, res, next) {
|
|
230
|
+
const provided = req.header('x-esque-pair') || req.body?.pairSecret;
|
|
231
|
+
if (provided !== PAIRING_SECRET) {
|
|
232
|
+
return res.status(401).json({
|
|
233
|
+
text:
|
|
234
|
+
'Unauthorized. Pair this bridge with your phone by scanning the QR code from the terminal where esque-bridge is running.',
|
|
235
|
+
status: 'blocked',
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
next();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// The execute endpoint. Body: { prompt, sessionId?, pushToken? }.
|
|
242
|
+
// Returns: { text, status: 'finished' | 'blocked' }.
|
|
243
|
+
async function executeHandler(req, res) {
|
|
244
|
+
const body = req.body || {};
|
|
245
|
+
const prompt = String(body.prompt || '');
|
|
246
|
+
const esqueSessionId = body.sessionId ?? null;
|
|
247
|
+
if (!prompt.trim()) {
|
|
248
|
+
return res.status(400).json({ text: 'Empty prompt.', status: 'blocked' });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const claudeSid = esqueSessionId ? sessionMap[esqueSessionId] : null;
|
|
252
|
+
const preview = prompt.slice(0, 80).replace(/\s+/g, ' ');
|
|
253
|
+
console.log(
|
|
254
|
+
`[bridge] POST ${preview}… esque=${esqueSessionId ?? '-'} claude=${claudeSid ?? 'new'}`,
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const result = await runClaude(prompt, claudeSid);
|
|
259
|
+
if (result.sessionId && esqueSessionId) {
|
|
260
|
+
sessionMap[esqueSessionId] = result.sessionId;
|
|
261
|
+
saveSessions();
|
|
262
|
+
}
|
|
263
|
+
res.json({
|
|
264
|
+
text: result.text,
|
|
265
|
+
status: result.isError ? 'blocked' : 'finished',
|
|
266
|
+
});
|
|
267
|
+
} catch (err) {
|
|
268
|
+
console.error('[bridge] error:', err.message);
|
|
269
|
+
res
|
|
270
|
+
.status(500)
|
|
271
|
+
.json({ text: `Claude bridge failed: ${err.message}`, status: 'blocked' });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Two routes for the same handler: `/` (what the Esque app POSTs to today)
|
|
276
|
+
// and `/execute` (the cleaner name the directive spec specified — kept so
|
|
277
|
+
// future iOS versions can switch without breaking the bridge).
|
|
278
|
+
app.post('/execute', requireAuth, executeHandler);
|
|
279
|
+
app.post('/', requireAuth, executeHandler);
|
|
280
|
+
|
|
281
|
+
// Anything else: 405.
|
|
282
|
+
app.use((_req, res) => res.status(405).json({ error: 'POST only' }));
|
|
283
|
+
|
|
284
|
+
// --- Boot -----------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
async function main() {
|
|
287
|
+
if (!fs.existsSync(WORKDIR) || !fs.statSync(WORKDIR).isDirectory()) {
|
|
288
|
+
console.error(`workdir does not exist: ${WORKDIR}`);
|
|
289
|
+
process.exit(1);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
await new Promise((resolve) => app.listen(PORT, resolve));
|
|
293
|
+
|
|
294
|
+
let tunnel;
|
|
295
|
+
try {
|
|
296
|
+
tunnel = await localtunnel({ port: PORT, subdomain: LT_SUBDOMAIN });
|
|
297
|
+
} catch (err) {
|
|
298
|
+
console.error('Failed to open localtunnel:', err.message);
|
|
299
|
+
console.error(
|
|
300
|
+
'If localtunnel.me is blocked on your network, try cloudflared:',
|
|
301
|
+
);
|
|
302
|
+
console.error(` cloudflared tunnel --url http://localhost:${PORT}`);
|
|
303
|
+
process.exit(1);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const pairUrl = `esque://pair?url=${encodeURIComponent(tunnel.url)}&secret=${PAIRING_SECRET}`;
|
|
307
|
+
|
|
308
|
+
console.log('');
|
|
309
|
+
console.log('━'.repeat(68));
|
|
310
|
+
console.log(' Esque Bridge Active');
|
|
311
|
+
console.log('━'.repeat(68));
|
|
312
|
+
console.log('');
|
|
313
|
+
console.log(
|
|
314
|
+
' Scan this QR code with the Esque Agent mobile app to pair your device.',
|
|
315
|
+
);
|
|
316
|
+
console.log('');
|
|
317
|
+
qrcode.generate(pairUrl, { small: true });
|
|
318
|
+
console.log('');
|
|
319
|
+
console.log(` Local http://localhost:${PORT}`);
|
|
320
|
+
console.log(` Tunnel ${tunnel.url}`);
|
|
321
|
+
console.log(` Workdir ${WORKDIR}`);
|
|
322
|
+
console.log(` Claude bin ${CLAUDE_BIN}`);
|
|
323
|
+
console.log(
|
|
324
|
+
` Pair secret ${PAIRING_SECRET.slice(0, 8)}… (rotates on restart — don't share)`,
|
|
325
|
+
);
|
|
326
|
+
console.log('');
|
|
327
|
+
console.log(' Press Ctrl-C to stop.');
|
|
328
|
+
console.log('━'.repeat(68));
|
|
329
|
+
|
|
330
|
+
const shutdown = (signal) => {
|
|
331
|
+
console.log(`\n Received ${signal} — closing tunnel…`);
|
|
332
|
+
try {
|
|
333
|
+
tunnel.close();
|
|
334
|
+
} catch {
|
|
335
|
+
/* tunnel may already be closed */
|
|
336
|
+
}
|
|
337
|
+
process.exit(0);
|
|
338
|
+
};
|
|
339
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
340
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
341
|
+
|
|
342
|
+
tunnel.on('close', () => {
|
|
343
|
+
console.error('\nTunnel closed unexpectedly. Restart `esque-bridge`.');
|
|
344
|
+
process.exit(1);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
main().catch((err) => {
|
|
349
|
+
console.error('Fatal:', err.message);
|
|
350
|
+
process.exit(1);
|
|
351
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "esque-bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Desktop-side receiver for the Esque Agent mobile app. Pairs your phone with your local Claude Code CLI via a tunnel + QR code, so prompts run through your Claude Pro / Max subscription instead of per-token API billing.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"esque-bridge": "index.js"
|
|
7
|
+
},
|
|
8
|
+
"main": "index.js",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node index.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"index.js",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"esque",
|
|
21
|
+
"claude",
|
|
22
|
+
"claude-code",
|
|
23
|
+
"anthropic",
|
|
24
|
+
"agent",
|
|
25
|
+
"bridge",
|
|
26
|
+
"mobile",
|
|
27
|
+
"ios"
|
|
28
|
+
],
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"express": "^4.21.1",
|
|
31
|
+
"localtunnel": "^2.0.2",
|
|
32
|
+
"qrcode-terminal": "^0.12.0"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT"
|
|
35
|
+
}
|