opencode-add-dir 1.5.0 → 1.7.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.
package/README.md CHANGED
@@ -6,12 +6,8 @@ When you need an agent to read, edit, or search files outside the current projec
6
6
 
7
7
  ## Quick Start
8
8
 
9
- Add to your `opencode.json`:
10
-
11
- ```json
12
- {
13
- "plugin": ["opencode-add-dir"]
14
- }
9
+ ```bash
10
+ opencode plugin opencode-add-dir -g
15
11
  ```
16
12
 
17
13
  Restart OpenCode. Done.
@@ -20,15 +16,13 @@ Restart OpenCode. Done.
20
16
  <summary>Alternative: setup CLI</summary>
21
17
 
22
18
  ```bash
23
- bunx opencode-add-dir-setup
19
+ npx opencode-add-dir-setup
24
20
  ```
25
21
 
26
- Automatically adds the plugin to your global `opencode.json`.
27
-
28
22
  </details>
29
23
 
30
24
  <details>
31
- <summary>Alternative: local file</summary>
25
+ <summary>Alternative: local development</summary>
32
26
 
33
27
  ```bash
34
28
  git clone https://github.com/kuzeofficial/add-dir-opencode.git
@@ -36,77 +30,78 @@ cd add-dir-opencode
36
30
  bun install && bun run deploy
37
31
  ```
38
32
 
39
- Bundles to `~/.config/opencode/plugins/add-dir.js`.
33
+ Add the local path to both configs:
34
+
35
+ ```jsonc
36
+ // ~/.config/opencode/opencode.json
37
+ { "plugin": ["/path/to/add-dir-opencode"] }
38
+
39
+ // ~/.config/opencode/tui.json
40
+ { "plugin": ["/path/to/add-dir-opencode"] }
41
+ ```
40
42
 
41
43
  </details>
42
44
 
43
45
  ## Commands
44
46
 
45
- ```
46
- /add-dir /path/to/directory # Add for this session
47
- /add-dir /path/to/directory --remember # Persist across sessions
48
- /list-dir # Show added directories
49
- /remove-dir /path/to/directory # Remove a directory
50
- ```
47
+ All commands are interactive TUI dialogs — type the command and select from autocomplete.
48
+
49
+ | Command | Dialog | Description |
50
+ |---------|--------|-------------|
51
+ | `/add-dir` | Text input + remember checkbox | Add a working directory. Toggle `[x] Remember` with tab to persist across sessions. |
52
+ | `/list-dir` | Alert | Shows all added directories. |
53
+ | `/remove-dir` | Select list + confirm | Pick a directory to remove, then confirm. |
51
54
 
52
55
  ## How It Works
53
56
 
54
- The plugin uses a layered approach to handle permissions across all sessions, including subagents:
57
+ The plugin has two parts: a **TUI plugin** for the interactive dialogs and a **server plugin** for silent permission handling.
55
58
 
56
- | Layer | When | Scope |
57
- |-------|------|-------|
58
- | **Config hook** | Startup | Injects `external_directory: "allow"` rules for persisted dirs into all agents |
59
- | **Session permission** | `/add-dir` | Sets `external_directory: true` on the current session |
60
- | **tool.execute.before** | Every file tool | Detects subagent sessions accessing added dirs, grants permission before execution |
61
- | **Event auto-approve** | Permission popup | Catches any remaining `external_directory` requests and auto-approves via SDK |
59
+ ### TUI Plugin
62
60
 
63
- ### Context Injection
61
+ Handles all three slash commands via dialogs. Writes persisted directories to `~/.local/share/opencode/add-dir/directories.json` and grants session permissions via the SDK.
64
62
 
65
- If an added directory contains `AGENTS.md`, `CLAUDE.md`, or `.agents/AGENTS.md`, the content is automatically injected into the system prompt.
63
+ ### Server Plugin
66
64
 
67
- ## Persistence
65
+ Runs in the background — no commands, only hooks:
68
66
 
69
- Directories added with `--remember` are stored in:
67
+ | Hook | What it does |
68
+ |------|-------------|
69
+ | `config` | Injects `external_directory: "allow"` permission rules for persisted dirs at startup |
70
+ | `tool.execute.before` | Auto-grants permissions when subagents access added directories |
71
+ | `event` | Auto-approves any remaining permission popups for added directories |
72
+ | `system.transform` | Injects `AGENTS.md` / `CLAUDE.md` content from added directories into the system prompt |
70
73
 
71
- ```
72
- ~/.local/share/opencode/add-dir/directories.json
73
- ```
74
+ ### Context Injection
74
75
 
75
- These are loaded at startup and injected into agent permission rules via the config hook.
76
+ If an added directory contains `AGENTS.md`, `CLAUDE.md`, or `.agents/AGENTS.md`, the content is automatically injected into the system prompt.
76
77
 
77
78
  ## Development
78
79
 
79
80
  ```bash
80
81
  bun install
81
- bun test # 33 tests
82
+ bun test # Run tests
82
83
  bun run typecheck # Type check
83
84
  bun run build # Build npm package
84
- bun run deploy # Bundle to ~/.config/opencode/plugins/
85
+ bun run deploy # Build server + TUI locally
85
86
  ```
86
87
 
87
88
  ### Project Structure
88
89
 
89
90
  ```
90
91
  src/
