agent-dir 0.4.1 → 1.0.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.
@@ -1,14 +1,13 @@
1
1
  import { spawn } from "node:child_process";
2
- function validatePaths(paths) {
3
- if (!Array.isArray(paths) ||
4
- paths.length === 0 ||
5
- paths.some((path) => typeof path !== "string" || !path)) {
6
- throw new Error("paths must contain at least one non-empty path.");
7
- }
8
- }
2
+ import { appendBoundedOutput, commandEnvironment, processTreeSpawnOptions, terminateProcessTree, } from "./commands.js";
9
3
  function runGit(root, args, timeoutMs = 30000) {
10
4
  return new Promise((resolve, reject) => {
11
- const child = spawn("git", args, { cwd: root, shell: false, env: process.env });
5
+ const child = spawn("git", args, {
6
+ cwd: root,
7
+ shell: false,
8
+ env: commandEnvironment(),
9
+ ...processTreeSpawnOptions(),
10
+ });
12
11
  let stdout = "";
13
12
  let stderr = "";
14
13
  let finished = false;
@@ -16,14 +15,14 @@ function runGit(root, args, timeoutMs = 30000) {
16
15
  if (finished)
17
16
  return;
18
17
  finished = true;
19
- child.kill("SIGTERM");
18
+ terminateProcessTree(child);
20
19
  reject(new Error(`git ${args.join(" ")} timed out after ${timeoutMs}ms.`));
21
20
  }, timeoutMs);
22
21
  child.stdout.on("data", (chunk) => {
23
- stdout += chunk.toString();
22
+ stdout = appendBoundedOutput(stdout, chunk);
24
23
  });
25
24
  child.stderr.on("data", (chunk) => {
26
- stderr += chunk.toString();
25
+ stderr = appendBoundedOutput(stderr, chunk);
27
26
  });
28
27
  child.on("error", (error) => {
29
28
  if (finished)
@@ -41,63 +40,6 @@ function runGit(root, args, timeoutMs = 30000) {
41
40
  });
42
41
  });
43
42
  }
44
- export function gitStage(root, paths) {
45
- validatePaths(paths);
46
- return runGit(root, ["add", "--", ...paths]);
47
- }
48
- export function gitUnstage(root, paths) {
49
- validatePaths(paths);
50
- return runGit(root, ["restore", "--staged", "--", ...paths]);
51
- }
52
- export function gitCommit(root, message) {
53
- if (!message.trim())
54
- throw new Error("commit message must not be empty.");
55
- return runGit(root, ["commit", "-m", message]);
56
- }
57
- export function gitPush(root, remote, branch) {
58
- const args = ["push"];
59
- if (remote) {
60
- if (!/^[A-Za-z0-9._-]+$/.test(remote))
61
- throw new Error("Invalid Git remote name.");
62
- args.push(remote);
63
- if (branch) {
64
- if (!/^[A-Za-z0-9._/-]+$/.test(branch))
65
- throw new Error("Invalid Git branch name.");
66
- args.push(branch);
67
- }
68
- }
69
- else if (branch) {
70
- throw new Error("A Git remote is required when specifying a branch.");
71
- }
72
- return runGit(root, args);
73
- }
74
43
  export function gitStatus(root) {
75
44
  return runGit(root, ["status", "--short", "--branch"]);
76
45
  }
77
- export function gitDiff(root, staged = false, pathspec) {
78
- const args = ["diff", "--no-ext-diff", "--no-color"];
79
- if (staged)
80
- args.push("--cached");
81
- if (pathspec)
82
- args.push("--", pathspec);
83
- return runGit(root, args);
84
- }
85
- export function gitRestore(root, paths, staged = false) {
86
- validatePaths(paths);
87
- return runGit(root, ["restore", ...(staged ? ["--staged"] : []), "--", ...paths]);
88
- }
89
- export function gitLog(root, limit = 20, pathspec) {
90
- if (!Number.isInteger(limit) || limit < 1 || limit > 200)
91
- throw new Error("limit must be an integer between 1 and 200.");
92
- const args = [
93
- "log",
94
- "--no-color",
95
- "--date=iso-strict",
96
- "--format=%H%x09%ad%x09%an%x09%s",
97
- "-n",
98
- String(limit),
99
- ];
100
- if (pathspec)
101
- args.push("--", pathspec);
102
- return runGit(root, args);
103
- }
@@ -15,5 +15,4 @@ export interface ProjectOverview {
15
15
  };
