tunnel-mcp 0.1.1 → 0.1.3
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 +44 -1
- package/README.md +46 -7
- package/dist/cli.d.ts +21 -0
- package/dist/cli.js +89 -0
- package/dist/cloudflared/tunnelProcess.d.ts +6 -0
- package/dist/cloudflared/tunnelProcess.js +107 -27
- package/dist/env.d.ts +19 -0
- package/dist/env.js +31 -0
- package/dist/index.js +65 -29
- package/dist/session.d.ts +1 -0
- package/dist/session.js +7 -1
- package/dist/skillInstall.d.ts +23 -0
- package/dist/skillInstall.js +69 -0
- package/dist/tools.js +1 -1
- package/package.json +3 -1
- package/postinstall.mjs +13 -0
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,46 @@ 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.3] - 2026-07-01
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **`tunnel_open` no longer hard-fails when the host can't reach
|
|
19
|
+
`*.trycloudflare.com`.** Because this is a cross-network tool, only the guest's
|
|
20
|
+
network has to reach the link — so a host-side reachability-probe failure
|
|
21
|
+
(blocked DNS, or a proxy Node's `fetch` ignores) now **opens the tunnel anyway
|
|
22
|
+
and returns a `reachabilityWarning`** by default, instead of blocking a tunnel
|
|
23
|
+
that would have worked for the guest. Behavior is configurable via
|
|
24
|
+
`TUNNEL_REACHABILITY`: `warn` (default), `strict` (previous hard-fail), or
|
|
25
|
+
`off` (skip the probe). This replaces the `TUNNEL_SKIP_REACHABILITY_CHECK` flag
|
|
26
|
+
from 0.1.2, which is still honored as `off` for backward compatibility.
|
|
27
|
+
|
|
28
|
+
## [0.1.2] - 2026-07-01
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **`install-skill` command and automatic skill install.** `tunnel-mcp
|
|
33
|
+
install-skill` copies the `tunnel-etiquette` skill into `~/.claude/skills`
|
|
34
|
+
(override with `--dir`/`$TUNNEL_SKILLS_DIR`, overwrite with `--force`), and a
|
|
35
|
+
global `npm install` now installs it best-effort via a postinstall script.
|
|
36
|
+
Set `TUNNEL_SKIP_SKILL_INSTALL=1` to opt out; the postinstall never fails an
|
|
37
|
+
install and is a no-op under `npx`, `--ignore-scripts`, and CI.
|
|
38
|
+
- **`--help` and `--version` flags**, plus a one-line stderr startup banner, so
|
|
39
|
+
running the server by hand no longer looks like a silent hang. The server also
|
|
40
|
+
hints how to install the etiquette skill when it isn't present.
|
|
41
|
+
- **`TUNNEL_SKIP_REACHABILITY_CHECK` escape hatch.** Opens a tunnel even when the
|
|
42
|
+
host can't reach `*.trycloudflare.com` itself — useful when only the guest's
|
|
43
|
+
network needs to reach the URL.
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- The MCP server reported a hardcoded, stale version (`0.1.0`) in its handshake;
|
|
48
|
+
it now reports the real package version.
|
|
49
|
+
- A failed cloudflared reachability probe surfaced a generic "never became
|
|
50
|
+
reachable" error. It now names the host and, when the failure is DNS
|
|
51
|
+
resolution, points at `*.trycloudflare.com` being blocked (a common
|
|
52
|
+
corporate/filtered-DNS cause) and mentions the escape hatch above.
|
|
53
|
+
|
|
14
54
|
## [0.1.1] - 2026-07-01
|
|
15
55
|
|
|
16
56
|
### Security
|
|
@@ -54,5 +94,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
54
94
|
declaring a fix "confirmed".
|
|
55
95
|
- Test suite of 109 tests built with vitest, developed test-first (TDD).
|
|
56
96
|
|
|
57
|
-
[Unreleased]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.
|
|
97
|
+
[Unreleased]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.3...HEAD
|
|
98
|
+
[0.1.3]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.2...v0.1.3
|
|
99
|
+
[0.1.2]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.1...v0.1.2
|
|
100
|
+
[0.1.1]: https://github.com/zachlikefolio/tunnel-mcp/compare/v0.1.0...v0.1.1
|
|
58
101
|
[0.1.0]: https://github.com/zachlikefolio/tunnel-mcp/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -50,13 +50,29 @@ npx tunnel-mcp
|
|
|
50
50
|
Register it with Claude Code (both developers do this once):
|
|
51
51
|
|
|
52
52
|
```bash
|
|
53
|
-
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
|
|
54
56
|
```
|
|
55
57
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
60
76
|
|
|
61
77
|
`cloudflared` is auto-downloaded to `~/.tunnel/bin` the first time it's needed if
|
|
62
78
|
it isn't already on your `PATH` — there's nothing extra to install.
|
|
@@ -146,7 +162,7 @@ vulnerability.
|
|
|
146
162
|
|
|
147
163
|
```bash
|
|
148
164
|
npm ci # install dependencies
|
|
149
|
-
npm test # run the test suite (
|
|
165
|
+
npm test # run the test suite (136 tests, TDD)
|
|
150
166
|
npm run build # compile TypeScript
|
|
151
167
|
npm run lint # eslint
|
|
152
168
|
npm run format:check # prettier --check .
|
|
@@ -155,6 +171,29 @@ npm run test:coverage # vitest run --coverage
|
|
|
155
171
|
|
|
156
172
|
See [CONTRIBUTING.md](./CONTRIBUTING.md) for how to propose changes.
|
|
157
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` warns that this machine can't reach `*.trycloudflare.com`.**
|
|
182
|
+
cloudflared reaches Cloudflare's edge over its own protocol, but the public
|
|
183
|
+
`*.trycloudflare.com` hostname still has to resolve via normal DNS — and some
|
|
184
|
+
networks (corporate/filtered networks, some public DNS resolvers, or a proxy that
|
|
185
|
+
Node's `fetch` ignores) can't reach it from the host. Because only your
|
|
186
|
+
**guest's** network truly has to reach the link, `tunnel_open` **opens anyway and
|
|
187
|
+
returns a `reachabilityWarning` by default** — share the link and have your guest
|
|
188
|
+
confirm they can open it. Control this with `TUNNEL_REACHABILITY`:
|
|
189
|
+
|
|
190
|
+
- `warn` (default) — open, but warn when the host can't reach the URL
|
|
191
|
+
- `strict` — fail `tunnel_open` unless the host can reach the URL first
|
|
192
|
+
- `off` — skip the host-side reachability probe entirely
|
|
193
|
+
|
|
194
|
+
Diagnose the host's DNS with `dig +short <random>.trycloudflare.com` or
|
|
195
|
+
`curl -sI https://<the-url>`.
|
|
196
|
+
|
|
158
197
|
## Roadmap / not yet supported
|
|
159
198
|
|
|
160
199
|
This is an MVP. The following are explicitly out of scope for now:
|
|
@@ -162,7 +201,7 @@ This is an MVP. The following are explicitly out of scope for now:
|
|
|
162
201
|
- Host-offline / asynchronous messaging
|
|
163
202
|
- More than two participants in a session
|
|
164
203
|
- Alternative transports (ngrok, WebRTC)
|
|
165
|
-
- Join-link rotation
|
|
204
|
+
- Join-link rotation (re-issuing a fresh link mid-session; note that links are already single-use and expiring — see the security model above)
|
|
166
205
|
- Encrypting the goal or other metadata
|
|
167
206
|
|
|
168
207
|
## License
|
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,89 @@
|
|
|
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_REACHABILITY warn (default) | strict | off — how tunnel_open reacts`,
|
|
66
|
+
` when this host can't reach *.trycloudflare.com. Only the`,
|
|
67
|
+
` guest's network truly must reach it, so the default warns`,
|
|
68
|
+
` and opens anyway; strict fails; off skips the check.`,
|
|
69
|
+
``,
|
|
70
|
+
`Docs: https://github.com/zachlikefolio/tunnel-mcp`,
|
|
71
|
+
].join('\n');
|
|
72
|
+
}
|
|
73
|
+
/** Runs the `install-skill` command. Returns a process exit code. */
|
|
74
|
+
export function runInstallSkill(args, out) {
|
|
75
|
+
try {
|
|
76
|
+
const res = installSkill({ skillsDir: args.dir, overwrite: args.force });
|
|
77
|
+
if (res.installed) {
|
|
78
|
+
out(`Installed the tunnel-etiquette skill to ${res.target}${res.updated ? ' (overwrote the existing copy)' : ''}.`);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
out(`The tunnel-etiquette skill is already present at ${res.target}. Re-run with --force to overwrite it.`);
|
|
82
|
+
}
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
out(`Failed to install the skill: ${e instanceof Error ? e.message : String(e)}`);
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { ReachabilityMode } from '../env.js';
|
|
1
2
|
export interface TunnelHandle {
|
|
2
3
|
publicUrl: string;
|
|
3
4
|
stop(): void;
|
|
5
|
+
reachabilityWarning?: string;
|
|
4
6
|
}
|
|
5
7
|
export interface StartOptions {
|
|
6
8
|
timeoutMs?: number;
|
|
@@ -9,9 +11,13 @@ export interface StartOptions {
|
|
|
9
11
|
intervalMs?: number;
|
|
10
12
|
healthCheck?: (url: string) => Promise<boolean>;
|
|
11
13
|
probeTimeoutMs?: number;
|
|
14
|
+
reachability?: ReachabilityMode;
|
|
12
15
|
}
|
|
13
16
|
export declare function parsePublicUrl(line: string): string | null;
|
|
17
|
+
export declare function describeProbeError(e: unknown): string;
|
|
14
18
|
export declare function defaultHealthCheck(url: string): Promise<boolean>;
|
|
19
|
+
export declare function unreachableMessage(url: string, attempts: number, lastReason?: string): string;
|
|
20
|
+
export declare function reachabilityWarningMessage(url: string, lastReason?: string): string;
|
|
15
21
|
/**
|
|
16
22
|
* `extraArgs` exists for tests: it lets a fake binary (e.g. `node fake.mjs`) be
|
|
17
23
|
* launched in place of `cloudflared tunnel --url ...`. Production passes none.
|
|
@@ -8,49 +8,108 @@ export function parsePublicUrl(line) {
|
|
|
8
8
|
const m = line.match(URL_RE);
|
|
9
9
|
return m ? m[0] : null;
|
|
10
10
|
}
|
|
11
|
-
//
|
|
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
|
+
// The shared DNS sentence: when a probe failure looks like name resolution,
|
|
39
|
+
// point at *.trycloudflare.com being blocked — the single most common real-world
|
|
40
|
+
// cause. Returns '' when the failure isn't DNS-shaped.
|
|
41
|
+
function dnsHint(url, reason) {
|
|
42
|
+
if (!reason || !/ENOTFOUND|EAI_AGAIN|getaddrinfo|\bdns\b/i.test(reason))
|
|
43
|
+
return '';
|
|
44
|
+
let host = url;
|
|
13
45
|
try {
|
|
14
|
-
|
|
15
|
-
return true;
|
|
46
|
+
host = new URL(url).host;
|
|
16
47
|
}
|
|
17
48
|
catch {
|
|
18
|
-
|
|
49
|
+
/* keep the raw url */
|
|
19
50
|
}
|
|
51
|
+
return (` This machine can't resolve ${host} — your DNS or network may be blocking *.trycloudflare.com` +
|
|
52
|
+
` (common on filtered/corporate networks and some public DNS resolvers).`);
|
|
53
|
+
}
|
|
54
|
+
// Fatal error for `strict` mode: the host never confirmed the edge was routing.
|
|
55
|
+
export function unreachableMessage(url, attempts, lastReason) {
|
|
56
|
+
let msg = `cloudflared reported ${url} but it never became reachable from this machine after ${attempts} probe(s)`;
|
|
57
|
+
if (lastReason)
|
|
58
|
+
msg += ` (last error: ${lastReason})`;
|
|
59
|
+
msg += '.' + dnsHint(url, lastReason);
|
|
60
|
+
msg +=
|
|
61
|
+
` Both you and your guest must be able to reach it. Set TUNNEL_REACHABILITY=warn (the default) to` +
|
|
62
|
+
` open anyway with a warning, or TUNNEL_REACHABILITY=off to skip this check entirely.`;
|
|
63
|
+
return msg;
|
|
20
64
|
}
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
65
|
+
// Non-fatal warning for `warn` mode: the tunnel is open, but this host couldn't
|
|
66
|
+
// confirm reachability. Only the guest's network has to reach the URL, so this
|
|
67
|
+
// is often a false alarm — but surface it so the human can sanity-check.
|
|
68
|
+
export function reachabilityWarningMessage(url, lastReason) {
|
|
69
|
+
let msg = `Tunnel opened, but this machine could not reach ${url}`;
|
|
70
|
+
if (lastReason)
|
|
71
|
+
msg += ` (${lastReason})`;
|
|
72
|
+
msg += '.' + dnsHint(url, lastReason);
|
|
73
|
+
msg +=
|
|
74
|
+
` Your guest still needs to reach the link — if they can't open it, check your DNS/proxy. Set` +
|
|
75
|
+
` TUNNEL_REACHABILITY=strict to require host reachability, or =off to silence this check.`;
|
|
76
|
+
return msg;
|
|
77
|
+
}
|
|
78
|
+
// Races a single probe against a per-attempt timeout so that a caller-supplied
|
|
79
|
+
// `check` that throws, rejects, or simply never resolves can never leave the
|
|
80
|
+
// loop (and therefore the outer startCloudflared promise) hanging.
|
|
81
|
+
function probeOnce(url, probe, probeTimeoutMs) {
|
|
26
82
|
return new Promise((resolve) => {
|
|
27
83
|
let settled = false;
|
|
28
|
-
const finish = (
|
|
84
|
+
const finish = (r) => {
|
|
29
85
|
if (!settled) {
|
|
30
86
|
settled = true;
|
|
31
|
-
resolve(
|
|
87
|
+
resolve(r);
|
|
32
88
|
}
|
|
33
89
|
};
|
|
34
|
-
const timer = setTimeout(() => finish(false), probeTimeoutMs);
|
|
90
|
+
const timer = setTimeout(() => finish({ ok: false, reason: 'probe timed out' }), probeTimeoutMs);
|
|
35
91
|
Promise.resolve()
|
|
36
|
-
.then(() =>
|
|
37
|
-
.then((
|
|
92
|
+
.then(() => probe(url))
|
|
93
|
+
.then((r) => {
|
|
38
94
|
clearTimeout(timer);
|
|
39
|
-
finish(
|
|
95
|
+
finish(r);
|
|
40
96
|
})
|
|
41
|
-
.catch(() => {
|
|
97
|
+
.catch((err) => {
|
|
42
98
|
clearTimeout(timer);
|
|
43
|
-
finish(false);
|
|
99
|
+
finish({ ok: false, reason: describeProbeError(err) });
|
|
44
100
|
});
|
|
45
101
|
});
|
|
46
102
|
}
|
|
47
|
-
async function waitHealthy(url, attempts, intervalMs,
|
|
103
|
+
async function waitHealthy(url, attempts, intervalMs, probe, probeTimeoutMs) {
|
|
104
|
+
let lastReason;
|
|
48
105
|
for (let i = 0; i < attempts; i++) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
106
|
+
const r = await probeOnce(url, probe, probeTimeoutMs);
|
|
107
|
+
if (r.ok)
|
|
108
|
+
return r;
|
|
109
|
+
lastReason = r.reason;
|
|
110
|
+
await new Promise((res) => setTimeout(res, intervalMs));
|
|
52
111
|
}
|
|
53
|
-
return false;
|
|
112
|
+
return { ok: false, reason: lastReason };
|
|
54
113
|
}
|
|
55
114
|
/**
|
|
56
115
|
* `extraArgs` exists for tests: it lets a fake binary (e.g. `node fake.mjs`) be
|
|
@@ -63,8 +122,13 @@ export function startCloudflared(binPath, localPort, opts = {}) {
|
|
|
63
122
|
const timeoutMs = opts.timeoutMs ?? CLOUDFLARED_URL_TIMEOUT_MS;
|
|
64
123
|
const attempts = opts.attempts ?? CLOUDFLARED_HEALTH_ATTEMPTS;
|
|
65
124
|
const intervalMs = opts.intervalMs ?? CLOUDFLARED_HEALTH_INTERVAL_MS;
|
|
66
|
-
const check = opts.healthCheck ?? defaultHealthCheck;
|
|
67
125
|
const probeTimeoutMs = opts.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
126
|
+
// A caller-supplied boolean healthCheck carries no failure reason; the default
|
|
127
|
+
// probe does. Adapt the former into a ProbeResult either way.
|
|
128
|
+
const custom = opts.healthCheck;
|
|
129
|
+
const probe = custom
|
|
130
|
+
? async (u) => ({ ok: await custom(u) })
|
|
131
|
+
: (u) => reachabilityProbe(u, probeTimeoutMs);
|
|
68
132
|
return new Promise((resolve, reject) => {
|
|
69
133
|
const child = spawn(binPath, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
70
134
|
let settled = false;
|
|
@@ -91,13 +155,29 @@ export function startCloudflared(binPath, localPort, opts = {}) {
|
|
|
91
155
|
if (url && !settled) {
|
|
92
156
|
settled = true;
|
|
93
157
|
clearTimeout(timer);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
158
|
+
// The reachability probe runs on the *host*, but only the guest's
|
|
159
|
+
// network must reach the URL for messaging. 'off' skips it entirely;
|
|
160
|
+
// 'warn' (the product default) opens anyway and reports a warning;
|
|
161
|
+
// 'strict' fails open() if the host can't confirm reachability.
|
|
162
|
+
const mode = opts.reachability ?? 'strict';
|
|
163
|
+
if (mode === 'off') {
|
|
164
|
+
resolve({ publicUrl: url, stop });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
waitHealthy(url, attempts, intervalMs, probe, probeTimeoutMs)
|
|
168
|
+
.then((res) => {
|
|
169
|
+
if (res.ok)
|
|
97
170
|
resolve({ publicUrl: url, stop });
|
|
171
|
+
else if (mode === 'warn') {
|
|
172
|
+
resolve({
|
|
173
|
+
publicUrl: url,
|
|
174
|
+
stop,
|
|
175
|
+
reachabilityWarning: reachabilityWarningMessage(url, res.reason),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
98
178
|
else {
|
|
99
179
|
stop();
|
|
100
|
-
reject(new Error(
|
|
180
|
+
reject(new Error(unreachableMessage(url, attempts, res.reason)));
|
|
101
181
|
}
|
|
102
182
|
})
|
|
103
183
|
.catch((err) => {
|
package/dist/env.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
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;
|
|
8
|
+
export type ReachabilityMode = 'warn' | 'strict' | 'off';
|
|
9
|
+
/**
|
|
10
|
+
* How `tunnel_open` treats a host-side reachability-probe failure:
|
|
11
|
+
* warn (default) — open anyway, surface a warning; the guest is the real test
|
|
12
|
+
* strict — fail open() if the host can't reach the public URL
|
|
13
|
+
* off — skip the probe entirely
|
|
14
|
+
* Reads `TUNNEL_REACHABILITY`. Only when it is unset/blank does it fall back to
|
|
15
|
+
* the deprecated `TUNNEL_SKIP_REACHABILITY_CHECK` (== off) from 0.1.2 — an
|
|
16
|
+
* explicitly set (even mistyped) `TUNNEL_REACHABILITY` never defers to the alias.
|
|
17
|
+
* Any unrecognized value defaults to warn.
|
|
18
|
+
*/
|
|
19
|
+
export declare function reachabilityMode(): ReachabilityMode;
|
package/dist/env.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* How `tunnel_open` treats a host-side reachability-probe failure:
|
|
16
|
+
* warn (default) — open anyway, surface a warning; the guest is the real test
|
|
17
|
+
* strict — fail open() if the host can't reach the public URL
|
|
18
|
+
* off — skip the probe entirely
|
|
19
|
+
* Reads `TUNNEL_REACHABILITY`. Only when it is unset/blank does it fall back to
|
|
20
|
+
* the deprecated `TUNNEL_SKIP_REACHABILITY_CHECK` (== off) from 0.1.2 — an
|
|
21
|
+
* explicitly set (even mistyped) `TUNNEL_REACHABILITY` never defers to the alias.
|
|
22
|
+
* Any unrecognized value defaults to warn.
|
|
23
|
+
*/
|
|
24
|
+
export function reachabilityMode() {
|
|
25
|
+
const raw = (process.env.TUNNEL_REACHABILITY ?? '').trim().toLowerCase();
|
|
26
|
+
if (raw === 'warn' || raw === 'strict' || raw === 'off')
|
|
27
|
+
return raw;
|
|
28
|
+
if (raw === '' && envFlag('TUNNEL_SKIP_REACHABILITY_CHECK'))
|
|
29
|
+
return 'off';
|
|
30
|
+
return 'warn';
|
|
31
|
+
}
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
71
|
+
await server.connect(transport);
|
|
21
72
|
}
|
|
22
|
-
|
|
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();
|
package/dist/session.d.ts
CHANGED
package/dist/session.js
CHANGED
|
@@ -7,9 +7,12 @@ 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
9
|
import { DEFAULT_LISTEN_TIMEOUT_MS, DEFAULT_IDLE_TEARDOWN_MS, DEFAULT_JOIN_LINK_TTL_MS, OPEN_RETRY_ATTEMPTS, } from './config.js';
|
|
10
|
+
import { reachabilityMode } from './env.js';
|
|
10
11
|
const DEFAULT_DEPS = {
|
|
11
12
|
ensureCloudflared: realEnsure,
|
|
12
|
-
|
|
13
|
+
// Resolve the reachability mode per-call (not at module load) so TUNNEL_REACHABILITY
|
|
14
|
+
// can be set right before opening a tunnel.
|
|
15
|
+
startCloudflared: (bin, port) => realStart(bin, port, { reachability: reachabilityMode() }),
|
|
13
16
|
};
|
|
14
17
|
export class TunnelSession {
|
|
15
18
|
deps;
|
|
@@ -81,6 +84,9 @@ export class TunnelSession {
|
|
|
81
84
|
joinLink,
|
|
82
85
|
status: 'waiting_for_guest',
|
|
83
86
|
joinLinkExpiresInSec: Math.round(joinTtlMs / 1000),
|
|
87
|
+
// Present only in 'warn' mode when the host couldn't confirm reachability;
|
|
88
|
+
// the agent should relay it to the human before sharing the link.
|
|
89
|
+
...(tunnel.reachabilityWarning ? { reachabilityWarning: tunnel.reachabilityWarning } : {}),
|
|
84
90
|
};
|
|
85
91
|
}
|
|
86
92
|
async join(joinLink, guestName) {
|
|
@@ -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. 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.',
|
|
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. If the result includes a reachabilityWarning, relay it to the human: this host could not confirm it can reach the link, so the guest should verify they can open it.',
|
|
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.
|
|
3
|
+
"version": "0.1.3",
|
|
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",
|
|
@@ -29,6 +30,7 @@
|
|
|
29
30
|
"format:check": "prettier --check .",
|
|
30
31
|
"dev": "tsx src/index.ts",
|
|
31
32
|
"e2e": "tsx scripts/e2e-two-agents.ts",
|
|
33
|
+
"postinstall": "node postinstall.mjs",
|
|
32
34
|
"prepublishOnly": "npm run build"
|
|
33
35
|
},
|
|
34
36
|
"keywords": [
|
package/postinstall.mjs
ADDED
|
@@ -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
|
+
}
|