opencode-add-dir 1.2.1 → 1.3.1

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/README.md CHANGED
@@ -31,7 +31,6 @@ Automatically adds the plugin to your global `opencode.json`.
31
31
  <summary>Alternative: local file</summary>
32
32
 
33
33
  ```bash
34
- # Clone and build
35
34
  git clone https://github.com/kuzeofficial/add-dir-opencode.git
36
35
  cd add-dir-opencode
37
36
  bun install && bun run deploy
@@ -41,27 +40,15 @@ Bundles to `~/.config/opencode/plugins/add-dir.js`.
41
40
 
42
41
  </details>
43
42
 
44
- ## Usage
45
-
46
- ### Slash Command
43
+ ## Commands
47
44
 
48
45
  ```
49
- /add-dir /path/to/directory # Session only
46
+ /add-dir /path/to/directory # Add for this session
50
47
  /add-dir /path/to/directory --remember # Persist across sessions
51
- /add-dir list # Show added directories
52
- /add-dir remove /path/to/directory # Remove a directory
48
+ /list-dir # Show added directories
49
+ /remove-dir /path/to/directory # Remove a directory
53
50
  ```
54
51
 
55
- ### LLM Tools
56
-
57
- The agent can also call these tools directly:
58
-
59
- | Tool | Description |
60
- |------|-------------|
61
- | `add_dir` | Add a directory (with optional `remember` flag) |
62
- | `list_dirs` | List all added directories |
63
- | `remove_dir` | Remove a directory |
64
-
65
52
  ## How It Works
66
53
 
67
54
  The plugin uses a layered approach to handle permissions across all sessions, including subagents:
@@ -69,13 +56,13 @@ The plugin uses a layered approach to handle permissions across all sessions, in
69
56
  | Layer | When | Scope |
70
57
  |-------|------|-------|
71
58
  | **Config hook** | Startup | Injects `external_directory: "allow"` rules for persisted dirs into all agents |
72
- | **Session permission** | `/add-dir` | Sets `external_directory: true` on the current session via `tools` field |
59
+ | **Session permission** | `/add-dir` | Sets `external_directory: true` on the current session |
73
60
  | **tool.execute.before** | Every file tool | Detects subagent sessions accessing added dirs, grants permission before execution |
74
61
  | **Event auto-approve** | Permission popup | Catches any remaining `external_directory` requests and auto-approves via SDK |
75
62
 
76
- ### AGENTS.md Injection
63
+ ### Context Injection
77
64
 
78
- If an added directory contains `AGENTS.md`, `CLAUDE.md`, or `.agents/AGENTS.md`, the content is automatically injected into the system prompt via `experimental.chat.system.transform`.
65
+ If an added directory contains `AGENTS.md`, `CLAUDE.md`, or `.agents/AGENTS.md`, the content is automatically injected into the system prompt.
79
66
 
80
67
  ## Persistence
81
68
 
@@ -91,7 +78,7 @@ These are loaded at startup and injected into agent permission rules via the con
91
78
 
92
79
  ```bash
93
80
  bun install
94
- bun test # 17 tests
81
+ bun test # 33 tests
95
82
  bun run typecheck # Type check
96
83
  bun run build # Build npm package
97
84
  bun run deploy # Bundle to ~/.config/opencode/plugins/
@@ -102,24 +89,18 @@ bun run deploy # Bundle to ~/.config/opencode/plugins/
102
89
  ```
103
90
  src/
104
91
  ├── index.ts # Entry point (default export)
105
- ├── plugin.ts # Hooks + tools
106
- ├── state.ts # Persistence
92
+ ├── plugin.ts # Hooks + commands
93
+ ├── state.ts # Persistence + path utils
107
94
  ├── validate.ts # Directory validation
108
95
  ├── permissions.ts # Session grants + auto-approve
109
- └── context.ts # AGENTS.md injection
96
+ ├── context.ts # AGENTS.md injection
97
+ └── types.ts # Shared type definitions
110
98
  ```
111
99
 
112
100
  ## Debugging
113
101
 
