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 +12 -31
- package/dist/index.js +74 -85
- package/dist/permissions.d.ts +6 -4
- package/dist/state.d.ts +1 -0
- package/dist/types.d.ts +28 -0
- package/package.json +1 -1
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
|
-
##
|
|
45
|
-
|
|
46
|
-
### Slash Command
|
|
43
|
+
## Commands
|
|
47
44
|
|
|
48
45
|
```
|
|
49
|
-
/add-dir /path/to/directory #
|
|
46
|
+
/add-dir /path/to/directory # Add for this session
|
|
50
47
|
/add-dir /path/to/directory --remember # Persist across sessions
|
|
51
|
-
/
|
|
52
|
-
/
|
|
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
|
|
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
|
-
###
|
|
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
|
|
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 #
|
|
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 +
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
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
|
-
|
|
209
|
+
const perm = cfg.permission ??= {};
|
|
210
|
+
const extDir = perm.external_directory ??= {};
|
|
198
211
|
for (const entry of dirs.values())
|
|
199
|
-
|
|
212
|
+
extDir[permissionGlob(entry.path)] = "allow";
|
|
200
213
|
},
|
|
201
214
|
"command.execute.before": async (input) => {
|
|
202
|
-
|
|
215
|
+
const handler = commands[input.command];
|
|
216
|
+
if (!handler)
|
|
203
217
|
return;
|
|
204
|
-
|
|
205
|
-
throw
|
|
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
|
-
|
|
213
|
-
|
|
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 (
|
|
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
|
};
|
package/dist/permissions.d.ts
CHANGED
|
@@ -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
|
|
4
|
-
export declare function grantSessionAsync(sdk:
|
|
5
|
-
export declare function
|
|
6
|
-
export declare function
|
|
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;
|
package/dist/types.d.ts
ADDED
|
@@ -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