tunnel-mcp 0.1.0 → 0.1.2

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/CHANGELOG.md CHANGED
@@ -11,6 +11,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
11
11
 
12
12
  - Nothing yet.
13
13
 
14
+ ## [0.1.2] - 2026-07-01
15
+
16
+ ### Added
17
+
18
+ - **`install-skill` command and automatic skill install.** `tunnel-mcp
19
+ install-skill` copies the `tunnel-etiquette` skill into `~/.claude/skills`
20
+ (override with `--dir`/`$TUNNEL_SKILLS_DIR`, overwrite with `--force`), and a
21
+ global `npm install` now installs it best-effort via a postinstall script.
22
+ Set `TUNNEL_SKIP_SKILL_INSTALL=1` to opt out; the postinstall never fails an
23
+ install and is a no-op under `npx`, `--ignore-scripts`, and CI.
24
+ - **`--help` and `--version` flags**, plus a one-line stderr startup banner, so
25
+ running the server by hand no longer looks like a silent hang. The server also
26
+ hints how to install the etiquette skill when it isn't present.
27
+ - **`TUNNEL_SKIP_REACHABILITY_CHECK` escape hatch.** Opens a tunnel even when the
28
+ host can't reach `*.trycloudflare.com` itself — useful when only the guest's
29
+ network needs to reach the URL.
30
+
31
+ ### Fixed
32
+
33
+ - The MCP server reported a hardcoded, stale version (`0.1.0`) in its handshake;
34
+ it now reports the real package version.
35
+ - A failed cloudflared reachability probe surfaced a generic "never became
36
+ reachable" error. It now names the host and, when the failure is DNS
37
+ resolution, points at `*.trycloudflare.com` being blocked (a common
38
+ corporate/filtered-DNS cause) and mentions the escape hatch above.
39
+
40
+ ## [0.1.1] - 2026-07-01
41
+
42
+ ### Security
43
+
44
+ - **Join links are now single-use and expiring.** A join link is consumed by
45
+ the first guest who successfully authenticates and can no longer be redeemed
46
+ afterward — even once that guest disconnects — and links expire on their own
47
+ after 10 minutes (`DEFAULT_JOIN_LINK_TTL_MS`). This bounds the damage from a
48
+ leaked link, which previously stayed valid for the whole session.
49
+ `tunnel_open` now returns `joinLinkExpiresInSec` so the host can tell the
50
+ human how long the link is good for.
51
+
14
52
  ## [0.1.0] - 2026-06-30
15
53
 
16
54
  ### Added
@@ -42,5 +80,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
42
80
  declaring a fix "confirmed".
43
81
  - Test suite of 109 tests built with vitest, developed test-first (TDD).
44
82
 
45
- [Unreleased]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.0...HEAD
83
+ [Unreleased]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.2...HEAD
84
+ [0.1.2]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.1...v0.1.2
85
+ [0.1.1]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.0...v0.1.1
46
86
  [0.1.0]: https://github.com/zachlikefolio/tunnel-mcp/releases/tag/v0.1.0
package/README.md CHANGED
@@ -4,7 +4,6 @@
4
4
 