114
- Run OpenCode with logs:
115
-
116
102
  ```bash
117
103
  opencode --print-logs 2>debug.log
118
- ```
119
-
120
- Filter plugin logs:
121
-
122
- ```bash
123
104
  grep "\[add-dir\]" debug.log
124
105
  ```
125
106
 
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
- // src/plugin.ts
2
- import { tool } from "@opencode-ai/plugin";
3
-
4
1
  // src/state.ts
5
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
6
3
  import { join } from "path";
7
4
  function stateDir() {
8
5
  return join(process.env["XDG_DATA_HOME"] || join(process.env["HOME"] || "~", ".local", "share"), "opencode", "add-dir");
9
6
  }
7
+ function expandHome(p) {
8
+ return p.startsWith("~/") ? (process.env["HOME"] || "~") + p.slice(1) : p;
9
+ }
10
10
  function loadDirs() {
11
11
  const dirs = new Map;
12
12
  const file = join(stateDir(), "directories.json");
@@ -39,9 +39,6 @@ function matchesDirs(dirs, filepath) {
39
39
  // src/validate.ts
40
40
  import { statSync } from "fs";
41
41
  import { resolve } from "path";
42
- function expandHome(p) {
43
- return p.startsWith("~/") ? (process.env["HOME"] || "~") + p.slice(1) : p;
44
- }
45
42
  function validateDir(input, worktree, existing) {
46
43
  const trimmed = input.trim();
47
44
  if (!trimmed)
@@ -51,7 +48,8 @@ function validateDir(input, worktree, existing) {
51
48
  if (!statSync(abs).isDirectory())
52
49
  return { ok: false, reason: `${abs} is not a directory.` };
53
50
  } catch (e) {
54
- if ("ENOENT ENOTDIR EACCES EPERM".includes(e.code))
51
+ const code = e.code;
52
+ if (code && ["ENOENT", "ENOTDIR", "EACCES", "EPERM"].includes(code))
55
53
  return { ok: false, reason: `Path ${abs} was not found.` };
56
54
  throw e;
57
55
  }
@@ -67,46 +65,37 @@ function validateDir(input, worktree, existing) {
67
65
  import { join as join2, resolve as resolve2 } from "path";
68
66
  var FILE_TOOLS = new Set(["read", "write", "edit", "apply_patch", "multiedit", "glob", "grep", "list", "bash"]);
69
67
  var grantedSessions = new Set;
70
- function expandHome2(p) {
71
- return p.startsWith("~/") ? (process.env["HOME"] || "~") + p.slice(1) : p;
72
- }
73
- function extractPath(tool, args) {
74
- if (!args)
75
- return "";
76
- if (tool === "bash")
77
- return args.workdir || args.command || "";
78
- return args.filePath || args.path || args.pattern || "";
79
- }
80
68
  function permissionGlob(dirPath) {
81
69
  return join2(dirPath, "*");
82
70
  }
71
+ function sendPrompt(sdk, sessionID, text, tools) {
72
+ const body = { noReply: true, ...tools && { tools }, parts: [{ type: "text", text }] };
73
+ return sdk.session.promptAsync({ path: { id: sessionID }, body })?.then?.(() => {})?.catch?.(() => {}) ?? Promise.resolve();
74
+ }
75
+ function notify(sdk, sessionID, text) {
76
+ sendPrompt(sdk, sessionID, text);
77
+ }
78
+ function grantSessionAsync(sdk, sessionID, text) {
79
+ grantedSessions.add(sessionID);
80
+ sendPrompt(sdk, sessionID, text, { external_directory: true });
81
+ }
83
82
  async function grantSession(sdk, sessionID, text) {
84
83
  if (grantedSessions.has(sessionID))
85
84
  return;
86
85
  grantedSessions.add(sessionID);
87
- await sdk.session.prompt({
88
- path: { id: sessionID },
89
- body: { noReply: true, tools: { external_directory: true }, parts: [{ type: "text", text }] }
90
- }).catch(() => {});
91
- }
92
- function grantSessionAsync(sdk, sessionID, text) {
93
- setTimeout(() => {
94
- sdk.session.promptAsync({
95
- path: { id: sessionID },
96
- body: { noReply: true, tools: { external_directory: true }, parts: [{ type: "text", text }] }
97
- })?.then?.(() => grantedSessions.add(sessionID))?.catch?.(() => {});
98
- }, 150);
86
+ const body = { noReply: true, tools: { external_directory: true }, parts: [{ type: "text", text }] };
87
+ await sdk.session.prompt({ path: { id: sessionID }, body }).catch(() => {});
99
88
  }
100
89
  function shouldGrantBeforeTool(dirs, tool, args) {
101
90
  if (!dirs.size || !FILE_TOOLS.has(tool))
102
91
  return false;
103
92
  const p = extractPath(tool, args);
104
- return !!p && matchesDirs(dirs, resolve2(expandHome2(p)));
93
+ return !!p && matchesDirs(dirs, resolve2(expandHome(p)));
105
94
  }
106
95
  async function autoApprovePermission(sdk, props, dirs) {
107
96
  if (props.permission !== "external_directory")
108
97
  return;
109
- const meta = props.metadata ?? {};
98
+ const meta = props.metadata;
110
99
  const filepath = meta.filepath ?? "";
111
100
  const parentDir = meta.parentDir ?? "";
112
101
  const patterns = props.patterns ?? [];
@@ -118,9 +107,16 @@ async function autoApprovePermission(sdk, props, dirs) {
118
107
  body: { response: "always" }
119
108
  }).catch(() => {});
120
109
  }
110
+ function extractPath(tool, args) {
111
+ if (!args)
112
+ return "";
113
+ if (tool === "bash")
114
+ return args.workdir || args.command || "";
115
+ return args.filePath || args.path || args.pattern || "";
116
+ }
121
117
 
122
118
  // src/context.ts
123
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
119
+ import { readFileSync as readFileSync2 } from "fs";
124
120
  import { join as join3 } from "path";
125
121
  var CONTEXT_FILES = ["AGENTS.md", "CLAUDE.md", ".agents/AGENTS.md"];
126
122
  function collectAgentContext(dirs) {
@@ -128,8 +124,6 @@ function collectAgentContext(dirs) {
128
124
  for (const entry of dirs.values()) {
129
125
  for (const name of CONTEXT_FILES) {
130
126
  const fp = join3(entry.path, name);
131
- if (!existsSync2(fp))
132
- continue;
133
127
  try {
134
128
  const content = readFileSync2(fp, "utf-8").trim();
135
129
  if (content)
@@ -143,24 +137,28 @@ ${content}`);
143
137
  }
144
138
 
145
139
  // src/plugin.ts
146
- var SENTINEL = "__ADD_DIR_HANDLED__";
140
+ var SENTINEL = Object.assign(new Error("__ADD_DIR_HANDLED__"), { stack: "" });
141
+ function log(msg, data) {
142
+ console.error(`[add-dir] ${msg}`, data !== undefined ? JSON.stringify(data) : "");
143
+ }
147
144
  var AddDirPlugin = async ({ client, worktree, directory }) => {
148
145
  const root = worktree || directory;
149
146
  const dirs = loadDirs();
150
147
  const sdk = client;
151
- function add(dirPath, persist, sessionID) {
148
+ log("init", { root, persistedDirs: [...dirs.keys()] });
149
+ function add(dirPath, persist) {
152
150
  const result = validateDir(dirPath, root, [...dirs.values()].map((d) => d.path));
153
151
  if (!result.ok)
154
- return result.reason;
152
+ return { ok: false, message: result.reason };
155
153
  dirs.set(result.absolutePath, { path: result.absolutePath, persist });
156
154
  if (persist)
157
155
  saveDirs(dirs);
158
156
  const label = persist ? "persistent" : "session";
159
- const msg = `Added ${result.absolutePath} as a working directory (${label}).`;
160
- grantSessionAsync(sdk, sessionID, msg);
161
- return msg;
157
+ return { ok: true, message: `Added ${result.absolutePath} as a working directory (${label}).` };
162
158
  }
163
159
  function remove(path) {
160
+ if (!path?.trim())
161
+ return "Usage: /remove-dir <path>";
164
162
  if (!dirs.has(path))
165
163
  return `${path} is not in the directory list.`;
166
164
  dirs.delete(path);
@@ -173,73 +171,64 @@ var AddDirPlugin = async ({ client, worktree, directory }) => {
173
171
  return [...dirs.values()].map((d) => `${d.path} (${d.persist ? "persistent" : "session"})`).join(`
174
172
  `);
175
173
  }
176
- function handleCommand(args, sessionID) {
174
+ function handleAdd(args, sessionID) {
177
175
  const tokens = args.trim().split(/\s+/);
178
176
  const flags = new Set(tokens.filter((t) => t.startsWith("--")));
179
177
  const pos = tokens.filter((t) => !t.startsWith("--"));
180
- if (pos[0] === "list")
181
- return list();
182
- if (pos[0] === "remove" && pos[1])
183
- return remove(pos[1]);
184
178
  if (!pos[0])
185
- return `Usage: /add-dir <path> [--remember]
186
- /add-dir list
187
- /add-dir remove <path>`;
188
- return add(pos[0], flags.has("--remember"), sessionID);
179
+ return notify(sdk, sessionID, "Usage: /add-dir <path> [--remember]");
180
+ const result = add(pos[0], flags.has("--remember"));
181
+ if (result.ok)
182
+ grantSessionAsync(sdk, sessionID, result.message);
183
+ else
184
+ notify(sdk, sessionID, result.message);
189
185
  }
186
+ const commands = {
187
+ "add-dir": (args, sid) => {
188
+ log("add-dir", { args, sid });
189
+ handleAdd(args, sid);
190
+ },
191
+ "list-dir": (_, sid) => {
192
+ log("list-dir", { sid });
193
+ notify(sdk, sid, list());
194
+ },
195
+ "remove-dir": (args, sid) => {
196
+ log("remove-dir", { args, sid });
197
+ notify(sdk, sid, remove(args));
198
+ }
199
+ };
190
200
  return {
191
201
  config: async (cfg) => {
192
202
  cfg.command ??= {};
193
- cfg.command["add-dir"] = { template: "/add-dir", description: "Add a working directory for this session" };
203
+ const cmd = cfg.command;
204
+ cmd["add-dir"] = { template: "/add-dir", description: "Add a working directory" };
205
+ cmd["list-dir"] = { template: "/list-dir", description: "List added working directories" };
206
+ cmd["remove-dir"] = { template: "/remove-dir", description: "Remove a working directory" };
194
207
  if (!dirs.size)
195
208
  return;
196
- cfg.permission ??= {};
197
- cfg.permission.external_directory ??= {};
209
+ const perm = cfg.permission ??= {};
210
+ const extDir = perm.external_directory ??= {};
198
211
  for (const entry of dirs.values())
199
- cfg.permission.external_directory[permissionGlob(entry.path)] = "allow";
212
+ extDir[permissionGlob(entry.path)] = "allow";
200
213
  },
201
214
  "command.execute.before": async (input) => {
202
- if (input.command !== "add-dir")
215
+ const handler = commands[input.command];
216
+ if (!handler)
203
217
  return;
204
- handleCommand(input.arguments || "", input.sessionID);
205
- throw new Error(SENTINEL);
218
+ handler(input.arguments || "", input.sessionID);
219
+ throw SENTINEL;
206
220
  },
207
221
  "tool.execute.before": async (input, output) => {
208
222
  if (shouldGrantBeforeTool(dirs, input.tool, output.args))
209
223
  await grantSession(sdk, input.sessionID, "Directory access granted by add-dir plugin.");
210
224
  },
211
225
  event: async ({ event }) => {
212
- if (event.type === "permission.asked" && event.properties)
213
- await autoApprovePermission(sdk, event.properties, dirs);
226
+ const e = event;
227
+ if (e.type === "permission.asked" && e.properties)
228
+ await autoApprovePermission(sdk, e.properties, dirs);
214
229
  },
215
- "experimental.chat.system.transform": async (_, output) => {
230
+ "experimental.chat.system.transform": async (_input, output) => {
216
231
  output.system.push(...collectAgentContext(dirs));
217
- },
218
- tool: {
219
- add_dir: tool({
220
- description: "Add an external directory as a working directory. Files in added directories can be read and edited without permission prompts.",
221
- args: {
222
- path: tool.schema.string().describe("Absolute or relative path to directory"),
223
- remember: tool.schema.boolean().optional().describe("Persist across sessions")
224
- },
225
- async execute(args, ctx) {
226
- return add(args.path, args.remember ?? false, ctx.sessionID);
227
- }
228
- }),
229
- list_dirs: tool({
230
- description: "List all added working directories.",
231
- args: {},
232
- async execute() {
233
- return list();
234
- }
235
- }),
236
- remove_dir: tool({
237
- description: "Remove a previously added working directory.",
238
- args: { path: tool.schema.string().describe("Path of directory to remove") },
239
- async execute(args) {
240
- return remove(args.path);
241
- }
242
- })
243
232
  }
244
233
  };
245
234
  };
@@ -1,6 +1,8 @@
1
1
  import type { DirEntry } from "./state.js";
2
+ import type { SDK, PermissionEvent, ToolArgs } from "./types.js";
2
3
  export declare function permissionGlob(dirPath: string): string;
3
- export declare function grantSession(sdk: any, sessionID: string, text: string): Promise<void>;
4
- export declare function grantSessionAsync(sdk: any, sessionID: string, text: string): void;
5
- export declare function shouldGrantBeforeTool(dirs: Map<string, DirEntry>, tool: string, args: any): boolean;
6
- export declare function autoApprovePermission(sdk: any, props: any, dirs: Map<string, DirEntry>): Promise<void>;
4
+ export declare function notify(sdk: SDK, sessionID: string, text: string): void;
5
+ export declare function grantSessionAsync(sdk: SDK, sessionID: string, text: string): void;
6
+ export declare function grantSession(sdk: SDK, sessionID: string, text: string): Promise<void>;
7
+ export declare function shouldGrantBeforeTool(dirs: Map<string, DirEntry>, tool: string, args: ToolArgs): boolean;
8
+ export declare function autoApprovePermission(sdk: SDK, props: PermissionEvent, dirs: Map<string, DirEntry>): Promise<void>;
package/dist/state.d.ts CHANGED
@@ -2,6 +2,7 @@ export interface DirEntry {
2
2
  path: string;
3
3
  persist: boolean;
4
4
  }
5
+ export declare function expandHome(p: string): string;
5
6
  export declare function loadDirs(): Map<string, DirEntry>;
6
7
  export declare function saveDirs(dirs: Map<string, DirEntry>): void;
7
8
  export declare function isChildOf(parent: string, child: string): boolean;
@@ -0,0 +1,28 @@
1
+ import type { PluginInput } from "@opencode-ai/plugin";
2
+ export type SDK = PluginInput["client"];
3
+ export interface PromptBody {
4
+ noReply: true;
5
+ tools?: Record<string, boolean>;
6
+ parts: Array<{
7
+ type: "text";
8
+ text: string;
9
+ }>;
10
+ }
11
+ export interface PermissionReplyBody {
12
+ response: "once" | "always" | "reject";
13
+ }
14
+ export interface PermissionEvent {
15
+ id: string;
16
+ sessionID: string;
17
+ permission: string;
18
+ patterns: string[];
19
+ metadata: Record<string, unknown>;
20
+ always: string[];
21
+ }
22
+ export interface ToolArgs {
23
+ filePath?: string;
24
+ path?: string;
25
+ pattern?: string;
26
+ workdir?: string;
27
+ command?: string;
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-add-dir",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
4
4
  "description": "Add working directories to your OpenCode session with auto-approved permissions",
5
5
  "author": "Cristian Fonseca <cfonsecacomas@gmail.com>",
6
6
  "type": "module",