@kamal0808/anytoany 0.1.0 → 0.3.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.
Files changed (2) hide show
  1. package/cli.js +263 -51
  2. package/package.json +1 -1
package/cli.js CHANGED
@@ -20,6 +20,9 @@
20
20
  import fs from 'node:fs';
21
21
  import path from 'node:path';
22
22
  import os from 'node:os';
23
+ import readline from 'node:readline';
24
+ import { execFileSync } from 'node:child_process';
25
+ import { fileURLToPath } from 'node:url';
23
26
 
24
27
  const HOME = process.env.ANY_HOME || path.join(os.homedir(), '.any');
25
28
  const CONFIG = path.join(HOME, 'config.json');
@@ -52,13 +55,17 @@ function resolve() {
52
55
  const cfg = loadConfig();
53
56
  const user = process.env.ANY_USER || cfg.user || hostnameUser();
54
57
  const relay = process.env.ANY_RELAY_URL || cfg.relay || DEFAULT_RELAY;
55
- const token = process.env.ANY_TOKEN || cfg.token || null;
56
- return { user: norm(user), relay: relay.replace(/\/$/, ''), token };
58
+ const key = process.env.ANY_KEY || cfg.key || null;
59
+ return { user: norm(user), relay: relay.replace(/\/$/, ''), key };
57
60
  }
58
61
 
59
62
  function norm(user) {
60
63
  if (!user) return null;
61
- return user.startsWith('@') ? user : '@' + user;
64
+ return user.startsWith('@') ? user.toLowerCase() : '@' + user.toLowerCase();
65
+ }
66
+
67
+ function validSlug(h) {
68
+ return /^@[a-z0-9_-]{2,30}$/.test(h);
62
69
  }
63
70
 