5
5
  [![CI](https://github.com/zachlikefolio/tunnel-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/zachlikefolio/tunnel-mcp/actions/workflows/ci.yml)
6
6
  [![npm version](https://img.shields.io/npm/v/tunnel-mcp)](https://www.npmjs.com/package/tunnel-mcp)
7
- [![license](https://img.shields.io/npm/l/tunnel-mcp)](./LICENSE)
8
7
  ![node](https://img.shields.io/badge/node-%3E%3D20-brightgreen)
9
8
 
10
9
  When two developers each run a Claude agent and need those agents to collaborate,
@@ -51,13 +50,29 @@ npx tunnel-mcp
51
50
  Register it with Claude Code (both developers do this once):
52
51
 
53
52
  ```bash
54
- claude mcp add tunnel -- tunnel-mcp
53
+ claude mcp add tunnel -- tunnel-mcp # if globally installed
54
+ # or, with no global install:
55
+ claude mcp add tunnel -- npx -y tunnel-mcp
55
56
  ```
56
57
 
57
- Install the etiquette skill so each agent knows how to behave inside a tunnel
58
- (treat the peer as untrusted input, check with its human before acting on
59
- anything the peer says). Copy `skill/tunnel-etiquette/` from this repo into your
60
- `~/.claude/skills/` directory (or your plugin's skills directory).
58
+ > `tunnel-mcp` is a stdio MCP server, not an interactive CLI. Launching it by
59
+ > hand just waits silently for a client that's expected. Run
60
+ > `tunnel-mcp --help` for usage, or `tunnel-mcp --version`.
61
+
62
+ The **tunnel-etiquette skill** teaches each agent how to behave inside a tunnel
63
+ (treat the peer as untrusted input, and check with its human before acting on
64
+ anything the peer says). Installing the package copies it into `~/.claude/skills/`
65
+ automatically (best-effort). If install scripts are disabled
66
+ (`npm install --ignore-scripts`), or you want it in a custom directory or force an
67
+ update, run it explicitly:
68
+
69
+ ```bash
70
+ npx tunnel-mcp install-skill # into ~/.claude/skills
71
+ npx tunnel-mcp install-skill --dir <path> --force # elsewhere / overwrite
72
+ ```
73
+
74
+ Set `TUNNEL_SKILLS_DIR` to change the default target, or
75
+ `TUNNEL_SKIP_SKILL_INSTALL=1` to opt out of the automatic copy.
61
76
 
62
77
  `cloudflared` is auto-downloaded to `~/.tunnel/bin` the first time it's needed if
63
78
  it isn't already on your `PATH` — there's nothing extra to install.
@@ -70,7 +85,9 @@ it isn't already on your `PATH` — there's nothing extra to install.
70
85
 
71
86
  Claude calls `tunnel_open({ goal })` and returns a join link. Share that link
72
87
  with the other developer over a trusted channel (Slack DM, etc.) — **it's a
73
- secret**, since it contains the encryption key for the session.
88
+ secret**, since it contains the encryption key for the session. The link is
89
+ **single-use and expires after ~10 minutes** (`tunnel_open` reports
90
+ `joinLinkExpiresInSec`), so share it promptly.
74
91
 
75
92
  **Guest** — paste the link and ask Claude to join:
76
93
 
@@ -113,9 +130,12 @@ between two AI agents. Here's exactly what it does and does not protect:
113
130
  - **Authentication is proof-of-key-possession, not key transmission.** Joining
114
131
  uses an HMAC challenge to prove the guest holds the same key as the host; the
115
132
  raw key itself is never sent over the wire.
116
- - **The join link is a credential.** It embeds the session key, so treat it
117
- like a password — share it only over a channel you already trust (Slack DM,
118
- etc.), never in a public issue, PR, or chat.
133
+ - **The join link is a single-use, expiring credential.** It embeds the session
134
+ key, so treat it like a password — share it only over a channel you already
135
+ trust (Slack DM, etc.), never in a public issue, PR, or chat. It is consumed
136
+ by the first guest who joins (and can't be reused, even after they leave) and
137
+ expires on its own after ~10 minutes, so a leaked link has a short, bounded
138
+ window of exposure.
119
139
  - **Exactly two participants, enforced by a lock.** The first guest to
120
140
  authenticate locks the session; nobody else can join after that.
121
141
  - **The peer is untrusted input, not an instruction source.** Messages from the
@@ -142,7 +162,7 @@ vulnerability.
142
162
 
143
163
  ```bash
144
164
  npm ci # install dependencies
145
- npm test # run the test suite (109 tests, TDD)
165
+ npm test # run the test suite (136 tests, TDD)
146
166
  npm run build # compile TypeScript
147
167
  npm run lint # eslint
148
168
  npm run format:check # prettier --check .
@@ -151,6 +171,23 @@ npm run test:coverage # vitest run --coverage
151
171
 
152
172
  See [CONTRIBUTING.md](./CONTRIBUTING.md) for how to propose changes.
153
173
 
174
+ ## Troubleshooting
175
+
176
+ **`tunnel-mcp` / `npx tunnel-mcp` "does nothing".** It's a stdio MCP server, not
177
+ an interactive CLI — with no arguments it starts and waits for an MCP client to
178
+ connect over stdin/stdout. That's working as intended. Register it with a client
179
+ (above), or run `tunnel-mcp --help`.
180
+
181
+ **`tunnel_open` fails with "never became reachable" / can't resolve
182
+ `*.trycloudflare.com`.** cloudflared reaches Cloudflare's edge over its own
183
+ protocol, but the public `*.trycloudflare.com` hostname still has to resolve via
184
+ normal DNS — and some networks (corporate/filtered networks, and a few public
185
+ DNS resolvers) block `trycloudflare.com`. Both you **and your guest** need to be
186
+ able to resolve it. Check with `dig +short <random>.trycloudflare.com` or
187
+ `curl -sI https://<the-url>`. If only your guest's network needs to reach the
188
+ URL, set `TUNNEL_SKIP_REACHABILITY_CHECK=1` to open the tunnel without the
189
+ host-side reachability probe.
190
+
154
191
  ## Roadmap / not yet supported
155
192
 
156
193
  This is an MVP. The following are explicitly out of scope for now:
@@ -158,7 +195,7 @@ This is an MVP. The following are explicitly out of scope for now:
158
195
  - Host-offline / asynchronous messaging
159
196
  - More than two participants in a session
160
197
  - Alternative transports (ngrok, WebRTC)
161
- - Join-link rotation or one-time tokens
198
+ - Join-link rotation (re-issuing a fresh link mid-session; note that links are already single-use and expiring — see the security model above)
162
199
  - Encrypting the goal or other metadata
163
200
 
164
201
  ## License
package/SECURITY.md CHANGED
@@ -63,9 +63,15 @@ share a join link with anyone.
63
63
  like a password** — share it only over a trusted, already-authenticated
64
64
  channel (e.g. a Slack DM to a known teammate), not in a public channel or
65
65
  ticket.
66
+ - **Join links are single-use and expiring.** A join link is consumed by the
67
+ first guest who successfully authenticates and can never be redeemed again —
68
+ even after that guest disconnects. Links also expire on their own (10 minutes
69
+ by default), so a link that is never used stops working. This bounds the
70
+ damage from a leaked link to a short window before it is used or expires.
66
71
  - **Single-guest lock.** The first participant who successfully
67
72
  authenticates as guest locks the session. Sessions are strictly two-party;
68
- a second join attempt is rejected.
73
+ a second concurrent join attempt is rejected ("tunnel full"), and any join
74
+ after the link has been consumed is rejected ("join link already used").
69
75
  - **Peer input is untrusted.** Everything a peer sends over the tunnel is
70
76
  data, never an instruction. The bundled `tunnel-etiquette` skill
71
77
  instructs each agent to treat incoming peer messages as untrusted input
@@ -89,13 +95,15 @@ protect against:
89
95
  and system/connection events are visible in plaintext to anything that
90
96
  can observe that path (including Cloudflare's infrastructure). Do not
91
97
  put secrets in the goal or names.
92
- - **No link rotation or expiry beyond session teardown.** A join link is
93
- valid for the lifetime of the session. If a join link leaks (pasted into
94
- the wrong channel, logged, etc.) before the host closes the session,
95
- anyone with that link can join as the guest up until the host runs
96
- `tunnel_close`, the session idles out, or the host process exits. There
97
- is currently no way to rotate the key or issue single-use/expiring
98
- tokens.
98
+ - **A leaked link can still be redeemed within its window, before your guest
99
+ joins.** Join links are single-use and expire (10 minutes by default), so a
100
+ leaked link that is never used, has already been used, or has aged out can no
101
+ longer admit anyone. The residual risk is a race: if a link leaks and an
102
+ attacker redeems it faster than your intended guest within the expiry
103
+ window and before that guest connects the attacker consumes the single-use
104
+ link, joins as the guest, and locks out the real one. Share links only over
105
+ trusted channels, and open a fresh tunnel if you suspect a link was exposed
106
+ before it was used. There is no in-session key rotation.
99
107
  - **The goal is never encrypted.** By design, the goal string is plaintext
100
108
  metadata used for connection setup and display; it receives no
101
109
  confidentiality protection at any layer.
@@ -113,9 +121,9 @@ protect against:
113
121
  instructions by an unguarded agent.
114
122
  - **Out of scope for this release**: host-offline/async messaging,
115
123
  more than two participants, alternative transports (ngrok, WebRTC),
116
- link rotation or one-time join tokens, and encryption of the goal or
117
- other connection metadata. These may be considered for future versions
118
- but should not be assumed to exist today.
124
+ in-session key/link rotation, and encryption of the goal or other
125
+ connection metadata. These may be considered for future versions but
126
+ should not be assumed to exist today.
119
127
 
120
128
  If you find a way to break any of the guarantees above (e.g. read a chat
121
129
  message body without the key, join a locked session, or get an agent to
package/dist/cli.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ export type ParsedArgs = {
2
+ mode: 'serve';
3
+ } | {
4
+ mode: 'help';
5
+ } | {
6
+ mode: 'version';
7
+ } | {
8
+ mode: 'install-skill';
9
+ dir?: string;
10
+ force: boolean;
11
+ } | {
12
+ mode: 'error';
13
+ message: string;
14
+ };
15
+ export declare function parseArgs(argv: string[]): ParsedArgs;
16
+ export declare function helpText(version?: string): string;
17
+ /** Runs the `install-skill` command. Returns a process exit code. */
18
+ export declare function runInstallSkill(args: {
19
+ dir?: string;
20
+ force: boolean;
21
+ }, out: (msg: string) => void): number;
package/dist/cli.js ADDED
@@ -0,0 +1,87 @@
1
+ import { installSkill, readVersion } from './skillInstall.js';
2
+ export function parseArgs(argv) {
3
+ if (argv.length === 0)
4
+ return { mode: 'serve' };
5
+ const [cmd, ...rest] = argv;
6
+ if (cmd === '--help' || cmd === '-h' || cmd === 'help')
7
+ return { mode: 'help' };
8
+ if (cmd === '--version' || cmd === '-v')
9
+ return { mode: 'version' };
10
+ if (cmd === 'install-skill') {
11
+ let dir;
12
+ let force = false;
13
+ for (let i = 0; i < rest.length; i++) {
14
+ const a = rest[i];
15
+ if (a === '--force' || a === '-f')
16
+ force = true;
17
+ else if (a === '--dir') {
18
+ const next = rest[++i];
19
+ // Guard against `--dir --force` (eats the flag) and a trailing `--dir`.
20
+ if (next === undefined || next.startsWith('-')) {
21
+ return { mode: 'error', message: '--dir requires a path' };
22
+ }
23
+ dir = next;
24
+ }
25
+ else if (a.startsWith('--dir=')) {
26
+ dir = a.slice('--dir='.length);
27
+ if (dir === '')
28
+ return { mode: 'error', message: '--dir requires a path' };
29
+ }
30
+ else
31
+ return { mode: 'error', message: `unknown option for install-skill: ${a}` };
32
+ }
33
+ return { mode: 'install-skill', dir, force };
34
+ }
35
+ return { mode: 'error', message: `unknown command: ${cmd}` };
36
+ }
37
+ export function helpText(version = readVersion()) {
38
+ return [
39
+ `tunnel-mcp v${version}`,
40
+ `An MCP server that lets two developers' Claude agents talk directly through an`,
41
+ `ephemeral, end-to-end-encrypted tunnel.`,
42
+ ``,
43
+ `This is a stdio MCP server, NOT an interactive CLI. With no arguments it starts`,
44
+ `the server and waits for an MCP client to connect over stdin/stdout, so running`,
45
+ `it by hand in a terminal will look like it "does nothing" — that's expected.`,
46
+ `You add it to an MCP client instead:`,
47
+ ``,
48
+ ` claude mcp add tunnel -- npx -y tunnel-mcp # Claude Code (this project)`,
49
+ ` claude mcp add -s user tunnel -- npx -y tunnel-mcp # ...available everywhere`,
50
+ ``,
51
+ `Commands:`,
52
+ ` (no args) Start the MCP server on stdio (for an MCP client to launch)`,
53
+ ` install-skill Install the tunnel-etiquette skill into your skills directory`,
54
+ ` --help, -h Show this help and exit`,
55
+ ` --version, -v Print the version and exit`,
56
+ ``,
57
+ `install-skill options:`,
58
+ ` --dir <path> Target skills directory (default: ~/.claude/skills or`,
59
+ ` $TUNNEL_SKILLS_DIR)`,
60
+ ` --force, -f Overwrite an existing install`,
61
+ ``,
62
+ `Environment:`,
63
+ ` TUNNEL_SKILLS_DIR Override the skills directory`,
64
+ ` TUNNEL_SKIP_SKILL_INSTALL=1 Skip the automatic skill install on npm install`,
65
+ ` TUNNEL_SKIP_REACHABILITY_CHECK=1 Open a tunnel even if this machine can't reach`,
66
+ ` *.trycloudflare.com (your guest still must)`,
67
+ ``,
68
+ `Docs: https://github.com/zachlikefolio/tunnel-mcp`,
69
+ ].join('\n');
70
+ }
71
+ /** Runs the `install-skill` command. Returns a process exit code. */
72
+ export function runInstallSkill(args, out) {
73
+ try {
74
+ const res = installSkill({ skillsDir: args.dir, overwrite: args.force });
75
+ if (res.installed) {
76
+ out(`Installed the tunnel-etiquette skill to ${res.target}${res.updated ? ' (overwrote the existing copy)' : ''}.`);
77
+ }
78
+ else {
79
+ out(`The tunnel-etiquette skill is already present at ${res.target}. Re-run with --force to overwrite it.`);
80
+ }
81
+ return 0;
82
+ }
83
+ catch (e) {
84
+ out(`Failed to install the skill: ${e instanceof Error ? e.message : String(e)}`);
85
+ return 1;
86
+ }
87
+ }
@@ -9,9 +9,12 @@ export interface StartOptions {
9
9
  intervalMs?: number;
10
10
  healthCheck?: (url: string) => Promise<boolean>;
11
11
  probeTimeoutMs?: number;
12
+ skipHealthCheck?: boolean;
12
13
  }
13
14
  export declare function parsePublicUrl(line: string): string | null;
15
+ export declare function describeProbeError(e: unknown): string;
14
16
  export declare function defaultHealthCheck(url: string): Promise<boolean>;
17
+ export declare function unreachableMessage(url: string, attempts: number, lastReason?: string): string;
15
18
  /**
16
19
  * `extraArgs` exists for tests: it lets a fake binary (e.g. `node fake.mjs`) be
17
20
  * launched in place of `cloudflared tunnel --url ...`. Production passes none.
@@ -8,49 +8,95 @@ export function parsePublicUrl(line) {
8
8
  const m = line.match(URL_RE);
9
9
  return m ? m[0] : null;
10
10
  }
11
- // Any HTTP response (even 404/502/426) means the Cloudflare edge is routing to us.
11
+ // undici (Node's global fetch) reports the real network error on `.cause`, e.g.
12
+ // a DNS failure surfaces as `cause.code === 'ENOTFOUND'`. Pull that out so the
13
+ // caller can tell "DNS can't resolve the host" apart from "edge not ready yet".
14
+ export function describeProbeError(e) {
15
+ const err = e;
16
+ const code = err?.cause?.code;
17
+ if (code)
18
+ return err.cause?.message ? `${code}: ${err.cause.message}` : code;
19
+ if (err?.name === 'TimeoutError')
20
+ return 'probe timed out';
21
+ return err?.message || 'unknown error';
22
+ }
23
+ // Any HTTP response (even 404/502/426) means the Cloudflare edge is routing to
24
+ // us. A thrown error carries why it failed (DNS, TLS, refused, timeout).
25
+ async function reachabilityProbe(url, probeTimeoutMs) {
26
+ try {
27
+ await fetch(url, { method: 'GET', signal: AbortSignal.timeout(probeTimeoutMs) });
28
+ return { ok: true };
29
+ }
30
+ catch (e) {
31
+ return { ok: false, reason: describeProbeError(e) };
32
+ }
33
+ }
34
+ // Back-compat boolean probe (kept for external callers/tests).
12
35
  export async function defaultHealthCheck(url) {
36
+ return (await reachabilityProbe(url, DEFAULT_PROBE_TIMEOUT_MS)).ok;
37
+ }
38
+ // Builds the actionable "never became reachable" error. Names the host and,
39
+ // when the failure looks like DNS resolution, points at *.trycloudflare.com
40
+ // blocking — the single most common real-world cause. Always mentions the
41
+ // escape hatch, since the guest's network (not the host's) is what must reach
42
+ // the URL for messaging.
43
+ export function unreachableMessage(url, attempts, lastReason) {
44
+ let host = url;
13
45
  try {
14
- await fetch(url, { method: 'GET', signal: AbortSignal.timeout(DEFAULT_PROBE_TIMEOUT_MS) });
15
- return true;
46
+ host = new URL(url).host;
16
47
  }
17
48
  catch {
18
- return false; // network/DNS error/timeout → edge not ready yet
49
+ /* keep the raw url */
50
+ }
51
+ const dnsish = !!lastReason && /ENOTFOUND|EAI_AGAIN|getaddrinfo|\bdns\b/i.test(lastReason);
52
+ let msg = `cloudflared reported ${url} but it never became reachable from this machine after ${attempts} probe(s)`;
53
+ if (lastReason)
54
+ msg += ` (last error: ${lastReason})`;
55
+ msg += '.';
56
+ if (dnsish) {
57
+ msg +=
58
+ ` This machine can't resolve ${host} — your DNS or network may be blocking *.trycloudflare.com` +
59
+ ` (common on filtered/corporate networks and some public DNS resolvers). You and your guest both` +
60
+ ` need to be able to resolve it.`;
19
61
  }
62
+ msg += ` If you're confident your guest's network can reach the URL, set TUNNEL_SKIP_REACHABILITY_CHECK=1 to open the tunnel anyway.`;
63
+ return msg;
20
64
  }
21
- // Races a single health-check attempt against a per-attempt timeout so that a
22
- // caller-supplied `check` that throws, rejects, or simply never resolves can
23
- // never leave the loop (and therefore the outer startCloudflared promise)
24
- // hanging. Any failure mode here just counts as "not healthy yet".
25
- function probeOnce(url, check, probeTimeoutMs) {
65
+ // Races a single probe against a per-attempt timeout so that a caller-supplied
66
+ // `check` that throws, rejects, or simply never resolves can never leave the
67
+ // loop (and therefore the outer startCloudflared promise) hanging.
68
+ function probeOnce(url, probe, probeTimeoutMs) {
26
69
  return new Promise((resolve) => {
27
70
  let settled = false;
28
- const finish = (ok) => {
71
+ const finish = (r) => {
29
72
  if (!settled) {
30
73
  settled = true;
31
- resolve(ok);
74
+ resolve(r);
32
75
  }
33
76
  };
34
- const timer = setTimeout(() => finish(false), probeTimeoutMs);
77
+ const timer = setTimeout(() => finish({ ok: false, reason: 'probe timed out' }), probeTimeoutMs);
35
78
  Promise.resolve()
36
- .then(() => check(url))
37
- .then((ok) => {
79
+ .then(() => probe(url))
80
+ .then((r) => {
38
81
  clearTimeout(timer);
39
- finish(ok);
82
+ finish(r);
40
83
  })
41
- .catch(() => {
84
+ .catch((err) => {
42
85
  clearTimeout(timer);
43
- finish(false);
86
+ finish({ ok: false, reason: describeProbeError(err) });
44
87
  });
45
88
  });
46
89
  }
47
- async function waitHealthy(url, attempts, intervalMs, check, probeTimeoutMs) {
90
+ async function waitHealthy(url, attempts, intervalMs, probe, probeTimeoutMs) {
91
+ let lastReason;
48
92
  for (let i = 0; i < attempts; i++) {
49
- if (await probeOnce(url, check, probeTimeoutMs))
50
- return true;
51
- await new Promise((r) => setTimeout(r, intervalMs));
93
+ const r = await probeOnce(url, probe, probeTimeoutMs);
94
+ if (r.ok)
95
+ return r;
96
+ lastReason = r.reason;
97
+ await new Promise((res) => setTimeout(res, intervalMs));
52
98
  }
53
- return false;
99
+ return { ok: false, reason: lastReason };
54
100
  }
55
101
  /**
56
102
  * `extraArgs` exists for tests: it lets a fake binary (e.g. `node fake.mjs`) be
@@ -63,8 +109,13 @@ export function startCloudflared(binPath, localPort, opts = {}) {
63
109
  const timeoutMs = opts.timeoutMs ?? CLOUDFLARED_URL_TIMEOUT_MS;
64
110
  const attempts = opts.attempts ?? CLOUDFLARED_HEALTH_ATTEMPTS;
65
111
  const intervalMs = opts.intervalMs ?? CLOUDFLARED_HEALTH_INTERVAL_MS;
66
- const check = opts.healthCheck ?? defaultHealthCheck;
67
112
  const probeTimeoutMs = opts.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
113
+ // A caller-supplied boolean healthCheck carries no failure reason; the default
114
+ // probe does. Adapt the former into a ProbeResult either way.
115
+ const custom = opts.healthCheck;
116
+ const probe = custom
117
+ ? async (u) => ({ ok: await custom(u) })
118
+ : (u) => reachabilityProbe(u, probeTimeoutMs);
68
119
  return new Promise((resolve, reject) => {
69
120
  const child = spawn(binPath, args, { stdio: ['ignore', 'pipe', 'pipe'] });
70
121
  let settled = false;
@@ -91,13 +142,20 @@ export function startCloudflared(binPath, localPort, opts = {}) {
91
142
  if (url && !settled) {
92
143
  settled = true;
93
144
  clearTimeout(timer);
94
- waitHealthy(url, attempts, intervalMs, check, probeTimeoutMs)
95
- .then((ok) => {
96
- if (ok)
145
+ // Escape hatch: the reachability probe runs on the *host*, but only the
146
+ // guest's network must reach the URL for messaging. A host on a filtered
147
+ // network can opt to skip the probe and hand out the link regardless.
148
+ if (opts.skipHealthCheck) {
149
+ resolve({ publicUrl: url, stop });
150
+ return;
151
+ }
152
+ waitHealthy(url, attempts, intervalMs, probe, probeTimeoutMs)
153
+ .then((res) => {
154
+ if (res.ok)
97
155
  resolve({ publicUrl: url, stop });
98
156
  else {
99
157
  stop();
100
- reject(new Error('cloudflared tunnel never became reachable'));
158
+ reject(new Error(unreachableMessage(url, attempts, res.reason)));
101
159
  }
102
160
  })
103
161
  .catch((err) => {
package/dist/config.d.ts CHANGED
@@ -3,6 +3,7 @@ export declare const BIN_DIR: string;
3
3
  export declare const SESSIONS_DIR: string;
4
4
  export declare const DEFAULT_LISTEN_TIMEOUT_MS = 60000;
5
5
  export declare const DEFAULT_IDLE_TEARDOWN_MS: number;
6
+ export declare const DEFAULT_JOIN_LINK_TTL_MS: number;
6
7
  export declare const CLOUDFLARED_URL_TIMEOUT_MS = 30000;
7
8
  export declare const CLOUDFLARED_HEALTH_ATTEMPTS = 10;
8
9
  export declare const CLOUDFLARED_HEALTH_INTERVAL_MS = 1000;
package/dist/config.js CHANGED
@@ -5,6 +5,9 @@ export const BIN_DIR = path.join(TUNNEL_HOME, 'bin');
5
5
  export const SESSIONS_DIR = path.join(TUNNEL_HOME, 'sessions');
6
6
  export const DEFAULT_LISTEN_TIMEOUT_MS = 60_000;
7
7
  export const DEFAULT_IDLE_TEARDOWN_MS = 30 * 60_000;
8
+ // Join links are single-use and expire after this window; a leaked link that
9
+ // is never used (or is reused after the guest joined) can't admit anyone.
10
+ export const DEFAULT_JOIN_LINK_TTL_MS = 10 * 60_000;
8
11
  // cloudflared startup robustness
9
12
  export const CLOUDFLARED_URL_TIMEOUT_MS = 30_000; // wait for the URL line
10
13
  export const CLOUDFLARED_HEALTH_ATTEMPTS = 10; // edge-reachability probes
package/dist/env.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * True only when an env var is set to a meaningfully "on" value. Unset, '', '0',
3
+ * 'false', 'no', and 'off' (any case, trimmed) all read as off — so `FOO=0`
4
+ * disables a flag instead of accidentally enabling it (a plain `process.env.FOO`
5
+ * truthiness check treats "0"/"false" as true).
6
+ */
7
+ export declare function envFlag(name: string): boolean;
package/dist/env.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * True only when an env var is set to a meaningfully "on" value. Unset, '', '0',
3
+ * 'false', 'no', and 'off' (any case, trimmed) all read as off — so `FOO=0`
4
+ * disables a flag instead of accidentally enabling it (a plain `process.env.FOO`
5
+ * truthiness check treats "0"/"false" as true).
6
+ */
7
+ export function envFlag(name) {
8
+ const v = process.env[name];
9
+ if (v === undefined)
10
+ return false;
11
+ const s = v.trim().toLowerCase();
12
+ return s !== '' && s !== '0' && s !== 'false' && s !== 'no' && s !== 'off';
13
+ }
package/dist/index.js CHANGED
@@ -3,35 +3,71 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import { TunnelSession } from './session.js';
5
5
  import { registerTools, defaultDisplayName } from './tools.js';
6
- const session = new TunnelSession();
7
- const server = new McpServer({ name: 'tunnel', version: '0.1.0' });
8
- registerTools(server, session, { displayName: defaultDisplayName() });
9
- let closing = false;
10
- async function teardown() {
11
- if (closing)
12
- return;
13
- closing = true;
14
- try {
15
- await session.close('process exit');
6
+ import { parseArgs, helpText, runInstallSkill } from './cli.js';
7
+ import { readVersion, isSkillInstalled } from './skillInstall.js';
8
+ // Write a one-shot command's output, then exit only once it has flushed — a bare
9
+ // write()+exit() can truncate output piped to another process.
10
+ function writeThenExit(stream, text, code) {
11
+ stream.write(text.endsWith('\n') ? text : text + '\n', () => process.exit(code));
12
+ }
13
+ async function main() {
14
+ const version = readVersion();
15
+ const parsed = parseArgs(process.argv.slice(2));
16
+ // One-shot commands print and exit without ever opening the JSON-RPC channel.
17
+ if (parsed.mode === 'help')
18
+ return writeThenExit(process.stdout, helpText(version), 0);
19
+ if (parsed.mode === 'version')
20
+ return writeThenExit(process.stdout, version, 0);
21
+ if (parsed.mode === 'error') {
22
+ return writeThenExit(process.stderr, `tunnel-mcp: ${parsed.message}\n\n${helpText(version)}`, 2);
23
+ }
24
+ if (parsed.mode === 'install-skill') {
25
+ const lines = [];
26
+ const code = runInstallSkill({ dir: parsed.dir, force: parsed.force }, (m) => lines.push(m));
27
+ // Success → stdout; failure → stderr.
28
+ return writeThenExit(code === 0 ? process.stdout : process.stderr, lines.join('\n'), code);
29
+ }
30
+ // Serve. stdout is reserved for the JSON-RPC transport, so all human-facing
31
+ // output goes to stderr (MCP clients capture it into their logs).
32
+ const session = new TunnelSession();
33
+ const server = new McpServer({ name: 'tunnel', version });
34
+ registerTools(server, session, { displayName: defaultDisplayName() });
35
+ let closing = false;
36
+ async function teardown() {
37
+ if (closing)
38
+ return;
39
+ closing = true;
40
+ try {
41
+ await session.close('process exit');
42
+ }
43
+ catch {
44
+ /* best effort */
45
+ }
46
+ process.exit(0);
16
47
  }
17
- catch {
18
- /* best effort */
48
+ process.on('SIGINT', teardown);
49
+ process.on('SIGTERM', teardown);
50
+ const transport = new StdioServerTransport();
51
+ // The host holds an HTTP/WS listener + a cloudflared child, so the event loop
52
+ // never drains and `beforeExit` would never fire. Drive teardown off the stdio
53
+ // pipe closing instead, which is how an MCP client actually ends the server.
54
+ transport.onclose = () => {
55
+ void teardown();
56
+ };
57
+ process.stdin.on('end', () => {
58
+ void teardown();
59
+ });
60
+ process.stdin.on('close', () => {
61
+ void teardown();
62
+ });
63
+ // Startup banner so a human who runs this by hand isn't staring at a silent,
64
+ // seemingly-hung process (this is a server, not an interactive CLI).
65
+ process.stderr.write(`tunnel-mcp v${version} ready on stdio — this is an MCP server, not an interactive CLI. ` +
66
+ `Add it to an MCP client (run \`tunnel-mcp --help\`).\n`);
67
+ if (!isSkillInstalled()) {
68
+ process.stderr.write(`tip: run \`npx tunnel-mcp install-skill\` to install the tunnel-etiquette skill ` +
69
+ `(teaches agents to treat the peer as untrusted input).\n`);
19
70
  }
20
- process.exit(0);
71
+ await server.connect(transport);
21
72
  }
22
- process.on('SIGINT', teardown);
23
- process.on('SIGTERM', teardown);
24
- const transport = new StdioServerTransport();
25
- // The host holds an HTTP/WS listener + a cloudflared child, so the event loop
26
- // never drains and `beforeExit` would never fire. Drive teardown off the stdio
27
- // pipe closing instead, which is how an MCP client actually ends the server.
28
- transport.onclose = () => {
29
- void teardown();
30
- };
31
- process.stdin.on('end', () => {
32
- void teardown();
33
- });
34
- process.stdin.on('close', () => {
35
- void teardown();
36
- });
37
- await server.connect(transport);
73
+ void main();
@@ -8,6 +8,7 @@ export interface HostRelayOptions {
8
8
  goal: string;
9
9
  hostName: string;
10
10
  idleMs?: number;
11
+ joinTtlMs?: number;
11
12
  }
12
13
  export declare class HostRelay extends EventEmitter {
13
14
  private opts;
@@ -19,9 +20,12 @@ export declare class HostRelay extends EventEmitter {
19
20
  private challenges;
20
21
  private idleTimer?;
21
22
  private tearingDown;
23
+ private consumed;
24
+ private joinDeadline;
22
25
  constructor(opts: HostRelayOptions, log: SessionLog);
23
26
  start(): Promise<number>;
24
27
  get peerConnected(): boolean;
28
+ armJoinDeadline(ttlMs?: number): void;
25
29
  submitLocal(msg: WireMessage): WireMessage;
26
30
  private resetIdle;
27
31
  private submit;
@@ -3,7 +3,7 @@ import { EventEmitter } from 'node:events';
3
3
  import { WebSocketServer, WebSocket } from 'ws';
4
4
  import { makeChallenge, verifyChallenge } from '../protocol/crypto.js';
5
5
  import { encodeFrame, decodeFrame, buildSystem, } from '../protocol/messages.js';
6
- import { DEFAULT_IDLE_TEARDOWN_MS } from '../config.js';
6
+ import { DEFAULT_IDLE_TEARDOWN_MS, DEFAULT_JOIN_LINK_TTL_MS } from '../config.js';
7
7
  export class HostRelay extends EventEmitter {
8
8
  opts;
9
9
  log;
@@ -14,10 +14,16 @@ export class HostRelay extends EventEmitter {
14
14
  challenges = new WeakMap();
15
15
  idleTimer;
16
16
  tearingDown = false;
17
+ // Single-use, expiring join link: the link is valid until `joinDeadline`, and
18
+ // is consumed by the first successful authentication so it can never be reused
19
+ // (even after that guest disconnects).
20
+ consumed = false;
21
+ joinDeadline;
17
22
  constructor(opts, log) {
18
23
  super();
19
24
  this.opts = opts;
20
25
  this.log = log;
26
+ this.joinDeadline = Date.now() + (opts.joinTtlMs ?? DEFAULT_JOIN_LINK_TTL_MS);
21
27
  this.server = http.createServer();
22
28
  this.wss = new WebSocketServer({ server: this.server, path: `/t/${opts.tunnelId}` });
23
29
  this.wss.on('connection', (ws) => this.onConnection(ws));
@@ -39,6 +45,13 @@ export class HostRelay extends EventEmitter {
39
45
  get peerConnected() {
40
46
  return !!this.guest && this.guest.readyState === WebSocket.OPEN;
41
47
  }
48
+ // (Re)start the single-use join link's expiry window. The session calls this
49
+ // once the link is actually minted (after cloudflared provisioning), so the
50
+ // window is measured from when the host receives the link to share — not from
51
+ // relay construction, which happens before provisioning could burn time.
52
+ armJoinDeadline(ttlMs) {
53
+ this.joinDeadline = Date.now() + (ttlMs ?? this.opts.joinTtlMs ?? DEFAULT_JOIN_LINK_TTL_MS);
54
+ }
42
55
  submitLocal(msg) {
43
56
  return this.submit(msg);
44
57
  }
@@ -95,11 +108,24 @@ export class HostRelay extends EventEmitter {
95
108
  ws.close();
96
109
  return;
97
110
  }
98
- if (this.guest && this.guest !== ws && this.guest.readyState === WebSocket.OPEN) {
99
- ws.send(encodeFrame({ t: 'auth_fail', reason: 'tunnel full' }));
111
+ if (Date.now() > this.joinDeadline) {
112
+ ws.send(encodeFrame({ t: 'auth_fail', reason: 'join link expired' }));
113
+ ws.close();
114
+ return;
115
+ }
116
+ if (this.consumed) {
117
+ // The single-use link was already redeemed. Distinguish the case
118
+ // where the original guest is still connected ('tunnel full') from a
119
+ // reuse attempt after they left ('join link already used').
120
+ const connected = !!this.guest && this.guest.readyState === WebSocket.OPEN;
121
+ ws.send(encodeFrame({
122
+ t: 'auth_fail',
123
+ reason: connected ? 'tunnel full' : 'join link already used',
124
+ }));
100
125
  ws.close();
101
126
  return;
102
127
  }
128
+ this.consumed = true;
103
129
  this.guest = ws;
104
130
  this.guestName = frame.name;
105
131
  const sinceSeq = Number.isFinite(frame.sinceSeq) ? frame.sinceSeq : 0;
package/dist/session.d.ts CHANGED
@@ -4,6 +4,7 @@ export interface SessionDeps {
4
4
  ensureCloudflared: () => Promise<string>;
5
5
  startCloudflared: (bin: string, port: number) => Promise<TunnelHandle>;
6
6
  idleMs?: number;
7
+ joinTtlMs?: number;
7
8
  }
8
9
  export interface SessionStatus {
9
10
  role: Role;
@@ -30,6 +31,7 @@ export declare class TunnelSession {
30
31
  tunnelId: string;
31
32
  joinLink: string;
32
33
  status: string;
34
+ joinLinkExpiresInSec: number;
33
35
  }>;
34
36
  join(joinLink: string, guestName: string): Promise<{
35
37
  tunnelId: string;
package/dist/session.js CHANGED
@@ -6,10 +6,13 @@ import { HostRelay } from './relay/hostRelay.js';
6
6
  import { GuestClient } from './relay/guestClient.js';
7
7
  import { ensureCloudflared as realEnsure } from './cloudflared/provision.js';
8
8
  import { startCloudflared as realStart } from './cloudflared/tunnelProcess.js';
9
- import { DEFAULT_LISTEN_TIMEOUT_MS, DEFAULT_IDLE_TEARDOWN_MS, OPEN_RETRY_ATTEMPTS, } from './config.js';
9
+ import { DEFAULT_LISTEN_TIMEOUT_MS, DEFAULT_IDLE_TEARDOWN_MS, DEFAULT_JOIN_LINK_TTL_MS, OPEN_RETRY_ATTEMPTS, } from './config.js';
10
+ import { envFlag } from './env.js';
10
11
  const DEFAULT_DEPS = {
11
12
  ensureCloudflared: realEnsure,
12
- startCloudflared: (bin, port) => realStart(bin, port),
13
+ // Read the escape hatch per-call (not at module load) so it can be set right
14
+ // before opening a tunnel.
15
+ startCloudflared: (bin, port) => realStart(bin, port, envFlag('TUNNEL_SKIP_REACHABILITY_CHECK') ? { skipHealthCheck: true } : {}),
13
16
  };
14
17
  export class TunnelSession {
15
18
  deps;
@@ -35,8 +38,9 @@ export class TunnelSession {
35
38
  const key = generateKey();
36
39
  const tunnelId = generateTunnelId();
37
40
  const idleMs = this.deps.idleMs ?? DEFAULT_IDLE_TEARDOWN_MS;
41
+ const joinTtlMs = this.deps.joinTtlMs ?? DEFAULT_JOIN_LINK_TTL_MS;
38
42
  const log = new SessionLog(tunnelId);
39
- const relay = new HostRelay({ tunnelId, key, goal, hostName, idleMs }, log);
43
+ const relay = new HostRelay({ tunnelId, key, goal, hostName, idleMs, joinTtlMs }, log);
40
44
  const port = await relay.start();
41
45
  // Bounded retry: cloudflared may crash or never yield a URL; re-spawn before giving up.
42
46
  let tunnel;
@@ -56,6 +60,10 @@ export class TunnelSession {
56
60
  log.delete();
57
61
  throw new Error(`could not establish a cloudflared tunnel after ${OPEN_RETRY_ATTEMPTS} attempts: ${String(lastErr)}`);
58
62
  }
63
+ // Start the single-use link's expiry window now that the link exists —
64
+ // measured from mint time, not from relay construction (which happened
65
+ // before cloudflared provisioning could burn part of the window).
66
+ relay.armJoinDeadline();
59
67
  const joinLink = mintLink(tunnel.publicUrl, tunnelId, key);
60
68
  this.role = 'host';
61
69
  this.key = key;
@@ -71,7 +79,12 @@ export class TunnelSession {
71
79
  void this.close();
72
80
  });
73
81
  relay.submitLocal(buildSystem('host', `tunnel opened — goal: ${goal}`));
74
- return { tunnelId, joinLink, status: 'waiting_for_guest' };
82
+ return {
83
+ tunnelId,
84
+ joinLink,
85
+ status: 'waiting_for_guest',
86
+ joinLinkExpiresInSec: Math.round(joinTtlMs / 1000),
87
+ };
75
88
  }
76
89
  async join(joinLink, guestName) {
77
90
  if (this.isOpen)
@@ -0,0 +1,23 @@
1
+ export declare function bundledSkillDir(): string;
2
+ export declare function readVersion(): string;
3
+ export declare function defaultSkillsDir(): string;
4
+ /** Precedence: explicit arg → $TUNNEL_SKILLS_DIR → ~/.claude/skills. */
5
+ export declare function resolveSkillsDir(explicit?: string): string;
6
+ export declare function isSkillInstalled(skillsDir?: string): boolean;
7
+ export interface InstallResult {
8
+ installed: boolean;
9
+ updated: boolean;
10
+ target: string;
11
+ source: string;
12
+ }
13
+ export declare function installSkill(opts?: {
14
+ skillsDir?: string;
15
+ overwrite?: boolean;
16
+ }): InstallResult;
17
+ /**
18
+ * Called from the postinstall script. Best-effort by contract: it must never
19
+ * throw (a failed skill copy must not fail `npm install`), it only installs
20
+ * when absent (never clobbers a user's copy on reinstall), and it bows out
21
+ * under CI or when the user opts out.
22
+ */
23
+ export declare function installSkillBestEffort(log?: (msg: string) => void): void;
@@ -0,0 +1,69 @@
1
+ import { cpSync, existsSync, readFileSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { envFlag } from './env.js';
6
+ const SKILL_NAME = 'tunnel-etiquette';
7
+ // dist/skillInstall.js → package root is one level up. During tests (tsx runs
8
+ // src/skillInstall.ts) this resolves to the repo root, which has the same
9
+ // `skill/` and `package.json` layout as the published package.
10
+ function packageRoot() {
11
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
+ }
13
+ export function bundledSkillDir() {
14
+ return path.join(packageRoot(), 'skill', SKILL_NAME);
15
+ }
16
+ export function readVersion() {
17
+ try {
18
+ const pkg = JSON.parse(readFileSync(path.join(packageRoot(), 'package.json'), 'utf8'));
19
+ return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
20
+ }
21
+ catch {
22
+ return '0.0.0';
23
+ }
24
+ }
25
+ export function defaultSkillsDir() {
26
+ return path.join(os.homedir(), '.claude', 'skills');
27
+ }
28
+ /** Precedence: explicit arg → $TUNNEL_SKILLS_DIR → ~/.claude/skills. */
29
+ export function resolveSkillsDir(explicit) {
30
+ return explicit || process.env.TUNNEL_SKILLS_DIR || defaultSkillsDir();
31
+ }
32
+ function targetDir(skillsDir) {
33
+ return path.join(skillsDir, SKILL_NAME);
34
+ }
35
+ export function isSkillInstalled(skillsDir = resolveSkillsDir()) {
36
+ return existsSync(path.join(targetDir(skillsDir), 'SKILL.md'));
37
+ }
38
+ export function installSkill(opts = {}) {
39
+ const source = bundledSkillDir();
40
+ if (!existsSync(path.join(source, 'SKILL.md'))) {
41
+ throw new Error(`bundled tunnel-etiquette skill not found at ${source}`);
42
+ }
43
+ const skillsDir = resolveSkillsDir(opts.skillsDir);
44
+ const target = targetDir(skillsDir);
45
+ const existed = existsSync(path.join(target, 'SKILL.md'));
46
+ if (existed && !opts.overwrite) {
47
+ return { installed: false, updated: false, target, source };
48
+ }
49
+ cpSync(source, target, { recursive: true });
50
+ return { installed: true, updated: existed, target, source };
51
+ }
52
+ /**
53
+ * Called from the postinstall script. Best-effort by contract: it must never
54
+ * throw (a failed skill copy must not fail `npm install`), it only installs
55
+ * when absent (never clobbers a user's copy on reinstall), and it bows out
56
+ * under CI or when the user opts out.
57
+ */
58
+ export function installSkillBestEffort(log = () => { }) {
59
+ if (envFlag('TUNNEL_SKIP_SKILL_INSTALL') || envFlag('CI'))
60
+ return;
61
+ try {
62
+ const res = installSkill({ overwrite: false });
63
+ if (res.installed)
64
+ log(`tunnel-mcp: installed the tunnel-etiquette skill to ${res.target}`);
65
+ }
66
+ catch {
67
+ /* never break an install */
68
+ }
69
+ }
package/dist/tools.js CHANGED
@@ -28,7 +28,7 @@ function register(server, name, schema, cb) {
28
28
  }
29
29
  export function registerTools(server, session, opts) {
30
30
  register(server, 'tunnel_open', {
31
- description: 'Open a tunnel as host and get a join link to share.',
31
+ description: 'Open a tunnel as host and get a join link to share. The link is a secret — share it over a trusted channel. It is single-use (works for exactly one guest) and expires (see joinLinkExpiresInSec in the result), so tell the human to share it promptly.',
32
32
  inputSchema: { goal: z.string() },
33
33
  }, async ({ goal }) => ok(await session.open(goal, opts.displayName)));
34
34
  register(server, 'tunnel_join', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tunnel-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Let two developers' Claude agents talk directly through an ephemeral, end-to-end-encrypted tunnel.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  "files": [
12
12
  "dist",
13
13
  "skill",
14
+ "postinstall.mjs",
14
15
  "README.md",
15
16
  "LICENSE",
16
17
  "CHANGELOG.md",
@@ -28,6 +29,8 @@
28
29
  "format": "prettier --write .",
29
30
  "format:check": "prettier --check .",
30
31
  "dev": "tsx src/index.ts",
32
+ "e2e": "tsx scripts/e2e-two-agents.ts",
33
+ "postinstall": "node postinstall.mjs",
31
34
  "prepublishOnly": "npm run build"
32
35
  },
33
36
  "keywords": [
@@ -62,6 +65,7 @@
62
65
  "zod": "^3.23.0"
63
66
  },
64
67
  "devDependencies": {
68
+ "@anthropic-ai/sdk": "^0.109.0",
65
69
  "@types/node": "^20.14.0",
66
70
  "@types/ws": "^8.5.10",
67
71
  "@vitest/coverage-v8": "^2.0.0",
@@ -0,0 +1,13 @@
1
+ // Best-effort: install the tunnel-etiquette skill into ~/.claude/skills when the
2
+ // package is installed. This MUST never fail an install and is deliberately a
3
+ // no-op in the cases where it can't or shouldn't run:
4
+ // - `npm install --ignore-scripts` (and hosts that disable scripts) skip it
5
+ // - CI, or TUNNEL_SKIP_SKILL_INSTALL=1, opt out (see installSkillBestEffort)
6
+ // - a dev checkout before `npm run build` has no dist/ yet → import throws → ignored
7
+ // It IS idempotent, so running again (e.g. npx populating its cache) is harmless.
8
+ try {
9
+ const { installSkillBestEffort } = await import('./dist/skillInstall.js');
10
+ installSkillBestEffort((m) => console.error(m));
11
+ } catch {
12
+ /* dist not built yet, or any other error — never break the install */
13
+ }