91
- ├── index.ts # Entry point (default export)
92
- ├── plugin.ts # Hooks + commands
93
- ├── state.ts # Persistence + path utils
94
- ├── validate.ts # Directory validation
95
- ├── permissions.ts # Session grants + auto-approve
96
- ├── context.ts # AGENTS.md injection
97
- └── types.ts # Shared type definitions
98
- ```
99
-
100
- ## Debugging
101
-
102
- ```bash
103
- opencode --print-logs 2>debug.log
104
- grep "\[add-dir\]" debug.log
92
+ ├── index.ts # Server plugin entry
93
+ ├── plugin.ts # Server hooks (permissions, context injection)
94
+ ├── tui-plugin.tsx # TUI plugin (dialogs for add/list/remove)
95
+ ├── state.ts # Persistence, path utils, tui.json auto-config
96
+ ├── validate.ts # Directory validation
97
+ ├── permissions.ts # Session grants + auto-approve
98
+ ├── context.ts # AGENTS.md injection
99
+ └── types.ts # Shared type definitions
105
100
  ```
106
101
 
107
102
  ## Limitations
108
103
 
109
- - Directories added mid-session (without `--remember`) rely on session-level permissions and the event hook auto-approve. The first access by a subagent may briefly show a permission popup before auto-dismissing.
104
+ - Directories added without "Remember" rely on session-level permissions. The first access by a subagent may briefly show a permission popup before auto-dismissing.
110
105
  - The `permission.ask` plugin hook is defined in the OpenCode SDK but [not invoked](https://github.com/sst/opencode/blob/main/packages/opencode/src/permission/index.ts) in the source — this plugin works around it using `tool.execute.before` and event-based auto-approval.
111
106
 
112
107
  ## License
package/bin/setup.mjs CHANGED
@@ -4,98 +4,55 @@ import { join } from "path"
4
4
  import { homedir } from "os"
5
5
 
6
6
  const PKG = "opencode-add-dir"
7
- const args = process.argv.slice(2)
8
- const isRemove = args.includes("--remove")
7
+ const isRemove = process.argv.includes("--remove")
8
+ const dir = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode")
9
9
 
10
- function configDir() {
11
- if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, "opencode")
12
- return join(homedir(), ".config", "opencode")
13
- }
14
-
15
- function stripJsonComments(text) {
16
- let result = ""
17
- let inString = false
18
- let escape = false
19
- for (let i = 0; i < text.length; i++) {
20
- const ch = text[i]
21
- if (escape) { result += ch; escape = false; continue }
22
- if (ch === "\\" && inString) { result += ch; escape = true; continue }
23
- if (ch === '"') { inString = !inString; result += ch; continue }
24
- if (inString) { result += ch; continue }
25
- if (ch === "/" && text[i + 1] === "/") { while (i < text.length && text[i] !== "\n") i++; continue }
26
- if (ch === "/" && text[i + 1] === "*") { i += 2; while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++; i++; continue }
27
- result += ch
28
- }
29
- return result
30
- }
31
-
32
- function findConfigFile(dir, baseName) {
10
+ function findFile(base) {
33
11
  for (const ext of [".jsonc", ".json"]) {
34
- const p = join(dir, baseName + ext)
12
+ const p = join(dir, base + ext)
35
13
  if (existsSync(p)) return p
36
14
  }
37
- return join(dir, baseName + ".json")
15
+ return join(dir, base + ".json")
38
16
  }
39
17
 
40
- function readConfig(filePath) {
41
- if (!existsSync(filePath)) return {}
42
- return JSON.parse(stripJsonComments(readFileSync(filePath, "utf-8")))
18
+ function readConfig(path) {
19
+ if (!existsSync(path)) return {}
20
+ return JSON.parse(readFileSync(path, "utf-8").replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""))
43
21
  }
44
22
 
45
23
  function hasPlugin(plugins) {
46
- return (plugins || []).some((p) => {
47
- const name = Array.isArray(p) ? p[0] : p
48
- return name === PKG || name.startsWith(PKG + "@")
49
- })
50
- }
51
-
52
- function withoutPlugin(plugins) {
53
- return (plugins || []).filter((p) => {
54
- const name = Array.isArray(p) ? p[0] : p
55
- return name !== PKG && !name.startsWith(PKG + "@")
24
+ return plugins.some((p) => {
25
+ const n = Array.isArray(p) ? p[0] : p
26
+ return n === PKG || n.startsWith(PKG + "@")
56
27
  })
57
28
  }
58
29
 
59
- function patchConfig(filePath, config, schemaUrl) {
60
- config.plugin = config.plugin || []
30
+ function patch(path, schema) {
31
+ const config = readConfig(path)
32
+ config.plugin ??= []
61
33
 
62
34
  if (isRemove) {
63
35
  if (!hasPlugin(config.plugin)) return false
64
- config.plugin = withoutPlugin(config.plugin)
65
- writeFileSync(filePath, JSON.stringify(config, null, 2) + "\n")
66
- return true
36
+ config.plugin = config.plugin.filter((p) => {
37
+ const n = Array.isArray(p) ? p[0] : p
38
+ return n !== PKG && !n.startsWith(PKG + "@")
39
+ })
40
+ } else {
41
+ if (hasPlugin(config.plugin)) return false
42
+ config.plugin.push(PKG)
43
+ if (schema && !config.$schema) config.$schema = schema
67
44
  }
68
45
 
69
- if (hasPlugin(config.plugin)) return false
70
- config.plugin.push(PKG)
71
- if (schemaUrl && !config.$schema) config.$schema = schemaUrl
72
- writeFileSync(filePath, JSON.stringify(config, null, 2) + "\n")
46
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n")
73
47
  return true
74
48
  }
75
49
 
76
- function run() {
77
- const dir = configDir()
78
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
50
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
79
51
 
80
- const serverPath = findConfigFile(dir, "opencode")
81
- const tuiPath = findConfigFile(dir, "tui")
82
-
83
- const serverConfig = readConfig(serverPath)
84
- const tuiConfig = readConfig(tuiPath)
85
-
86
- const verb = isRemove ? "Removed" : "Added"
87
- const serverChanged = patchConfig(serverPath, serverConfig, "https://opencode.ai/config.json")
88
- const tuiChanged = patchConfig(tuiPath, tuiConfig)
89
-
90
- if (serverChanged) console.log(`${verb} ${PKG} in ${serverPath}`)
91
- else console.log(`${PKG} already ${isRemove ? "absent from" : "in"} ${serverPath}`)
92
-
93
- if (tuiChanged) console.log(`${verb} ${PKG} in ${tuiPath}`)
94
- else console.log(`${PKG} already ${isRemove ? "absent from" : "in"} ${tuiPath}`)
95
-
96
- if (serverChanged || tuiChanged) {
97
- console.log("Restart OpenCode to activate the plugin.")
98
- }
52
+ for (const [label, path, schema] of [
53
+ ["server", findFile("opencode"), "https://opencode.ai/config.json"],
54
+ ["tui", findFile("tui")],
55
+ ]) {
56
+ if (patch(path, schema)) console.log(`${isRemove ? "Removed from" : "Added to"} ${label}: ${path}`)
57
+ else console.log(`${label}: already ${isRemove ? "absent" : "configured"}`)
99
58
  }
100
-
101
- run()
package/dist/index.js CHANGED
@@ -1,309 +1,7 @@
1
- // src/state.ts
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
- import { join } from "path";
4
- function stateDir() {
5
- return join(process.env["XDG_DATA_HOME"] || join(process.env["HOME"] || "~", ".local", "share"), "opencode", "add-dir");
6
- }
7
- function expandHome(p) {
8
- return p.startsWith("~/") ? (process.env["HOME"] || "~") + p.slice(1) : p;
9
- }
10
- function loadDirs() {
11
- const dirs = new Map;
12
- const file = join(stateDir(), "directories.json");
13
- if (!existsSync(file))
14
- return dirs;
15
- try {
16
- for (const p of JSON.parse(readFileSync(file, "utf-8")))
17
- dirs.set(p, { path: p, persist: true });
18
- } catch {}
19
- return dirs;
20
- }
21
- function saveDirs(dirs) {
22
- const list = [...dirs.values()].filter((d) => d.persist).map((d) => d.path);
23
- const dir = stateDir();
24
- if (!existsSync(dir))
25
- mkdirSync(dir, { recursive: true });
26
- writeFileSync(join(dir, "directories.json"), JSON.stringify(list, null, 2));
27
- }
28
- function isChildOf(parent, child) {
29
- return child === parent || child.startsWith(parent + "/");
30
- }
31
- function matchesDirs(dirs, filepath) {
32
- for (const entry of dirs.values()) {
33
- if (isChildOf(entry.path, filepath))
34
- return true;
35
- }
36
- return false;
37
- }
38
- var PKG = "opencode-add-dir";
39
- function stripJsonComments(text) {
40
- let result = "";
41
- let inString = false;
42
- let escape = false;
43
- for (let i = 0;i < text.length; i++) {
44
- const ch = text[i];
45
- if (escape) {
46
- result += ch;
47
- escape = false;
48
- continue;
49
- }
50
- if (ch === "\\" && inString) {
51
- result += ch;
52
- escape = true;
53
- continue;
54
- }
55
- if (ch === '"') {
56
- inString = !inString;
57
- result += ch;
58
- continue;
59
- }
60
- if (inString) {
61
- result += ch;
62
- continue;
63
- }
64
- if (ch === "/" && text[i + 1] === "/") {
65
- while (i < text.length && text[i] !== `
66
- `)
67
- i++;
68
- continue;
69
- }
70
- if (ch === "/" && text[i + 1] === "*") {
71
- i += 2;
72
- while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
73
- i++;
74
- i++;
75
- continue;
76
- }
77
- result += ch;
78
- }
79
- return result;
80
- }
81
- function configDir() {
82
- return join(process.env["XDG_CONFIG_HOME"] || join(process.env["HOME"] || "~", ".config"), "opencode");
83
- }
84
- function findTuiConfig() {
85
- const dir = configDir();
86
- for (const name of ["tui.jsonc", "tui.json"]) {
87
- const p = join(dir, name);
88
- if (existsSync(p))
89
- return p;
90
- }
91
- return join(dir, "tui.json");
92
- }
93
- function ensureTuiConfig() {
94
- try {
95
- const dir = configDir();
96
- if (!existsSync(dir))
97
- mkdirSync(dir, { recursive: true });
98
- const filePath = findTuiConfig();
99
- let config = {};
100
- if (existsSync(filePath)) {
101
- config = JSON.parse(stripJsonComments(readFileSync(filePath, "utf-8")));
102
- }
103
- const plugins = config.plugin ?? [];
104
- const hasEntry = plugins.some((p) => {
105
- const name = Array.isArray(p) ? p[0] : p;
106
- return name === PKG || typeof name === "string" && name.startsWith(PKG + "@");
107
- });
108
- if (hasEntry)
109
- return;
110
- config.plugin = [...plugins, PKG];
111
- writeFileSync(filePath, JSON.stringify(config, null, 2) + `
112
- `);
113
- } catch {}
114
- }
1
+ import{existsSync as m,mkdirSync as k,readFileSync as E,writeFileSync as G,statSync as g,unlinkSync as I}from"fs";import{join as o}from"path";function S(){return o(process.env.XDG_DATA_HOME||o(process.env.HOME||"~",".local","share"),"opencode","add-dir")}function x(){return o(S(),"directories.json")}function d(){return o(S(),"session-dirs.json")}function C(n){return n.startsWith("~/")?(process.env.HOME||"~")+n.slice(1):n}function y(n){try{return JSON.parse(E(n,"utf-8"))}catch{return[]}}function N(){let n=new Map;for(let t of y(x()))n.set(t,{path:t,persist:!0});for(let t of y(d()))if(!n.has(t))n.set(t,{path:t,persist:!1});return n}var c,h=0,D=0,b=500;function p(){let n=Date.now();if(c&&n-D<b)return c;D=n;let t=0;try{t+=g(x()).mtimeMs}catch{}try{t+=g(d()).mtimeMs}catch{}if(c&&t===h)return c;return h=t,c=N(),c}function F(n,t){return t===n||t.startsWith(n+"/")}function u(n,t){for(let e of n.values())if(F(e.path,t))return!0;return!1}var l="opencode-add-dir",f=o(process.env.XDG_CONFIG_HOME||o(process.env.HOME||"~",".config"),"opencode");function H(n){let t="",e=!1,i=!1;for(let r=0;r<n.length;r++){let s=n[r];if(i){t+=s,i=!1;continue}if(s==="\\"&&e){t+=s,i=!0;continue}if(s==='"'){e=!e,t+=s;continue}if(e){t+=s;continue}if(s==="/"&&n[r+1]==="/"){while(r<n.length&&n[r]!==`
2
+ `)r++;continue}if(s==="/"&&n[r+1]==="*"){r+=2;while(r<n.length&&!(n[r]==="*"&&n[r+1]==="/"))r++;r++;continue}t+=s}return t}function R(){for(let n of["tui.jsonc","tui.json"]){let t=o(f,n);if(m(t))return t}return o(f,"tui.json")}function A(){try{I(d())}catch{}try{if(!m(f))k(f,{recursive:!0});let n=R(),t={};if(m(n))t=JSON.parse(H(E(n,"utf-8")));let e=t.plugin??[];if(e.some((r)=>{let s=Array.isArray(r)?r[0]:r;return s===l||typeof s==="string"&&s.startsWith(l+"@")}))return;t.plugin=[...e,l],G(n,JSON.stringify(t,null,2)+`
3
+ `)}catch{}}import{resolve as J}from"path";var K=new Set(["read","write","edit","apply_patch","multiedit","glob","grep","list","bash"]),T=new Set;function v(n){return n+"/*"}async function w(n,t){if(T.has(t))return;T.add(t),await n.session.prompt({path:{id:t},body:{noReply:!0,tools:{external_directory:!0},parts:[]}}).catch(()=>{})}function M(n,t,e){if(!n.size||!K.has(t))return!1;let i=L(t,e);return!!i&&u(n,J(C(i)))}async function P(n,t,e){if(t.permission!=="external_directory")return;let{filepath:i="",parentDir:r=""}=t.metadata,s=t.patterns??[];if(!(u(e,i)||u(e,r)||s.some((j)=>u(e,j.replace(/\/?\*$/,""))))||!t.id||!t.sessionID)return;await n.postSessionIdPermissionsPermissionId({path:{id:t.sessionID,permissionID:t.id},body:{response:"always"}}).catch(()=>{})}function L(n,t){if(!t)return"";if(n==="bash")return t.workdir||t.command||"";return t.filePath||t.path||t.pattern||""}import{existsSync as $,readFileSync as X}from"fs";import{join as z}from"path";var W=["AGENTS.md","CLAUDE.md",".agents/AGENTS.md"];function B(){return process.env.OPENCODE_ADDDIR_INJECT_CONTEXT==="1"}function O(n){if(!n.size)return[];let e=[`Additional working directories:
4
+ ${[...n.values()].map((i)=>`- ${i.path}`).join(`
5
+ `)}`];if(!B())return e;for(let i of n.values())for(let r of W){let s=z(i.path,r);if(!$(s))continue;let a=X(s,"utf-8").trim();if(a)e.push(`# Context from ${s}
115
6
 