16
16
  }
17
17
  export declare function fileInfo(root: string, relativePath: string): Promise<Record<string, unknown>>;
18
- export declare function packageInfo(root: string): Promise<Record<string, unknown>>;
19
18
  export declare function projectOverview(root: string): Promise<ProjectOverview>;
@@ -23,28 +23,6 @@ export async function fileInfo(root, relativePath) {
23
23
  createdAt: stat.birthtime.toISOString(),
24
24
  };
25
25
  }
26
- export async function packageInfo(root) {
27
- const packagePath = path.join(await fs.realpath(root), "package.json");
28
- try {
29
- const packageJson = JSON.parse(await fs.readFile(packagePath, "utf8"));
30
- return {
31
- path: "package.json",
32
- name: packageJson.name ?? null,
33
- version: packageJson.version ?? null,
34
- description: packageJson.description ?? null,
35
- packageManager: packageJson.packageManager ?? null,
36
- engines: packageJson.engines ?? null,
37
- scripts: packageJson.scripts ?? {},
38
- dependencies: packageJson.dependencies ?? {},
39
- devDependencies: packageJson.devDependencies ?? {},
40
- };
41
- }
42
- catch (error) {
43
- if (error.code === "ENOENT")
44
- return { path: "package.json", exists: false };
45
- throw new Error("package.json exists but is not valid JSON.");
46
- }
47
- }
48
26
  export async function projectOverview(root) {
49
27
  const entries = await listFiles(root);
50
28
  const files = entries.filter((entry) => entry.type === "file");
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { promises as fs } from "node:fs";
3
3
  import path from "node:path";
4
+ import { appendBoundedOutput, commandEnvironment, processTreeSpawnOptions, terminateProcessTree, } from "./commands.js";
4
5
  export function runNpm(root, script, timeoutMs = 120000, allowedScripts = [], signal) {
5
6
  if (!/^[A-Za-z0-9:_-]+$/.test(script))
6
7
  throw new Error("Invalid npm script name.");
@@ -21,7 +22,8 @@ export function runNpm(root, script, timeoutMs = 120000, allowedScripts = [], si
21
22
  const child = spawn(executable, ["run", script], {
22
23
  cwd: root,
23
24
  shell: false,
24
- env: process.env,
25
+ env: commandEnvironment(),
26
+ ...processTreeSpawnOptions(),
25
27
  });
26
28
  let stdout = "";
27
29
  let stderr = "";
@@ -32,14 +34,14 @@ export function runNpm(root, script, timeoutMs = 120000, allowedScripts = [], si
32
34
  return;
33
35
  finished = true;
34
36
  clearTimeout(timer);
35
- child.kill("SIGTERM");
37
+ terminateProcessTree(child);
36
38
  reject(new Error(`npm run ${script} was aborted.`));
37
39
  };
38
40
  timer = setTimeout(() => {
39
41
  if (finished)
40
42
  return;
41
43
  finished = true;
42
- child.kill("SIGTERM");
44
+ terminateProcessTree(child);
43
45
  signal?.removeEventListener("abort", abort);
44
46
  reject(new Error(`npm run ${script} timed out after ${timeoutMs}ms.`));
45
47
  }, timeoutMs);
@@ -49,10 +51,10 @@ export function runNpm(root, script, timeoutMs = 120000, allowedScripts = [], si
49
51
  }
50
52
  signal?.addEventListener("abort", abort, { once: true });
51
53
  child.stdout.on("data", (chunk) => {
52
- stdout += chunk.toString();
54
+ stdout = appendBoundedOutput(stdout, chunk);
53
55
  });
54
56
  child.stderr.on("data", (chunk) => {
55
- stderr += chunk.toString();
57
+ stderr = appendBoundedOutput(stderr, chunk);
56
58
  });
57
59
  child.on("error", (error) => {
58
60
  if (!finished) {
@@ -1,3 +1,4 @@
1
+ export declare function invalidateSkillDiscoveryCache(root: string): void;
1
2
  export interface SkillResource {
2
3
  uri: string;
3
4
  digest: string;
@@ -5,9 +5,15 @@ import { listFiles } from "./files.js";
5
5
  const SKILL_ROOTS = ["skills", ".agents/skills", ".claude/skills", ".github/skills"];
6
6
  const MAX_RESOURCES = 512;
7
7
  const MAX_TOTAL_BYTES = 16 * 1024 * 1024;
8
+ const MAX_RESOURCE_RESPONSE_BYTES = 32_000;
8
9
  const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
9
10
  const MAX_SKILL_NAME_LENGTH = 64;
10
11
  const MAX_DESCRIPTION_LENGTH = 1024;
12
+ const DISCOVERY_CACHE_TTL_MS = 2_000;
13
+ const discoveryCache = new Map();
14
+ export function invalidateSkillDiscoveryCache(root) {
15
+ discoveryCache.delete(path.resolve(root));
16
+ }
11
17
  export async function listSkills(root) {
12
18
  return (await discoverSkills(root)).map((skill) => skill.entry);
13
19
  }
@@ -22,8 +28,51 @@ export async function readSkillResource(root, uri) {
22
28
  const filePath = skill.resources.get(uri);
23
29
  if (!filePath)
24
30
  continue;
31
+ const stats = await fs.stat(filePath);
32
+ if (stats.size > MAX_RESOURCE_RESPONSE_BYTES) {
33
+ const handle = await fs.open(filePath, "r");
34
+ let previewBytes;
35
+ try {
36
+ previewBytes = Buffer.alloc(MAX_RESOURCE_RESPONSE_BYTES);
37
+ const { bytesRead } = await handle.read(previewBytes, 0, previewBytes.length, 0);
38
+ previewBytes = previewBytes.subarray(0, bytesRead);
39
+ }
40
+ finally {
41
+ await handle.close();
42
+ }
43
+ if (previewBytes.includes(0)) {
44
+ return {
45
+ uri,
46
+ mimeType: "text/plain",
47
+ text: `Binary resource omitted: ${stats.size} bytes exceeds the ${MAX_RESOURCE_RESPONSE_BYTES}-byte response limit.`,
48
+ };
49
+ }
50
+ const marker = "\n…[resource truncated at 32000 bytes]";
51
+ let preview = previewBytes
52
+ .subarray(0, MAX_RESOURCE_RESPONSE_BYTES - Buffer.byteLength(marker, "utf8"))
53
+ .toString("utf8");
54
+ while (preview.endsWith("\uFFFD"))
55
+ preview = preview.slice(0, -1);
56
+ return { uri, mimeType: mimeTypeFor(filePath), text: `${preview}${marker}` };
57
+ }
25
58
  const bytes = await fs.readFile(filePath);
26
59
  const text = bytes.toString("utf8");
60
+ if (bytes.length > MAX_RESOURCE_RESPONSE_BYTES) {
61
+ if (Buffer.from(text, "utf8").equals(bytes)) {
62
+ const marker = "\n…[resource truncated at 32000 bytes]";
63
+ let preview = bytes
64
+ .subarray(0, MAX_RESOURCE_RESPONSE_BYTES - Buffer.byteLength(marker, "utf8"))
65
+ .toString("utf8");
66
+ while (preview.endsWith("\uFFFD"))
67
+ preview = preview.slice(0, -1);
68
+ return { uri, mimeType: mimeTypeFor(filePath), text: `${preview}${marker}` };
69
+ }
70
+ return {
71
+ uri,
72
+ mimeType: "text/plain",
73
+ text: `Binary resource omitted: ${bytes.length} bytes exceeds the ${MAX_RESOURCE_RESPONSE_BYTES}-byte response limit.`,
74
+ };
75
+ }
27
76
  if (Buffer.from(text, "utf8").equals(bytes)) {
28
77
  return { uri, mimeType: mimeTypeFor(filePath), text };
29
78
  }
@@ -89,6 +138,22 @@ export async function skillResourceUrisForPath(root, relativePath) {
89
138
  return [...matches].sort();
90
139
  }
91
140
  async function discoverSkills(root) {
141
+ const resolvedRoot = await fs.realpath(root);
142
+ const cached = discoveryCache.get(resolvedRoot);
143
+ if (cached && cached.expiresAt > Date.now())
144
+ return cached.promise;
145
+ const promise = discoverSkillsUncached(resolvedRoot);
146
+ discoveryCache.set(resolvedRoot, { expiresAt: Date.now() + DISCOVERY_CACHE_TTL_MS, promise });
147
+ try {
148
+ return await promise;
149
+ }
150
+ catch (error) {
151
+ if (discoveryCache.get(resolvedRoot)?.promise === promise)
152
+ discoveryCache.delete(resolvedRoot);
153
+ throw error;
154
+ }
155
+ }
156
+ async function discoverSkillsUncached(root) {
92
157
  const resolvedRoot = await fs.realpath(root);
93
158
  const entries = await listFiles(root);
94
159
  const skills = [];
@@ -14,8 +14,12 @@ function cleanOutput(value) {
14
14
  function writeLine(line = "") {
15
15
  process.stdout.write(`${line}\r\n`);
16
16
  }
17
- function publicUrl(subdomain) {
18
- return `https://${subdomain}.wormhole.bar`;
17
+ function publicUrl(options) {
18
+ if (options.provider === "cloudflared" && options.hostname)
19
+ return `https://${options.hostname}`;
20
+ if (options.subdomain)
21
+ return `https://${options.subdomain}.wormhole.bar`;
22
+ throw new Error("Tunnel URL cannot be determined from the configured options.");
19
23
  }
20
24
  function reasonCategory(reason) {
21
25
  if (/timeout/i.test(reason))
@@ -35,8 +39,8 @@ function failureFromLine(line) {
35
39
  return registration?.[1] ? `Wormhole registration failed: ${registration[1].trim()}` : undefined;
36
40
  }
37
41
  export function startTunnel(options) {
38
- if (options.provider !== "wormhole")
39
- return Promise.reject(new Error(`Unsupported tunnel provider: ${options.provider}`));
42
+ if (options.provider === "cloudflared" && !options.hostname)
43
+ return Promise.reject(new Error("Cloudflared requires a hostname routed to a named Cloudflare Tunnel."));
40
44
  return new Promise((resolve, reject) => {
41
45
  const telemetry = options.telemetry && options.telemetry.level !== "none"
42
46
  ? new TelemetryRecorder({
@@ -146,12 +150,13 @@ export function startTunnel(options) {
146
150
  if (!url || stopped || reconnecting)
147
151
  return;
148
152
  try {
153
+ // A 401 is expected: it proves the public tunnel reaches the authenticated server.
154
+ // Never send the Agent Dir bearer token to the public hostname.
149
155
  const response = await fetch(new URL(HEALTH_PATH, url), {
150
156
  method: "HEAD",
151
- ...(options.token ? { headers: { authorization: `Bearer ${options.token}` } } : {}),
152
157
  signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
153
158
  });
154
- if (!response.ok)
159
+ if (!response.ok && response.status !== 401)
155
160
  throw new Error(`Health check returned HTTP ${response.status}.`);
156
161
  healthFailures = 0;
157
162
  if (healthState === "degraded") {
@@ -167,7 +172,7 @@ export function startTunnel(options) {
167
172
  }
168
173
  if (healthFailures >= HEALTH_FAILURE_THRESHOLD) {
169
174
  healthFailures = 0;
170
- handleDisconnect("Public tunnel health check failed.");
175
+ handleDisconnect("Tunnel health check failed.");
171
176
  }
172
177
  }
173
178
  }, HEALTH_INTERVAL_MS);
@@ -177,10 +182,16 @@ export function startTunnel(options) {
177
182
  if (stopped)
178
183
  return;
179
184
  reconnecting = false;
180
- const args = ["http", String(options.port)];
181
- if (!options.random && options.subdomain)
185
+ const args = options.provider === "wormhole" ? ["http", String(options.port)] : ["tunnel", "run"];
186
+ if (options.provider === "wormhole" && !options.random && options.subdomain)
182
187
  args.push("--subdomain", options.subdomain);
183
- child = spawn("wormhole", args, { stdio: ["ignore", "pipe", "pipe"] });
188
+ const executable = options.provider === "wormhole" ? "wormhole" : "cloudflared";
189
+ child = spawn(executable, args, {
190
+ stdio: ["ignore", "pipe", "pipe"],
191
+ env: options.provider === "cloudflared" && options.cloudflaredToken
192
+ ? { ...process.env, TUNNEL_TOKEN: options.cloudflaredToken }
193
+ : process.env,
194
+ });
184
195
  let buffer = "";
185
196
  let connected = false;
186
197
  const cleanupAttempt = () => {
@@ -220,7 +231,7 @@ export function startTunnel(options) {
220
231
  if (!resolved) {
221
232
  if (!child) {
222
233
  stop();
223
- reject(new Error("Wormhole connected without a child process."));
234
+ reject(new Error(`${options.provider} connected without a child process.`));
224
235
  return;
225
236
  }
226
237
  resolved = true;
@@ -245,10 +256,16 @@ export function startTunnel(options) {
245
256
  return;
246
257
  }
247
258
  const explicit = line.match(/https:\/\/[^\s"'<>]+/)?.[0]?.replace(/[),.;]+$/, "");
248
- if (explicit)
259
+ if (options.provider === "wormhole" && explicit)
249
260
  connectedAt(explicit);
250
- else if (/Status\s+.*connected/i.test(line) && options.subdomain)
251
- connectedAt(publicUrl(options.subdomain));
261
+ else if (options.provider === "wormhole" &&
262
+ /Status\s+.*connected/i.test(line) &&
263
+ options.subdomain)
264
+ connectedAt(publicUrl(options));
265
+ else if (options.provider === "cloudflared" &&
266
+ /registered tunnel connection|connection .* registered/i.test(line) &&
267
+ options.hostname)
268
+ connectedAt(publicUrl(options));
252
269
  };
253
270
  const onData = (chunk) => {
254
271
  buffer += cleanOutput(chunk);
@@ -257,12 +274,12 @@ export function startTunnel(options) {
257
274
  for (const line of lines)
258
275
  processLine(line);
259
276
  };
260
- const onError = (error) => disconnect(`Wormhole error: ${error.message}`);
277
+ const onError = (error) => disconnect(`${executable} error: ${error.message}`);
261
278
  const onExit = (code, signal) => {
262
279
  const detail = signal ? `signal ${signal}` : `code ${code}`;
263
280
  disconnect(startup
264
- ? `Wormhole exited before the tunnel was established (${detail}).`
265
- : `Wormhole process exited (${detail}).`);
281
+ ? `${executable} exited before the tunnel was established (${detail}).`
282
+ : `${executable} process exited (${detail}).`);
266
283
  };
267
284
  child.stdout?.on("data", onData);
268
285
  child.stderr?.on("data", onData);
@@ -270,7 +287,7 @@ export function startTunnel(options) {
270
287
  child.once("exit", onExit);
271
288
  timer = setTimeout(() => {
272
289
  if (!connected)
273
- disconnect(`Timed out waiting for Wormhole to establish the tunnel after ${STARTUP_TIMEOUT_MS / 1000}s.`);
290
+ disconnect(`Timed out waiting for ${executable} to establish the tunnel after ${STARTUP_TIMEOUT_MS / 1000}s.`);
274
291
  }, STARTUP_TIMEOUT_MS);
275
292
  };
276
293
  const handleDisconnect = (reason) => {
@@ -6,13 +6,24 @@ export interface NpmConfig {
6
6
  export interface Profile {
7
7
  directory: string;
8
8
  port: number;
9
- tunnel: "none" | "wormhole";
9
+ tunnel: "none" | "wormhole" | "cloudflared";
10
+ defaultTunnel?: "none" | "wormhole" | "cloudflared";
11
+ tunnels?: {
12
+ wormhole?: {
13
+ subdomain?: string;
14
+ random?: boolean;
15
+ };
16
+ cloudflared?: {
17
+ hostname: string;
18
+ token?: string;
19
+ };
20
+ };
10
21
  subdomain?: string;
22
+ hostname?: string;
11
23
  token?: string;
12
24
  npm?: NpmConfig;
13
25
  commands?: string[];
14
26
  blacklistedCommands?: string[];
15
- git?: boolean;
16
27
  }
17
28
  export interface AgentConfig {
18
29
  version: 1;
@@ -23,7 +34,6 @@ export interface CommandConfig {
23
34
  npm?: NpmConfig;
24
35
  commands?: string[];
25
36
  blacklistedCommands?: string[];
26
- git?: boolean;
27
37
  }
28
38
  export interface ServerOptions {
29
39
  root: string;
@@ -40,9 +50,11 @@ export interface RequestLog {
40
50
  tool?: string;
41
51
  }
42
52
  export interface TunnelOptions {
43
- provider: "wormhole";
53
+ provider: "wormhole" | "cloudflared";
44
54
  port: number;
55
+ cloudflaredToken?: string;
45
56
  subdomain?: string;
57
+ hostname?: string;
46
58
  random?: boolean;
47
59
  token?: string;
48
60
  telemetry?: TelemetryConfig;
@@ -0,0 +1,154 @@
1
+ # Configuration and tunnel setup
2
+
3
+ This guide covers first-run setup, saved profiles, authentication tokens, tunnels, and command permissions.
4
+
5
+ ## Requirements
6
+
7
+ - Node.js 24 or newer
8
+ - npm
9
+ - Wormhole CLI or `cloudflared` CLI for remote access
10
+
11
+ ## First-run setup
12
+
13
+ When run interactively for the first time without an existing config, Agent Dir offers a setup wizard. It collects a profile name, project directory, and port, then optionally configures Cloudflare Tunnel and Wormhole. Cloudflare connector tokens are entered without being echoed. You can enable either provider, both, or neither; Cloudflare is the default when both are configured.
14
+
15
+ Run the wizard directly at any time:
16
+
17
+ ```bash
18
+ agent-dir setup
19
+ ```
20
+
21
+ The wizard saves the profile and exits without starting a tunnel. Non-interactive invocations and invocations with explicit launch options do not start the wizard.
22
+
23
+ To skip profile setup and start a temporary random Wormhole tunnel:
24
+
25
+ ```bash
26
+ agent-dir . --random --tunnel wormhole
27
+ ```
28
+
29
+ ## Profiles
30
+
31
+ Profiles store the project directory, port, tunnel settings, allowed npm scripts, allowed non-npm commands, and blocked command prefixes.
32
+
33
+ ```bash
34
+ agent-dir config add my-project \
35
+ --directory ~/src/my-project \
36
+ --tunnel wormhole \
37
+ --subdomain my-project-x7k4m2 \
38
+ --npm typecheck,lint,format,test,build \
39
+ --command grep,find,rg,git \
40
+ --blacklist "git commit,git push --force"
41
+ ```
42
+
43
+ Start a saved profile by name:
44
+
45
+ ```bash
46
+ agent-dir my-project
47
+ ```
48
+
49
+ Manage profiles:
50
+
51
+ ```bash
52
+ agent-dir config list
53
+ agent-dir config show my-project
54
+ agent-dir config delete my-project
55
+ agent-dir config delete --all
56
+ ```
57
+
58
+ Deletion asks for confirmation by default. Use `--yes` for automation. The older `config remove <name>` command is an alias for `config delete <name>`.
59
+
60
+ Profile configuration is stored at `~/.config/agent-dir/config.json`, outside the project repository, with user-only permissions. CLI options can override saved values for a single run. `config show` redacts configured tunnel tokens.
61
+
62
+ ### Example profile
63
+
64
+ ```json
65
+ {
66
+ "directory": "~/src/my-project",
67
+ "port": 3002,
68
+ "tunnel": "cloudflared",
69
+ "defaultTunnel": "cloudflared",
70
+ "tunnels": {
71
+ "wormhole": { "subdomain": "my-project-x7k4m2" },
72
+ "cloudflared": {
73
+ "hostname": "mcp.example.com",
74
+ "token": "CLOUDFLARE_TUNNEL_TOKEN"
75
+ }
76
+ }
77
+ }
78
+ ```
79
+
80
+ The Cloudflare connector token is separate from the Agent Dir MCP Bearer token. Avoid passing secrets as command-line arguments because they may appear in shell history or process listings; prefer the interactive prompt when possible.
81
+
82
+ ## Tunnel setup
83
+
84
+ ### Wormhole
85
+
86
+ Temporary random URL:
87
+
88
+ ```bash
89
+ agent-dir . --random --tunnel wormhole
90
+ ```
91
+
92
+ Custom subdomain:
93
+
94
+ ```bash
95
+ agent-dir . --tunnel wormhole --subdomain my-project-x7k4m2
96
+ ```
97
+
98
+ Choose a distinctive subdomain to reduce collisions. Wormhole limits how many subdomains a user can have registered or active. If registration fails with a subdomain limit error, stop unused Wormhole processes, release an existing subdomain, or use `--random`.
99
+
100
+ The tunnel is only transport; Agent Dir's local server still enforces its own authentication.
101
+
102
+ ### Cloudflare Tunnel
103
+
104
+ Configure a Cloudflare Tunnel and DNS/ingress route for the hostname before launching Agent Dir:
105
+
106
+ ```bash
107
+ agent-dir . --tunnel cloudflared --hostname mcp.example.com
108
+ ```
109
+
110
+ For saved profiles, Cloudflare is selected by default when a `tunnels.cloudflared` entry exists. Use `-c` to explicitly select Cloudflare or `-w` to select Wormhole:
111
+
112
+ ```bash
113
+ agent-dir my-project -c
114
+ agent-dir my-project -w
115
+ ```
116
+
117
+ The `--hostname` value is the public URL Agent Dir displays; the named tunnel's DNS and ingress configuration must already exist. Agent Dir monitors `cloudflared tunnel run` and restarts the process if it disconnects. See the [Cloudflare Tunnel setup guide](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/).
118
+
119
+ ## Authentication tokens
120
+
121
+ Agent Dir generates a random Bearer token when the server starts unless a profile token or `--token` is configured. Authentication cannot be disabled.
122
+
123
+ Retrieve or rotate a profile token:
124
+
125
+ ```bash
126
+ agent-dir config token my-project
127
+ agent-dir config token my-project --rotate
128
+ ```
129
+
130
+ The token command intentionally prints the secret. Do not share or commit its output.
131
+
132
+ Clients should send `Authorization: Bearer <token>`. Query-string tokens are supported for clients that cannot set headers, but are less secure because URLs may be recorded in logs or copied.
133
+
134
+ ## Command permissions
135
+
136
+ npm scripts and general executables have separate allowlists.
137
+
138
+ Allow npm scripts:
139
+
140
+ ```text
141
+ --npm typecheck,lint,format,test,build
142
+ ```
143
+
144
+ Allow executables:
145
+
146
+ ```text
147
+ --command grep,find,rg,git
148
+ ```
149
+
150
+ Commands are launched with `shell: false`; executable and arguments are passed separately. Git uses the same allowlisted command interface as other executables; there are no dedicated Git MCP tools.
151
+
152
+ A configured `blacklistedCommands` entry overrides the allowlist and blocks matching command/argument prefixes. For example, allowing `git` while blacklisting `git commit` and `git push --force` still permits `git status`. Prefix entries are separated by commas.
153
+
154
+ Avoid allowing `sh`, `bash`, `zsh`, `cmd`, `node`, or `python` unless you intentionally want to grant much broader execution capability.