agent-dir 0.5.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { appendBoundedOutput, commandEnvironment, processTreeSpawnOptions, terminateProcessTree, } from "./commands.js";
3
- function validatePaths(paths) {
4
- if (!Array.isArray(paths) ||
5
- paths.length === 0 ||
6
- paths.some((path) => typeof path !== "string" || !path)) {
7
- throw new Error("paths must contain at least one non-empty path.");
8
- }
9
- }
10
3
  function runGit(root, args, timeoutMs = 30000) {
11
4
  return new Promise((resolve, reject) => {
12
5
  const child = spawn("git", args, {
@@ -47,63 +40,6 @@ function runGit(root, args, timeoutMs = 30000) {
47
40
  });
48
41
  });
49
42
  }
50
- export function gitStage(root, paths) {
51
- validatePaths(paths);
52
- return runGit(root, ["add", "--", ...paths]);
53
- }
54
- export function gitUnstage(root, paths) {
55
- validatePaths(paths);
56
- return runGit(root, ["restore", "--staged", "--", ...paths]);
57
- }
58
- export function gitCommit(root, message) {
59
- if (!message.trim())
60
- throw new Error("commit message must not be empty.");
61
- return runGit(root, ["commit", "-m", message]);
62
- }
63
- export function gitPush(root, remote, branch) {
64
- const args = ["push"];
65
- if (remote) {
66
- if (!/^[A-Za-z0-9._-]+$/.test(remote))
67
- throw new Error("Invalid Git remote name.");
68
- args.push(remote);
69
- if (branch) {
70
- if (!/^[A-Za-z0-9._/-]+$/.test(branch))
71
- throw new Error("Invalid Git branch name.");
72
- args.push(branch);
73
- }
74
- }
75
- else if (branch) {
76
- throw new Error("A Git remote is required when specifying a branch.");
77
- }
78
- return runGit(root, args);
79
- }
80
43
  export function gitStatus(root) {
81
44
  return runGit(root, ["status", "--short", "--branch"]);
82
45
  }
83
- export function gitDiff(root, staged = false, pathspec) {
84
- const args = ["diff", "--no-ext-diff", "--no-color"];
85
- if (staged)
86
- args.push("--cached");
87
- if (pathspec)
88
- args.push("--", pathspec);
89
- return runGit(root, args);
90
- }
91
- export function gitRestore(root, paths, staged = false) {
92
- validatePaths(paths);
93
- return runGit(root, ["restore", ...(staged ? ["--staged"] : []), "--", ...paths]);
94
- }
95
- export function gitLog(root, limit = 20, pathspec) {
96
- if (!Number.isInteger(limit) || limit < 1 || limit > 200)
97
- throw new Error("limit must be an integer between 1 and 200.");
98
- const args = [
99
- "log",
100
- "--no-color",
101
- "--date=iso-strict",
102
- "--format=%H%x09%ad%x09%an%x09%s",
103
- "-n",
104
- String(limit),
105
- ];
106
- if (pathspec)
107
- args.push("--", pathspec);
108
- return runGit(root, args);
109
- }
@@ -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,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 = [];
@@ -24,7 +24,6 @@ export interface Profile {
24
24
  npm?: NpmConfig;
25
25
  commands?: string[];
26
26
  blacklistedCommands?: string[];
27
- git?: boolean;
28
27
  }
29
28
  export interface AgentConfig {
30
29
  version: 1;
@@ -35,7 +34,6 @@ export interface CommandConfig {
35
34
  npm?: NpmConfig;
36
35
  commands?: string[];
37
36
  blacklistedCommands?: string[];
38
- git?: boolean;
39
37
  }
40
38
  export interface ServerOptions {
41
39
  root: string;
@@ -43,6 +41,7 @@ export interface ServerOptions {
43
41
  token: string;
44
42
  commandConfig?: CommandConfig;
45
43
  telemetry?: TelemetryConfig;
44
+ codeGraph?: import("./codegraph.js").CodeGraphIntegration;
46
45
  }
47
46
  export interface RequestLog {
48
47
  method: string;
@@ -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.
package/docs/MCP.md ADDED
@@ -0,0 +1,106 @@
1
+ # MCP interface and security model
2
+
3
+ Agent Dir exposes a local project through an authenticated, stateless Model Context Protocol endpoint.
4
+
5
+ ## Endpoint and authentication
6
+
7
+ - `POST /mcp` is the application MCP endpoint and requires authentication.
8
+ - `HEAD /__health` is reserved for local tunnel health checks and does not expose project contents.
9
+ - Legacy direct filesystem REST routes such as `/__tree` and direct `GET`/`PUT`/`DELETE` file paths are not supported.
10
+
11
+ Use the Bearer token in the standard header:
12
+
13
+ ```http
14
+ Authorization: Bearer <token>
15
+ ```
16
+
17
+ The server also accepts a query-string token for clients that cannot set an Authorization header. Prefer the header because query-string tokens can be exposed in URLs and logs.
18
+
19
+ ## Stateless protocol behavior
20
+
21
+ The implementation supports MCP protocol version `2026-07-28`. Requests carry protocol version and client capabilities in `params._meta`; this path does not require an initialize handshake or `Mcp-Session-Id`.
22
+
23
+ Standard Streamable HTTP requests do not need implementation-specific `MCP-Protocol-Version`, `Mcp-Method`, or `Mcp-Name` headers. If supplied, these headers are validated against the JSON-RPC request and metadata.
24
+
25
+ Supported features include:
26
+
27
+ - `server/discover`
28
+ - cursor pagination for list-style methods
29
+ - `subscriptions/listen` for tool, prompt, and resource change events
30
+ - resource subscriptions and filesystem change notifications
31
+ - cache metadata
32
+ - structured tool results with `resultType` and `outputSchema`
33
+ - the stable `io.modelcontextprotocol/skills` extension
34
+
35
+ Modern `subscriptions/listen` replaces the legacy GET/SSE notification model. List-style methods use opaque cursors when more than 50 entries are available.
36
+
37
+ ## Public tool set
38
+
39
+ The core public tool set is intentionally small:
40
+
41
+ | Tool | Purpose |
42
+ | --- | --- |
43
+ | `project_context` | Compact project overview and active execution policy |
44
+ | `find_files` | Find paths by glob |
45
+ | `search_code` | Search source-like files with bounded output |
46
+ | `find_symbol` | Locate likely symbol definitions |
47
+ | `find_references` | Locate symbol references |
48
+ | `read_range` | Read a bounded line range from a UTF-8 file |
49
+ | `apply_changes` | Create, patch, or delete files |
50
+ | `file_info` | Inspect filesystem metadata |
51
+ | `validate` | Run lightweight diagnostics and explicitly requested allowed npm scripts |
52
+ | `run_npm_batch` | Run explicitly allowlisted npm scripts sequentially |
53
+ | `run_command_batch` | Run explicitly allowlisted executables sequentially without a shell |
54
+ | `codegraph_explore` | Optional structural code intelligence when CodeGraph is available |
55
+
56
+ `codegraph_explore` is only advertised when CodeGraph is installed and the current project has a readable `.codegraph/codegraph.db` index. Other CodeGraph tools are not forwarded.
57
+
58
+ Batch command operations execute sequentially and stop at the first failed command. Tool results include structured content and a serialized text representation; tools returning structured data advertise an output schema.
59
+
60
+ ## Command execution policy
61
+
62
+ npm scripts and other executables use separate allowlists. An executable not in the configured allowlist is rejected, and configured `blacklistedCommands` prefixes override the allowlist. Commands are launched without a shell.
63
+
64
+ Git is run through `run_command_batch` when `git` is allowlisted. There are no dedicated Git MCP tools. This keeps the advertised tool set small while preserving explicit executable and argument restrictions. Avoid allowing shells or interpreters unless broader command execution is intentional.
65
+
66
+ ## CodeGraph integration
67
+
68
+ CodeGraph is optional and installed separately:
69
+
70
+ ```bash
71
+ npm install -g @colbymchenry/codegraph
72
+ codegraph init
73
+ ```
74
+
75
+ Agent Dir exposes `codegraph_explore` only when the executable is on `PATH` and the current project has a readable index. It starts CodeGraph on first use and reuses the child process for subsequent calls. The project root is fixed as the CodeGraph path; callers cannot forward arbitrary `projectPath` values. Agent Dir passes a limited environment to the child process and terminates it during shutdown.
76
+
77
+ Discovery distinguishes these states:
78
+
79
+ - `not_installed` — CodeGraph is unavailable on `PATH`.
80
+ - `not_indexed` — installed, but this project has no readable index.
81
+ - `available` — installed and indexed.
82
+ - `startup_failed` / `runtime_failed` — CodeGraph failed to start or later terminated; Agent Dir remains available.
83
+
84
+ When indexed, agents are instructed to use CodeGraph for non-trivial structural questions such as call/data flow, symbol relationships, and change impact. Exact-text searches and focused file edits use Agent Dir's native tools.
85
+
86
+ ## Agent instructions and Skills
87
+
88
+ `server/discover` returns dynamically generated instructions. The Resources primitive exposes:
89
+
90
+ ```text
91
+ agent-dir://instructions
92
+ agent-dir://capabilities
93
+ ```
94
+
95
+ The capabilities resource reports the Agent Dir version, registered tool names, execution policy, and preferred/avoid tool-selection patterns.
96
+
97
+ Agent Dir implements the stable MCP Skills extension (`io.modelcontextprotocol/skills`) and discovers project-local `SKILL.md` files under:
98
+
99
+ ```text
100
+ skills/
101
+ .agents/skills/
102
+ .claude/skills/
103
+ .github/skills/
104
+ ```
105
+
106
+ Skills are exposed through `skills/list`, `skills/get`, `resources/list`, and `resources/read`. Skill manifests are bounded to 512 resources and 16 MiB. Binary supporting files are returned as MCP blobs; `resources/directory/read` lists direct children of a skill resource directory.