116
- // src/validate.ts
117
- import { statSync } from "fs";
118
- import { resolve } from "path";
119
- function validateDir(input, worktree, existing) {
120
- const trimmed = input.trim();
121
- if (!trimmed)
122
- return { ok: false, reason: "No directory path provided." };
123
- const abs = resolve(expandHome(trimmed));
124
- try {
125
- if (!statSync(abs).isDirectory())
126
- return { ok: false, reason: `${abs} is not a directory.` };
127
- } catch (e) {
128
- const code = e.code;
129
- if (code && ["ENOENT", "ENOTDIR", "EACCES", "EPERM"].includes(code))
130
- return { ok: false, reason: `Path ${abs} was not found.` };
131
- throw e;
132
- }
133
- if (isChildOf(worktree, abs))
134
- return { ok: false, reason: `${abs} is already within the project directory ${worktree}.` };
135
- for (const dir of existing)
136
- if (isChildOf(dir, abs))
137
- return { ok: false, reason: `${abs} is already accessible within ${dir}.` };
138
- return { ok: true, absolutePath: abs };
139
- }
140
-
141
- // src/permissions.ts
142
- import { join as join2, resolve as resolve2 } from "path";
143
- var FILE_TOOLS = new Set(["read", "write", "edit", "apply_patch", "multiedit", "glob", "grep", "list", "bash"]);
144
- var grantedSessions = new Set;
145
- function permissionGlob(dirPath) {
146
- return join2(dirPath, "*");
147
- }
148
- function sendPrompt(sdk, sessionID, text, tools) {
149
- const body = { noReply: true, ...tools && { tools }, parts: [{ type: "text", text }] };
150
- return sdk.session.promptAsync({ path: { id: sessionID }, body })?.then?.(() => {})?.catch?.(() => {}) ?? Promise.resolve();
151
- }
152
- function notify(sdk, sessionID, text) {
153
- sendPrompt(sdk, sessionID, text);
154
- }
155
- function grantSessionAsync(sdk, sessionID, text) {
156
- grantedSessions.add(sessionID);
157
- sendPrompt(sdk, sessionID, text, { external_directory: true });
158
- }
159
- async function grantSession(sdk, sessionID, text) {
160
- if (grantedSessions.has(sessionID))
161
- return;
162
- grantedSessions.add(sessionID);
163
- const body = { noReply: true, tools: { external_directory: true }, parts: [{ type: "text", text }] };
164
- await sdk.session.prompt({ path: { id: sessionID }, body }).catch(() => {});
165
- }
166
- function shouldGrantBeforeTool(dirs, tool, args) {
167
- if (!dirs.size || !FILE_TOOLS.has(tool))
168
- return false;
169
- const p = extractPath(tool, args);
170
- return !!p && matchesDirs(dirs, resolve2(expandHome(p)));
171
- }
172
- async function autoApprovePermission(sdk, props, dirs) {
173
- if (props.permission !== "external_directory")
174
- return;
175
- const meta = props.metadata;
176
- const filepath = meta.filepath ?? "";
177
- const parentDir = meta.parentDir ?? "";
178
- const patterns = props.patterns ?? [];
179
- const matches = matchesDirs(dirs, filepath) || matchesDirs(dirs, parentDir) || patterns.some((p) => matchesDirs(dirs, p.replace(/\/?\*$/, "")));
180
- if (!matches || !props.id || !props.sessionID)
181
- return;
182
- await sdk.postSessionIdPermissionsPermissionId({
183
- path: { id: props.sessionID, permissionID: props.id },
184
- body: { response: "always" }
185
- }).catch(() => {});
186
- }
187
- function extractPath(tool, args) {
188
- if (!args)
189
- return "";
190
- if (tool === "bash")
191
- return args.workdir || args.command || "";
192
- return args.filePath || args.path || args.pattern || "";
193
- }
194
-
195
- // src/context.ts
196
- import { readFileSync as readFileSync2 } from "fs";
197
- import { join as join3 } from "path";
198
- var CONTEXT_FILES = ["AGENTS.md", "CLAUDE.md", ".agents/AGENTS.md"];
199
- function collectAgentContext(dirs) {
200
- const sections = [];
201
- for (const entry of dirs.values()) {
202
- for (const name of CONTEXT_FILES) {
203
- const fp = join3(entry.path, name);
204
- try {
205
- const content = readFileSync2(fp, "utf-8").trim();
206
- if (content)
207
- sections.push(`# Context from ${fp}
208
-
209
- ${content}`);
210
- } catch {}
211
- }
212
- }
213
- return sections;
214
- }
215
-
216
- // src/plugin.ts
217
- var SENTINEL = Object.assign(new Error("__ADD_DIR_HANDLED__"), { stack: "" });
218
- ensureTuiConfig();
219
- var AddDirPlugin = async ({ client, worktree, directory }) => {
220
- const root = worktree || directory;
221
- const dirs = loadDirs();
222
- const sdk = client;
223
- function add(dirPath, persist) {
224
- const result = validateDir(dirPath, root, [...dirs.values()].map((d) => d.path));
225
- if (!result.ok)
226
- return { ok: false, message: result.reason };
227
- dirs.set(result.absolutePath, { path: result.absolutePath, persist });
228
- if (persist)
229
- saveDirs(dirs);
230
- const label = persist ? "persistent" : "session";
231
- return { ok: true, message: `Added ${result.absolutePath} as a working directory (${label}).` };
232
- }
233
- function remove(path) {
234
- if (!path?.trim())
235
- return "Usage: /remove-dir <path>";
236
- if (!dirs.has(path))
237
- return `${path} is not in the directory list.`;
238
- dirs.delete(path);
239
- saveDirs(dirs);
240
- return `Removed ${path} from working directories.`;
241
- }
242
- function list() {
243
- if (!dirs.size)
244
- return "No additional directories added.";
245
- return [...dirs.values()].map((d) => `${d.path} (${d.persist ? "persistent" : "session"})`).join(`
246
- `);
247
- }
248
- function handleAdd(args, sessionID) {
249
- const tokens = args.trim().split(/\s+/);
250
- const flags = new Set(tokens.filter((t) => t.startsWith("--")));
251
- const pos = tokens.filter((t) => !t.startsWith("--"));
252
- if (!pos[0])
253
- return notify(sdk, sessionID, "Usage: /add-dir <path> [--remember]");
254
- const result = add(pos[0], flags.has("--remember"));
255
- if (result.ok)
256
- grantSessionAsync(sdk, sessionID, result.message);
257
- else
258
- notify(sdk, sessionID, result.message);
259
- }
260
- const commands = {
261
- __adddir: (args, sid) => handleAdd(args, sid),
262
- "list-dir": (_, sid) => notify(sdk, sid, list()),
263
- "remove-dir": (args, sid) => notify(sdk, sid, remove(args))
264
- };
265
- return {
266
- config: async (cfg) => {
267
- cfg.command ??= {};
268
- const cmd = cfg.command;
269
- cmd["__adddir"] = { template: "/__adddir", description: "Internal: add a working directory" };
270
- cmd["list-dir"] = { template: "/list-dir", description: "List added working directories" };
271
- cmd["remove-dir"] = { template: "/remove-dir", description: "Remove a working directory" };
272
- if (!dirs.size)
273
- return;
274
- const perm = cfg.permission ??= {};
275
- const extDir = perm.external_directory ??= {};
276
- for (const entry of dirs.values())
277
- extDir[permissionGlob(entry.path)] = "allow";
278
- },
279
- "command.execute.before": async (input) => {
280
- const handler = commands[input.command];
281
- if (!handler)
282
- return;
283
- handler(input.arguments || "", input.sessionID);
284
- throw SENTINEL;
285
- },
286
- "tool.execute.before": async (input, output) => {
287
- if (shouldGrantBeforeTool(dirs, input.tool, output.args))
288
- await grantSession(sdk, input.sessionID, "Directory access granted by add-dir plugin.");
289
- },
290
- event: async ({ event }) => {
291
- const e = event;
292
- if (e.type === "permission.asked" && e.properties)
293
- await autoApprovePermission(sdk, e.properties, dirs);
294
- },
295
- "experimental.chat.system.transform": async (_input, output) => {
296
- output.system.push(...collectAgentContext(dirs));
297
- }
298
- };
299
- };
300
-
301
- // src/index.ts
302
- var plugin = {
303
- id: "opencode-add-dir",
304
- server: AddDirPlugin
305
- };
306
- var src_default = plugin;
307
- export {
308
- src_default as default
309
- };
7
+ ${a}`)}return e}A();var _=async({client:n})=>{let t=n;return{config:async(e)=>{let i=p();if(!i.size)return;let r=e.permission??={},s=r.external_directory??={};for(let a of i.values())s[v(a.path)]="allow"},"tool.execute.before":async(e,i)=>{let r=p();if(M(r,e.tool,i.args))await w(t,e.sessionID)},event:async({event:e})=>{let i=e;if(i.type==="permission.asked"&&i.properties){let r=p();await P(t,i.properties,r)}},"experimental.chat.system.transform":async(e,i)=>{let r=p();i.system.push(...O(r))}}};var U={id:"opencode-add-dir",server:_},ut=U;export{ut as default};
@@ -1,8 +1,8 @@
1
1
  import type { DirEntry } from "./state.js";
2
2
  import type { SDK, PermissionEvent, ToolArgs } from "./types.js";
3
+ export declare function resetGrantedSessions(): void;
3
4
  export declare function permissionGlob(dirPath: string): string;
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>;
5
+ export declare function grantSession(sdk: SDK, sessionID: string): Promise<void>;
7
6
  export declare function shouldGrantBeforeTool(dirs: Map<string, DirEntry>, tool: string, args: ToolArgs): boolean;
8
7
  export declare function autoApprovePermission(sdk: SDK, props: PermissionEvent, dirs: Map<string, DirEntry>): Promise<void>;
8
+ export declare function extractPath(tool: string, args: ToolArgs): string;
package/dist/state.d.ts CHANGED
@@ -3,8 +3,8 @@ export interface DirEntry {
3
3
  persist: boolean;
4
4
  }
5
5
  export declare function expandHome(p: string): string;
6
- export declare function loadDirs(): Map<string, DirEntry>;
7
- export declare function saveDirs(dirs: Map<string, DirEntry>): void;
6
+ export declare function freshDirs(): Map<string, DirEntry>;
7
+ export declare function invalidateCache(): void;
8
8
  export declare function isChildOf(parent: string, child: string): boolean;
9
9
  export declare function matchesDirs(dirs: Map<string, DirEntry>, filepath: string): boolean;
10
10
  export declare function ensureTuiConfig(): void;
@@ -1,5 +1,6 @@
1
- import type { TuiPluginModule } from "@opencode-ai/plugin/tui";
2
- declare const plugin: TuiPluginModule & {
1
+ import type { TuiPlugin } from "@opencode-ai/plugin/tui";
2
+ declare const _default: {
3
3
  id: string;
4
+ tui: TuiPlugin;
4
5
  };
5
- export default plugin;
6
+ export default _default;
package/dist/tui.tsx CHANGED
@@ -1,110 +1,175 @@
1
1
  import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
2
+ import { useKeyboard } from "@opentui/solid"
2
3
  import { createSignal } from "solid-js"
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "fs"
5
+ import { join, resolve } from "path"
3
6
 
4
- const PLUGIN_ID = "opencode-add-dir"
5
- const INTERNAL_COMMAND = "__adddir"
7
+ const ID = "opencode-add-dir"
8
+ const STATE_DIR = join(process.env["XDG_DATA_HOME"] || join(process.env["HOME"] || "~", ".local", "share"), "opencode", "add-dir")
9
+ const PERSISTED_FILE = join(STATE_DIR, "directories.json")
10
+ const SESSION_FILE = join(STATE_DIR, "session-dirs.json")
6
11
 
7
- function activeSessionID(api: TuiPluginApi): string | undefined {
8
- const route = api.route.current
9
- if (route.name !== "session" || !route.params) return
10
- return route.params.sessionID as string
12
+ function readJsonArray(file: string): string[] {
13
+ try { return JSON.parse(readFileSync(file, "utf-8")) } catch { return [] }
11
14
  }
12
15
 
13
- async function ensureSession(api: TuiPluginApi): Promise<string | undefined> {
14
- const existing = activeSessionID(api)
15
- if (existing) return existing
16
+ function writeJsonArray(file: string, items: string[]) {
17
+ if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true })
18
+ writeFileSync(file, JSON.stringify(items, null, 2))
19
+ }
16
20
 
17
- const res = await api.client.session.create({})
18
- if (res.error) return
21
+ function allDirs(): string[] {
22
+ return [...new Set([...readJsonArray(PERSISTED_FILE), ...readJsonArray(SESSION_FILE)])]
23
+ }
19
24
 
20
- api.route.navigate("session", { sessionID: res.data.id })
21
- return res.data.id
25
+ function resolvePath(input: string) {
26
+ const p = input.trim()
27
+ return resolve(p.startsWith("~/") ? (process.env["HOME"] || "~") + p.slice(1) : p)
22
28
  }
23
29
 
24
- async function executeAddDir(api: TuiPluginApi, dirPath: string) {
25
- const sessionID = await ensureSession(api)
26
- if (!sessionID) {
27
- api.ui.toast({ variant: "error", message: "Failed to create session" })
28
- return
30
+ function validate(input: string): string | undefined {
31
+ if (!input.trim()) return "Path is required."
32
+ const abs = resolvePath(input)
33
+ try { if (!statSync(abs).isDirectory()) return `Not a directory: ${abs}` }
34
+ catch { return `Not found: ${abs}` }
35
+ if (allDirs().includes(abs)) return `Already added: ${abs}`
36
+ }
37
+
38
+ function addDir(abs: string, persist: boolean) {
39
+ const file = persist ? PERSISTED_FILE : SESSION_FILE
40
+ const dirs = readJsonArray(file)
41
+ if (!dirs.includes(abs)) writeJsonArray(file, [...dirs, abs])
42
+ }
43
+
44
+ function removeDir(path: string) {
45
+ for (const file of [PERSISTED_FILE, SESSION_FILE]) {
46
+ const dirs = readJsonArray(file)
47
+ if (dirs.includes(path)) writeJsonArray(file, dirs.filter((d) => d !== path))
29
48
  }
49
+ }
30
50
 
31
- // The server plugin intercepts via command.execute.before, handles the logic,
32
- // sends feedback through the session event stream, then throws SENTINEL to
33
- // prevent the command template from reaching the LLM. This rejection is expected.
34
- api.client.session.command({
35
- sessionID,
36
- command: INTERNAL_COMMAND,
37
- arguments: dirPath,
38
- }).catch(() => {})
51
+ function getSessionID(api: TuiPluginApi): string | undefined {
52
+ const r = api.route.current
53
+ return r.name === "session" && r.params ? r.params.sessionID as string : undefined
54
+ }
55
+
56
+ async function ensureSession(api: TuiPluginApi): Promise<string | undefined> {
57
+ const id = getSessionID(api)
58
+ if (id) return id
59
+ const res = await api.client.session.create({})
60
+ if (res.error) return
61
+ api.route.navigate("session", { sessionID: res.data.id })
62
+ return res.data.id
39
63
  }
40
64
 
41
65
  function AddDirDialog(props: { api: TuiPluginApi }) {
42
66
  const [busy, setBusy] = createSignal(false)
67
+ const [remember, setRemember] = createSignal(false)
68
+ const { api } = props
43
69
 
44
- async function handleConfirm(value: string) {
45
- const dirPath = value.trim()
46
- if (!dirPath) {
47
- props.api.ui.toast({ variant: "error", message: "Please enter a directory path" })
48
- return
49
- }
50
-
51
- setBusy(true)
52
- try {
53
- await executeAddDir(props.api, dirPath)
54
- props.api.ui.dialog.clear()
55
- } finally {
56
- setBusy(false)
57
- }
58
- }
70
+ useKeyboard((e) => {
71
+ if (e.name !== "tab" || busy()) return
72
+ e.preventDefault()
73
+ e.stopPropagation()
74
+ setRemember((v) => !v)
75
+ })
59
76
 
60
77
  return (
61
- <props.api.ui.DialogPrompt
78
+ <api.ui.DialogPrompt
62
79
  title="Add directory"
63
80
  placeholder="/path/to/directory"
64
81
  busy={busy()}
65
- busyText="Adding directory..."
82
+ busyText="Adding..."
66
83
  description={() => (
67
- <box gap={0}>
68
- <text fg={props.api.theme.current.textMuted}>
69
- To get the full path of a project:
70
- </text>
71
- <text fg={props.api.theme.current.textMuted}>
72
- {" "}1. Move to the project in your terminal
73
- </text>
74
- <text fg={props.api.theme.current.textMuted}>
75
- {" "}2. Run "pwd" and copy the output
76
- </text>
77
- <text fg={props.api.theme.current.textMuted}>
78
- {" "}3. Paste below
79
- </text>
84
+ <box gap={1}>
85
+ <box gap={0}>
86
+ <text fg={api.theme.current.textMuted}>How to get the full path:</text>
87
+ <text fg={api.theme.current.textMuted}> 1. cd to the project in your terminal</text>
88
+ <text fg={api.theme.current.textMuted}> 2. Run "pwd", copy the output</text>
89
+ <text fg={api.theme.current.textMuted}> 3. Paste below</text>
90
+ </box>
91
+ <box flexDirection="row" gap={1}>
92
+ <text fg={remember() ? api.theme.current.text : api.theme.current.textMuted}>
93
+ {remember() ? "[x]" : "[ ]"} Remember across sessions
94
+ </text>
95
+ <text fg={api.theme.current.textMuted}>(tab)</text>
96
+ </box>
80
97
  </box>
81
98
  )}
82
- onConfirm={handleConfirm}
83
- onCancel={() => props.api.ui.dialog.clear()}
99
+ onConfirm={async (value) => {
100
+ if (busy()) return
101
+ const err = validate(value)
102
+ if (err) return api.ui.toast({ variant: "error", message: err })
103
+
104
+ const abs = resolvePath(value)
105
+ const persist = remember()
106
+
107
+ setBusy(true)
108
+ const sid = await ensureSession(api)
109
+ if (!sid) {
110
+ setBusy(false)
111
+ return api.ui.toast({ variant: "error", message: "Failed to create session" })
112
+ }
113
+
114
+ addDir(abs, persist)
115
+ api.ui.dialog.clear()
116
+
117
+ const label = persist ? "persistent" : "session"
118
+ api.client.session.prompt({
119
+ sessionID: sid,
120
+ parts: [{ type: "text", text: `Added ${abs} as a working directory (${label}).`, ignored: true }],
121
+ noReply: true,
122
+ tools: { external_directory: true },
123
+ }).catch(() => {})
124
+ }}
125
+ onCancel={() => api.ui.dialog.clear()}
84
126
  />
85
127
  )
86
128
  }
87
129
 
88
- function showDialog(api: TuiPluginApi) {
89
- api.ui.dialog.replace(() => <AddDirDialog api={api} />)
130
+ function showListDirs(api: TuiPluginApi) {
131
+ const dirs = allDirs()
132
+ if (!dirs.length) return api.ui.toast({ variant: "info", message: "No directories added." })
133
+ api.ui.dialog.replace(() => (
134
+ <api.ui.DialogAlert
135
+ title={`Directories (${dirs.length})`}
136
+ message={dirs.join("\n")}
137
+ onConfirm={() => api.ui.dialog.clear()}
138
+ />
139
+ ))
140
+ }
141
+
142
+ function showRemoveDir(api: TuiPluginApi) {
143
+ const dirs = allDirs()
144
+ if (!dirs.length) return api.ui.toast({ variant: "info", message: "No directories to remove." })
145
+ api.ui.dialog.replace(() => (
146
+ <api.ui.DialogSelect
147
+ title="Remove directory"
148
+ options={dirs.map((d) => ({ title: d, value: d }))}
149
+ onSelect={(opt) => {
150
+ api.ui.dialog.replace(() => (
151
+ <api.ui.DialogConfirm
152
+ title="Remove directory"
153
+ message={`Remove ${opt.value}?`}
154
+ onConfirm={() => {
155
+ removeDir(opt.value as string)
156
+ api.ui.dialog.clear()
157
+ api.ui.toast({ variant: "success", message: `Removed ${opt.value}` })
158
+ }}
159
+ onCancel={() => showRemoveDir(api)}
160
+ />
161
+ ))
162
+ }}
163
+ />
164
+ ))
90
165
  }
91
166
 
92
167
  const tui: TuiPlugin = async (api) => {
93
168
  api.command.register(() => [
94
- {
95
- title: "Add directory",
96
- value: "add-dir.dialog",
97
- description: "Add a working directory to the session",
98
- category: "Directories",
99
- slash: { name: "add-dir" },
100
- onSelect: () => showDialog(api),
101
- },
169
+ { title: "Add directory", value: "add-dir", description: "Add a working directory", category: "Directories", slash: { name: "add-dir" }, onSelect: () => api.ui.dialog.replace(() => <AddDirDialog api={api} />) },
170
+ { title: "List directories", value: "list-dir", description: "Show working directories", category: "Directories", slash: { name: "list-dir" }, onSelect: () => showListDirs(api) },
171
+ { title: "Remove directory", value: "remove-dir", description: "Remove a working directory", category: "Directories", slash: { name: "remove-dir" }, onSelect: () => showRemoveDir(api) },
102
172
  ])
103
173
  }
104
174
 
105
- const plugin: TuiPluginModule & { id: string } = {
106
- id: PLUGIN_ID,
107
- tui,
108
- }
109
-
110
- export default plugin
175
+ export default { id: ID, tui } satisfies TuiPluginModule & { id: string }
package/dist/types.d.ts CHANGED
@@ -1,16 +1,5 @@
1
1
  import type { PluginInput } from "@opencode-ai/plugin";
2
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
3
  export interface PermissionEvent {
15
4
  id: string;
16
5
  sessionID: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-add-dir",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
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",
@@ -27,17 +27,15 @@
27
27
  },
28
28
  "files": [
29
29
  "dist/",
30
- "bin/ensure-tui.mjs",
31
30
  "bin/setup.mjs",
32
31
  "README.md",
33
32
  "LICENSE"
34
33
  ],
35
34
  "scripts": {
36
- "build": "bun run build:server && bun run build:tui && bun x tsc --emitDeclarationOnly",
37
- "build:server": "bun build ./src/index.ts --outdir ./dist --target node --format esm --external @opencode-ai/plugin",
35
+ "build": "rm -rf dist && bun run build:server && bun run build:tui && bun x tsc --emitDeclarationOnly",
36
+ "build:server": "bun build ./src/index.ts --outdir ./dist --target node --format esm --external @opencode-ai/plugin --minify",
38
37
  "build:tui": "cp ./src/tui-plugin.tsx ./dist/tui.tsx",
39
- "postinstall": "node ./bin/ensure-tui.mjs",
40
- "deploy": "bun run build:server && bun run build:tui",
38
+ "deploy": "rm -rf dist && bun run build:server && bun run build:tui",
41
39
  "test": "bun test",
42
40
  "typecheck": "bun x tsc --noEmit",
43
41
  "prepublishOnly": "bun run typecheck && bun test && bun run build"
@@ -1,39 +0,0 @@
1
- #!/usr/bin/env node
2
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"
3
- import { join } from "path"
4
- import { homedir } from "os"
5
-
6
- const PKG = "opencode-add-dir"
7
-
8
- try {
9
- const dir = join(
10
- process.env.XDG_CONFIG_HOME || join(homedir(), ".config"),
11
- "opencode",
12
- )
13
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
14
-
15
- let filePath = join(dir, "tui.json")
16
- for (const name of ["tui.jsonc", "tui.json"]) {
17
- const p = join(dir, name)
18
- if (existsSync(p)) { filePath = p; break }
19
- }
20
-
21
- let config = {}
22
- if (existsSync(filePath)) {
23
- const raw = readFileSync(filePath, "utf-8")
24
- config = JSON.parse(raw.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""))
25
- }
26
-
27
- const plugins = config.plugin || []
28
- const has = plugins.some((p) => {
29
- const name = Array.isArray(p) ? p[0] : p
30
- return name === PKG || (typeof name === "string" && name.startsWith(PKG + "@"))
31
- })
32
-
33
- if (!has) {
34
- config.plugin = [...plugins, PKG]
35
- writeFileSync(filePath, JSON.stringify(config, null, 2) + "\n")
36
- }
37
- } catch {
38
- // Non-critical — TUI dialog available after manual setup or restart
39
- }
@@ -1,8 +0,0 @@
1
- export type Result = {
2
- ok: true;
3
- absolutePath: string;
4
- } | {
5
- ok: false;
6
- reason: string;
7
- };
8
- export declare function validateDir(input: string, worktree: string, existing: string[]): Result;