64
71
  function die(msg) {
@@ -66,11 +73,28 @@ function die(msg) {
66
73
  process.exit(1);
67
74
  }
68
75
 
76
+ // register (claim a handle, get your own key) — no auth needed
77
+ async function registerWithRelay(relay, handle, displayName) {
78
+ let res;
79
+ try {
80
+ res = await fetch(relay + '/register', {
81
+ method: 'POST',
82
+ headers: { 'content-type': 'application/json' },
83
+ body: JSON.stringify({ handle, displayName }),
84
+ });
85
+ } catch (e) {
86
+ die(`can't reach relay at ${relay} (${e.message})`);
87
+ }
88
+ const j = await res.json().catch(() => ({}));
89
+ if (!res.ok) die(j.error || `register failed (${res.status})`);
90
+ return j; // { handle, key, displayName }
91
+ }
92
+
69
93
  async function api(relay, method, route, body) {
70
- const { token } = resolve();
94
+ const { key } = resolve();
71
95
  const headers = {};
72
96
  if (body) headers['content-type'] = 'application/json';
73
- if (token) headers['authorization'] = `Bearer ${token}`;
97
+ if (key) headers['authorization'] = `Bearer ${key}`;
74
98
  let res;
75
99
  try {
76
100
  res = await fetch(relay + route, {
@@ -119,12 +143,19 @@ function printMessage(m, { full = false } = {}) {
119
143
  // ---- commands ------------------------------------------------------------
120
144
 
121
145
  async function cmdRegister(args) {
122
- const user = norm(args[0]);
123
- if (!user) die('usage: any register @you');
146
+ let handle = norm(args[0]);
147
+ if (!handle) die('usage: any register @you [Display Name]');
148
+ if (!validSlug(handle)) die('handle must be 2-30 chars: a-z 0-9 _ - (e.g. @kamal)');
124
149
  const cfg = loadConfig();
125
- cfg.user = user;
150
+ const relay = (process.env.ANY_RELAY_URL || cfg.relay || DEFAULT_RELAY).replace(/\/$/, '');
151
+ const name = args.slice(1).join(' ') || null;
152
+ const acct = await registerWithRelay(relay, handle, name);
153
+ cfg.user = acct.handle;
154
+ cfg.key = acct.key;
155
+ cfg.relay = relay;
156
+ if (acct.displayName) cfg.displayName = acct.displayName;
126
157
  saveConfig(cfg);
127
- console.log(`registered as ${user}`);
158
+ console.log(`✓ registered as ${acct.handle}${acct.displayName ? ' (' + acct.displayName + ')' : ''}`);
128
159
  }
129
160
 
130
161
  async function cmdRelay(args) {
@@ -136,27 +167,20 @@ async function cmdRelay(args) {
136
167
  console.log(`relay set to ${cfg.relay}`);
137
168
  }
138
169
 
139
- async function cmdToken(args) {
140
- const t = args[0];
141
- if (!t) die('usage: any token <shared-secret>');
142
- const cfg = loadConfig();
143
- cfg.token = t;
144
- saveConfig(cfg);
145
- console.log('token saved');
146
- }
147
-
148
170
  async function cmdWhoami() {
149
- const { user, relay, token } = resolve();
150
- console.log(`user: ${user || '(unset — run: any register @you)'}`);
151
- console.log(`relay: ${relay}`);
152
- console.log(`token: ${token ? 'set' : '(none)'}`);
153
- const health = await api(relay, 'GET', '/health').catch(() => null);
154
- if (health) console.log(`relay: online (${health.messages} messages stored)`);
171
+ const { user, relay, key } = resolve();
172
+ console.log(`handle: ${user || '(unregistered — run: any setup)'}`);
173
+ console.log(`relay: ${relay}`);
174
+ console.log(`key: ${key ? 'set' : '(none — run: any setup)'}`);
175
+ if (key) {
176
+ const me = await api(relay, 'GET', '/whoami').catch(() => null);
177
+ if (me) console.log(`status: online as ${me.handle}${me.displayName ? ' (' + me.displayName + ')' : ''}`);
178
+ }
155
179
  }
156
180
 
157
181
  async function cmdSend(args) {
158
- const { user, relay } = resolve();
159
- if (!user) die('you are not registered. Run: any register @you');
182
+ const { key, relay } = resolve();
183
+ if (!key) die('not registered. Run: any setup');
160
184
  const to = norm(args[0]);
161
185
  if (!to) die('usage: any send @who <text | file>');
162
186
  const rest = args.slice(1);
@@ -172,24 +196,20 @@ async function cmdSend(args) {
172
196
  body = rest.join(' ');
173
197
  }
174
198
 
175
- const { id } = await api(relay, 'POST', '/send', { from: user, to, subject, body });
199
+ const { id } = await api(relay, 'POST', '/send', { to, subject, body });
176
200
  console.log(`sent → ${to} (#${id})`);
177
201
  }
178
202
 
179
203
  async function cmdInbox(args) {
180
- const { user, relay } = resolve();
181
- if (!user) die('you are not registered. Run: any register @you');
204
+ const { key, relay } = resolve();
205
+ if (!key) die('not registered. Run: any setup');
182
206
  const all = args.includes('--all');
183
- const { messages, count } = await api(
184
- relay,
185
- 'GET',
186
- `/inbox?user=${encodeURIComponent(user)}${all ? '&all=1' : ''}`,
187
- );
207
+ const { messages, count } = await api(relay, 'GET', `/inbox${all ? '?all=1' : ''}`);
188
208
  if (count === 0) {
189
209
  console.log(all ? 'inbox empty' : 'no new messages');
190
210
  return;
191
211
  }
192
- console.log(`${count} ${all ? 'message(s)' : 'new message(s)'} for ${user}:\n`);
212
+ console.log(`${count} ${all ? 'message(s)' : 'new message(s)'}:\n`);
193
213
  for (const m of messages) {
194
214
  printMessage(m);
195
215
  console.log('');
@@ -208,34 +228,30 @@ async function cmdRead(args) {
208
228
  }
209
229
 
210
230
  async function cmdReply(args) {
211
- const { user, relay } = resolve();
212
- if (!user) die('you are not registered. Run: any register @you');
231
+ const { key, relay } = resolve();
232
+ if (!key) die('not registered. Run: any setup');
213
233
  const id = Number(args[0]);
214
234
  if (!id) die('usage: any reply <id> <text>');
215
235
  const body = args.slice(1).join(' ');
216
236
  if (!body) die('nothing to reply with');
217
- const { id: newId } = await api(relay, 'POST', '/reply', { id, from: user, body });
237
+ const { id: newId } = await api(relay, 'POST', '/reply', { id, body });
218
238
  console.log(`replied to #${id} (#${newId})`);
219
239
  }
220
240
 
221
241
  async function cmdWatch(args) {
222
- const { user, relay } = resolve();
223
- if (!user) die('you are not registered. Run: any register @you');
242
+ const { key, relay } = resolve();
243
+ if (!key) die('not registered. Run: any setup');
224
244
  const i = args.indexOf('--interval');
225
245
  const interval = (i >= 0 ? Number(args[i + 1]) : 5) * 1000;
226
246
  const seen = new Set();
227
- console.log(`watching inbox for ${user} (every ${interval / 1000}s). Ctrl-C to stop.\n`);
247
+ console.log(`watching inbox (every ${interval / 1000}s). Ctrl-C to stop.\n`);
228
248
  // Prime with existing unread so we only announce genuinely new arrivals.
229
- const first = await api(relay, 'GET', `/inbox?user=${encodeURIComponent(user)}&all=1`);
249
+ const first = await api(relay, 'GET', `/inbox?all=1`);
230
250
  for (const m of first.messages) seen.add(m.id);
231
251
  if (first.messages.length) console.log(`(${first.messages.length} existing message(s), waiting for new ones)\n`);
232
252
 
233
253
  async function poll() {
234
- const { messages } = await api(
235
- relay,
236
- 'GET',
237
- `/inbox?user=${encodeURIComponent(user)}&all=1`,
238
- ).catch(() => ({ messages: [] }));
254
+ const { messages } = await api(relay, 'GET', `/inbox?all=1`).catch(() => ({ messages: [] }));
239
255
  for (const m of messages.sort((a, b) => a.ts - b.ts)) {
240
256
  if (seen.has(m.id)) continue;
241
257
  seen.add(m.id);
@@ -247,13 +263,208 @@ async function cmdWatch(args) {
247
263
  setInterval(poll, interval);
248
264
  }
249
265
 
266
+ // ---- watcher: poke (one tick) + setup (install the background watcher) ----
267
+
268
+ function deepLink(user, folder) {
269
+ const prompt =
270
+ `You are helping me (${user}) with my anytoany messages. Run \`any inbox\` to see new messages, ` +
271
+ `read the newest with \`any read <id>\`, and help me draft a reply. When I approve, send it with ` +
272
+ `\`any reply <id> "..."\`. Do not send anything until I confirm.`;
273
+ let url = `claude://code/new?q=${encodeURIComponent(prompt)}`;
274
+ if (folder) url += `&folder=${encodeURIComponent(folder)}`;
275
+ return url;
276
+ }
277
+
278
+ function fireBanner(m, link) {
279
+ const preview = (m.body || '').replace(/\s+/g, ' ').slice(0, 120);
280
+ const title = `📨 ${m.from} → you`;
281
+ try {
282
+ execFileSync(
283
+ 'terminal-notifier',
284
+ ['-title', title, '-subtitle', 'click to read & reply in Claude', '-message', preview, '-execute', `open '${link}'`],
285
+ { stdio: 'ignore' },
286
+ );
287
+ return;
288
+ } catch {}
289
+ try {
290
+ execFileSync('osascript', ['-e', `display notification ${JSON.stringify(preview)} with title ${JSON.stringify(title)}`], { stdio: 'ignore' });
291
+ } catch {}
292
+ }
293
+
294
+ // one watch tick: alert on any new unread message. Run by launchd every ~15s.
295
+ async function cmdPoke() {
296
+ const { user, relay, key: authKey } = resolve();
297
+ if (!authKey) die('not registered (run: any setup)');
298
+ const slug = user.replace('@', '');
299
+ const seenPath = path.join(HOME, `poke-seen-${slug}.txt`);
300
+ const lockPath = path.join(HOME, `poke-${slug}.lock`);
301
+ fs.mkdirSync(HOME, { recursive: true });
302
+ try {
303
+ fs.mkdirSync(lockPath); // single-flight
304
+ } catch {
305
+ return;
306
+ }
307
+ try {
308
+ const { messages } = await api(relay, 'GET', `/inbox`);
309
+ if (!messages || !messages.length) return;
310
+ let seen = new Set();
311
+ try {
312
+ seen = new Set(fs.readFileSync(seenPath, 'utf8').split('\n').filter(Boolean).map(Number));
313
+ } catch {}
314
+ const link = deepLink(user, process.env.ANY_FOLDER || loadConfig().folder || null);
315
+ for (const m of messages) {
316
+ if (seen.has(m.id)) continue;
317
+ fireBanner(m, link);
318
+ fs.appendFileSync(seenPath, m.id + '\n');
319
+ console.log(`alerted #${m.id} from ${m.from}`);
320
+ }
321
+ } finally {
322
+ try {
323
+ fs.rmdirSync(lockPath);
324
+ } catch {}
325
+ }
326
+ }
327
+
328
+ function promptLine(q) {
329
+ return new Promise((res) => {
330
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
331
+ rl.question(q, (a) => {
332
+ rl.close();
333
+ res(a.trim());
334
+ });
335
+ });
336
+ }
337
+
338
+ function have(cmd) {
339
+ try {
340
+ execFileSync('/bin/bash', ['-lc', `command -v ${cmd}`], { stdio: 'ignore' });
341
+ return true;
342
+ } catch {
343
+ return false;
344
+ }
345
+ }
346
+
347
+ // one-command onboarding: claim a handle (get your key) + install the watcher
348
+ async function cmdSetup() {
349
+ const cfg = loadConfig();
350
+ const tty = process.stdin.isTTY;
351
+ const relay = (process.env.ANY_RELAY_URL || cfg.relay || DEFAULT_RELAY).replace(/\/$/, '');
352
+
353
+ if (!cfg.key) {
354
+ // claim a new account
355
+ let handle = norm(process.env.ANY_USER || cfg.user);
356
+ if (!handle) {
357
+ if (!tty) die('no handle — run interactively, or: any register @you first');
358
+ handle = norm(await promptLine('Pick a handle (e.g. @yash): '));
359
+ }
360
+ if (!validSlug(handle)) die('handle must be 2-30 chars: a-z 0-9 _ - (e.g. @kamal)');
361
+ let name = cfg.displayName || process.env.ANY_NAME || null;
362
+ if (tty && !name) name = (await promptLine('Display name (optional): ')) || null;
363
+ const acct = await registerWithRelay(relay, handle, name);
364
+ cfg.user = acct.handle;
365
+ cfg.key = acct.key;
366
+ cfg.relay = relay;
367
+ if (acct.displayName) cfg.displayName = acct.displayName;
368
+ saveConfig(cfg);
369
+ console.log(`✓ registered as ${acct.handle}${acct.displayName ? ' (' + acct.displayName + ')' : ''} → ${relay}`);
370
+ } else {
371
+ if (!cfg.relay) {
372
+ cfg.relay = relay;
373
+ saveConfig(cfg);
374
+ }
375
+ console.log(`✓ already registered as ${cfg.user} → ${cfg.relay}`);
376
+ }
377
+ const user = cfg.user;
378
+
379
+ const me = await api(cfg.relay, 'GET', '/whoami').catch(() => null);
380
+ console.log(me ? `✓ connected as ${me.handle}` : '! could not verify with relay (continuing)');
381
+
382
+ if (process.platform !== 'darwin') {
383
+ console.log('\nThe background banner watcher is macOS-only for now.');
384
+ console.log('You can still use: any inbox / any read <id> / any reply <id> "…"');
385
+ return;
386
+ }
387
+
388
+ // ensure a stable global install (npx runs from a temp cache the watcher can't rely on)
389
+ if (!have('any')) {
390
+ console.log('Installing @kamal0808/anytoany globally…');
391
+ try {
392
+ execFileSync('npm', ['install', '-g', '@kamal0808/anytoany'], { stdio: 'inherit' });
393
+ } catch {}
394
+ }
395
+ if (!have('terminal-notifier')) {
396
+ console.log('Installing terminal-notifier (clickable banners)…');
397
+ try {
398
+ execFileSync('brew', ['install', 'terminal-notifier'], { stdio: 'inherit' });
399
+ } catch {
400
+ console.log('! could not auto-install — run: brew install terminal-notifier');
401
+ }
402
+ }
403
+
404
+ // resolve a stable cli.js path for the LaunchAgent
405
+ let cliForAgent = fileURLToPath(import.meta.url);
406
+ try {
407
+ const anyBin = execFileSync('/bin/bash', ['-lc', 'command -v any'], { encoding: 'utf8' }).trim();
408
+ if (anyBin) cliForAgent = fs.realpathSync(anyBin);
409
+ } catch {}
410
+
411
+ const key = user.replace('@', '');
412
+ const label = `com.anytoany.poke.${key}`;
413
+ const laDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
414
+ fs.mkdirSync(laDir, { recursive: true });
415
+ const plistPath = path.join(laDir, `${label}.plist`);
416
+ const nodeDir = path.dirname(process.execPath);
417
+ fs.writeFileSync(
418
+ plistPath,
419
+ `<?xml version="1.0" encoding="UTF-8"?>
420
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
421
+ <plist version="1.0">
422
+ <dict>
423
+ <key>Label</key><string>${label}</string>
424
+ <key>ProgramArguments</key>
425
+ <array><string>${process.execPath}</string><string>${cliForAgent}</string><string>poke</string></array>
426
+ <key>EnvironmentVariables</key>
427
+ <dict>
428
+ <key>ANY_USER</key><string>${user}</string>
429
+ <key>PATH</key><string>${nodeDir}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
430
+ </dict>
431
+ <key>StartInterval</key><integer>15</integer>
432
+ <key>RunAtLoad</key><true/>
433
+ <key>StandardOutPath</key><string>/tmp/any-poke-${key}.log</string>
434
+ <key>StandardErrorPath</key><string>/tmp/any-poke-${key}.err</string>
435
+ </dict>
436
+ </plist>
437
+ `,
438
+ );
439
+
440
+ // prime seen-file so existing messages don't all alert at once
441
+ try {
442
+ const { messages } = await api(cfg.relay, 'GET', `/inbox?all=1`);
443
+ fs.writeFileSync(path.join(HOME, `poke-seen-${key}.txt`), (messages || []).map((m) => m.id).join('\n') + '\n');
444
+ } catch {}
445
+
446
+ const uid = process.getuid();
447
+ try {
448
+ execFileSync('launchctl', ['bootout', `gui/${uid}/${label}`], { stdio: 'ignore' });
449
+ } catch {}
450
+ try {
451
+ execFileSync('launchctl', ['bootstrap', `gui/${uid}`, plistPath], { stdio: 'ignore' });
452
+ console.log('✓ background watcher installed');
453
+ } catch {
454
+ console.log(`! load the watcher manually:\n launchctl bootstrap gui/${uid} ${plistPath}`);
455
+ }
456
+
457
+ console.log(`\nDone — you're on the network as ${user}.`);
458
+ console.log('New message → a banner pops → click it → Claude Desktop opens to read & reply.');
459
+ }
460
+
250
461
  function cmdHelp() {
251
462
  console.log(`any — tiny message relay CLI
252
463
 
253
- any register @you set your identity
464
+ any setup one-command onboarding (claim @handle + banner watcher)
465
+ any register @you [Name] claim a handle (get your key)
254
466
  any relay <url> point at the relay server
255
- any token <secret> set the shared secret (for a hosted relay)
256
- any whoami show identity + relay status
467
+ any whoami show your handle + relay status
257
468
 
258
469
  any send @who <text|file> send a message (a file becomes subject+body)
259
470
  any inbox [--all] list messages for you (default: unread only)
@@ -271,9 +482,10 @@ Env: ANY_USER, ANY_RELAY_URL, ANY_HOME
271
482
  const [, , cmd, ...args] = process.argv;
272
483
 
273
484
  const table = {
485
+ setup: cmdSetup,
486
+ poke: cmdPoke,
274
487
  register: cmdRegister,
275
488
  relay: cmdRelay,
276
- token: cmdToken,
277
489
  whoami: cmdWhoami,
278
490
  send: cmdSend,
279
491
  inbox: cmdInbox,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kamal0808/anytoany",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Tiny CLI to send/inbox/reply messages between Claude Code sessions via the anytoany relay",
6
6
  "bin": {