opencode-add-dir 1.6.0 → 1.7.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
@@ -12,15 +12,6 @@ opencode plugin opencode-add-dir -g
12
12
 
13
13
  Restart OpenCode. Done.
14
14
 
15
- <details>
16
- <summary>Alternative: setup CLI</summary>
17
-
18
- ```bash
19
- npx opencode-add-dir-setup
20
- ```
21
-
22
- </details>
23
-
24
15
  <details>
25
16
  <summary>Alternative: local development</summary>
26
17
 
@@ -69,11 +60,7 @@ Runs in the background — no commands, only hooks:
69
60
  | `config` | Injects `external_directory: "allow"` permission rules for persisted dirs at startup |
70
61
  | `tool.execute.before` | Auto-grants permissions when subagents access added directories |
71
62
  | `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 |
73
-
74
- ### Context Injection
75
-
76
- If an added directory contains `AGENTS.md`, `CLAUDE.md`, or `.agents/AGENTS.md`, the content is automatically injected into the system prompt.
63
+ | `system.transform` | Injects added directory paths into the system prompt so the LLM knows about them |
77
64
 
78
65
  ## Development
79
66
 
@@ -93,17 +80,11 @@ src/
93
80
  ├── plugin.ts # Server hooks (permissions, context injection)
94
81
  ├── tui-plugin.tsx # TUI plugin (dialogs for add/list/remove)
95
82
  ├── state.ts # Persistence, path utils, tui.json auto-config
96
- ├── validate.ts # Directory validation
97
83
  ├── permissions.ts # Session grants + auto-approve
98
- ├── context.ts # AGENTS.md injection
84
+ ├── context.ts # System prompt injection
99
85
  └── types.ts # Shared type definitions
100
86
  ```
101
87
 
102
- ## Limitations
103
-
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.
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.
106
-
107
88
  ## License
108
89
 
109
90
  [MIT](LICENSE)
package/dist/index.js CHANGED
@@ -1,232 +1,7 @@
1
- // src/state.ts
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } 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
- var dirsFile = () => join(stateDir(), "directories.json");
8
- function expandHome(p) {
9
- return p.startsWith("~/") ? (process.env["HOME"] || "~") + p.slice(1) : p;
10
- }
11
- function loadDirs() {
12
- const dirs = new Map;
13
- const file = dirsFile();
14
- if (!existsSync(file))
15
- return dirs;
16
- try {
17
- for (const p of JSON.parse(readFileSync(file, "utf-8")))
18
- dirs.set(p, { path: p, persist: true });
19
- } catch {}
20
- return dirs;
21
- }
22
- var cachedDirs;
23
- var cachedMtime = 0;
24
- function freshDirs() {
25
- const file = dirsFile();
26
- try {
27
- const mtime = statSync(file).mtimeMs;
28
- if (cachedDirs && mtime === cachedMtime)
29
- return cachedDirs;
30
- cachedMtime = mtime;
31
- cachedDirs = loadDirs();
32
- return cachedDirs;
33
- } catch {
34
- if (!cachedDirs)
35
- cachedDirs = new Map;
36
- return cachedDirs;
37
- }
38
- }
39
- function isChildOf(parent, child) {
40
- return child === parent || child.startsWith(parent + "/");
41
- }
42
- function matchesDirs(dirs, filepath) {
43
- for (const entry of dirs.values()) {
44
- if (isChildOf(entry.path, filepath))
45
- return true;
46
- }
47
- return false;
48
- }
49
- var PKG = "opencode-add-dir";
50
- function stripJsonComments(text) {
51
- let result = "";
52
- let inString = false;
53
- let escape = false;
54
- for (let i = 0;i < text.length; i++) {
55
- const ch = text[i];
56
- if (escape) {
57
- result += ch;
58
- escape = false;
59
- continue;
60
- }
61
- if (ch === "\\" && inString) {
62
- result += ch;
63
- escape = true;
64
- continue;
65
- }
66
- if (ch === '"') {
67
- inString = !inString;
68
- result += ch;
69
- continue;
70
- }
71
- if (inString) {
72
- result += ch;
73
- continue;
74
- }
75
- if (ch === "/" && text[i + 1] === "/") {
76
- while (i < text.length && text[i] !== `
77
- `)
78
- i++;
79
- continue;
80
- }
81
- if (ch === "/" && text[i + 1] === "*") {
82
- i += 2;
83
- while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
84
- i++;
85
- i++;
86
- continue;
87
- }
88
- result += ch;
89
- }
90
- return result;
91
- }
92
- function configDir() {
93
- return join(process.env["XDG_CONFIG_HOME"] || join(process.env["HOME"] || "~", ".config"), "opencode");
94
- }
95
- function findTuiConfig() {
96
- const dir = configDir();
97
- for (const name of ["tui.jsonc", "tui.json"]) {
98
- const p = join(dir, name);
99
- if (existsSync(p))
100
- return p;
101
- }
102
- return join(dir, "tui.json");
103
- }
104
- function ensureTuiConfig() {
105
- try {
106
- const dir = configDir();
107
- if (!existsSync(dir))
108
- mkdirSync(dir, { recursive: true });
109
- const filePath = findTuiConfig();
110
- let config = {};
111
- if (existsSync(filePath)) {
112
- config = JSON.parse(stripJsonComments(readFileSync(filePath, "utf-8")));
113
- }
114
- const plugins = config.plugin ?? [];
115
- const hasEntry = plugins.some((p) => {
116
- const name = Array.isArray(p) ? p[0] : p;
117
- return name === PKG || typeof name === "string" && name.startsWith(PKG + "@");
118
- });
119
- if (hasEntry)
120
- return;
121
- config.plugin = [...plugins, PKG];
122
- writeFileSync(filePath, JSON.stringify(config, null, 2) + `
123
- `);
124
- } catch {}
125
- }
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}
126
6
 
127
- // src/permissions.ts
128
- import { join as join2, resolve } from "path";
129
- var FILE_TOOLS = new Set(["read", "write", "edit", "apply_patch", "multiedit", "glob", "grep", "list", "bash"]);
130
- var grantedSessions = new Set;
131
- function permissionGlob(dirPath) {
132
- return join2(dirPath, "*");
133
- }
134
- async function grantSession(sdk, sessionID, text) {
135
- if (grantedSessions.has(sessionID))
136
- return;
137
- grantedSessions.add(sessionID);
138
- const body = { noReply: true, tools: { external_directory: true }, parts: [{ type: "text", text }] };
139
- await sdk.session.prompt({ path: { id: sessionID }, body }).catch(() => {});
140
- }
141
- function shouldGrantBeforeTool(dirs, tool, args) {
142
- if (!dirs.size || !FILE_TOOLS.has(tool))
143
- return false;
144
- const p = extractPath(tool, args);
145
- return !!p && matchesDirs(dirs, resolve(expandHome(p)));
146
- }
147
- async function autoApprovePermission(sdk, props, dirs) {
148
- if (props.permission !== "external_directory")
149
- return;
150
- const meta = props.metadata;
151
- const filepath = meta.filepath ?? "";
152
- const parentDir = meta.parentDir ?? "";
153
- const patterns = props.patterns ?? [];
154
- const matches = matchesDirs(dirs, filepath) || matchesDirs(dirs, parentDir) || patterns.some((p) => matchesDirs(dirs, p.replace(/\/?\*$/, "")));
155
- if (!matches || !props.id || !props.sessionID)
156
- return;
157
- await sdk.postSessionIdPermissionsPermissionId({
158
- path: { id: props.sessionID, permissionID: props.id },
159
- body: { response: "always" }
160
- }).catch(() => {});
161
- }
162
- function extractPath(tool, args) {
163
- if (!args)
164
- return "";
165
- if (tool === "bash")
166
- return args.workdir || args.command || "";
167
- return args.filePath || args.path || args.pattern || "";
168
- }
169
-
170
- // src/context.ts
171
- import { readFileSync as readFileSync2 } from "fs";
172
- import { join as join3 } from "path";
173
- var CONTEXT_FILES = ["AGENTS.md", "CLAUDE.md", ".agents/AGENTS.md"];
174
- function collectAgentContext(dirs) {
175
- const sections = [];
176
- for (const entry of dirs.values()) {
177
- for (const name of CONTEXT_FILES) {
178
- const fp = join3(entry.path, name);
179
- try {
180
- const content = readFileSync2(fp, "utf-8").trim();
181
- if (content)
182
- sections.push(`# Context from ${fp}
183
-
184
- ${content}`);
185
- } catch {}
186
- }
187
- }
188
- return sections;
189
- }
190
-
191
- // src/plugin.ts
192
- ensureTuiConfig();
193
- var AddDirPlugin = async ({ client }) => {
194
- const sdk = client;
195
- return {
196
- config: async (cfg) => {
197
- const dirs = freshDirs();
198
- if (!dirs.size)
199
- return;
200
- const perm = cfg.permission ??= {};
201
- const extDir = perm.external_directory ??= {};
202
- for (const entry of dirs.values())
203
- extDir[permissionGlob(entry.path)] = "allow";
204
- },
205
- "tool.execute.before": async (input, output) => {
206
- const dirs = freshDirs();
207
- if (shouldGrantBeforeTool(dirs, input.tool, output.args))
208
- await grantSession(sdk, input.sessionID, "Directory access granted by add-dir plugin.");
209
- },
210
- event: async ({ event }) => {
211
- const e = event;
212
- if (e.type === "permission.asked" && e.properties) {
213
- const dirs = freshDirs();
214
- await autoApprovePermission(sdk, e.properties, dirs);
215
- }
216
- },
217
- "experimental.chat.system.transform": async (_input, output) => {
218
- const dirs = freshDirs();
219
- output.system.push(...collectAgentContext(dirs));
220
- }
221
- };
222
- };
223
-
224
- // src/index.ts
225
- var plugin = {
226
- id: "opencode-add-dir",
227
- server: AddDirPlugin
228
- };
229
- var src_default = plugin;
230
- export {
231
- src_default as default
232
- };
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,7 +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 grantSession(sdk: SDK, sessionID: string, text: string): Promise<void>;
5
+ export declare function grantSession(sdk: SDK, sessionID: string): Promise<void>;
5
6
  export declare function shouldGrantBeforeTool(dirs: Map<string, DirEntry>, tool: string, args: ToolArgs): boolean;
6
7
  export declare function autoApprovePermission(sdk: SDK, props: PermissionEvent, dirs: Map<string, DirEntry>): Promise<void>;
7
8
  export declare function extractPath(tool: string, args: ToolArgs): string;
package/dist/tui.tsx CHANGED
@@ -5,16 +5,21 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "fs
5
5
  import { join, resolve } from "path"
6
6
 
7
7
  const ID = "opencode-add-dir"
8
- const DIRS_FILE = () => join(process.env["XDG_DATA_HOME"] || join(process.env["HOME"] || "~", ".local", "share"), "opencode", "add-dir", "directories.json")
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")
9
11
 
10
- function readDirs(): string[] {
11
- try { return JSON.parse(readFileSync(DIRS_FILE(), "utf-8")) } catch { return [] }
12
+ function readJsonArray(file: string): string[] {
13
+ try { return JSON.parse(readFileSync(file, "utf-8")) } catch { return [] }
12
14
  }
13
15
 
14
- function writeDirs(dirs: string[]) {
15
- const dir = join(DIRS_FILE(), "..")
16
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
17
- writeFileSync(DIRS_FILE(), JSON.stringify(dirs, null, 2))
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
+ }
20
+
21
+ function allDirs(): string[] {
22
+ return [...new Set([...readJsonArray(PERSISTED_FILE), ...readJsonArray(SESSION_FILE)])]
18
23
  }
19
24
 
20
25
  function resolvePath(input: string) {
@@ -23,20 +28,33 @@ function resolvePath(input: string) {
23
28
  }
24
29
 
25
30
  function validate(input: string): string | undefined {
26
- if (!input.trim()) return "No directory path provided."
31
+ if (!input.trim()) return "Path is required."
27
32
  const abs = resolvePath(input)
28
- try { if (!statSync(abs).isDirectory()) return `${abs} is not a directory.` }
29
- catch { return `Path ${abs} was not found.` }
30
- if (readDirs().includes(abs)) return `${abs} is already added.`
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))
48
+ }
31
49
  }
32
50
 
33
- function sessionID(api: TuiPluginApi): string | undefined {
51
+ function getSessionID(api: TuiPluginApi): string | undefined {
34
52
  const r = api.route.current
35
53
  return r.name === "session" && r.params ? r.params.sessionID as string : undefined
36
54
  }
37
55
 
38
- async function withSession(api: TuiPluginApi): Promise<string | undefined> {
39
- const id = sessionID(api)
56
+ async function ensureSession(api: TuiPluginApi): Promise<string | undefined> {
57
+ const id = getSessionID(api)
40
58
  if (id) return id
41
59
  const res = await api.client.session.create({})
42
60
  if (res.error) return
@@ -44,20 +62,6 @@ async function withSession(api: TuiPluginApi): Promise<string | undefined> {
44
62
  return res.data.id
45
63
  }
46
64
 
47
- type PromptAsyncFn = (params: {
48
- sessionID: string
49
- parts: { type: "text"; text: string }[]
50
- noReply: boolean
51
- tools: Record<string, boolean>
52
- }) => Promise<unknown>
53
-
54
- async function grant(api: TuiPluginApi, sid: string, msg: string) {
55
- const promptAsync = (api.client.session as unknown as { promptAsync: PromptAsyncFn }).promptAsync
56
- await promptAsync({
57
- sessionID: sid, parts: [{ type: "text", text: msg }], noReply: true, tools: { external_directory: true },
58
- }).catch(() => {})
59
- }
60
-
61
65
  function AddDirDialog(props: { api: TuiPluginApi }) {
62
66
  const [busy, setBusy] = createSignal(false)
63
67
  const [remember, setRemember] = createSignal(false)
@@ -65,7 +69,8 @@ function AddDirDialog(props: { api: TuiPluginApi }) {
65
69
 
66
70
  useKeyboard((e) => {
67
71
  if (e.name !== "tab" || busy()) return
68
- e.preventDefault(); e.stopPropagation()
72
+ e.preventDefault()
73
+ e.stopPropagation()
69
74
  setRemember((v) => !v)
70
75
  })
71
76
 
@@ -74,44 +79,56 @@ function AddDirDialog(props: { api: TuiPluginApi }) {
74
79
  title="Add directory"
75
80
  placeholder="/path/to/directory"
76
81
  busy={busy()}
77
- busyText="Adding directory..."
82
+ busyText="Adding..."
78
83
  description={() => (
79
84
  <box gap={1}>
80
85
  <box gap={0}>
81
- <text fg={api.theme.current.textMuted}>To get the full path of a project:</text>
82
- <text fg={api.theme.current.textMuted}> 1. Move to the project in your terminal</text>
83
- <text fg={api.theme.current.textMuted}> 2. Run "pwd" and copy the output</text>
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>
84
89
  <text fg={api.theme.current.textMuted}> 3. Paste below</text>
85
90
  </box>
86
91
  <box flexDirection="row" gap={1}>
87
92
  <text fg={remember() ? api.theme.current.text : api.theme.current.textMuted}>
88
93
  {remember() ? "[x]" : "[ ]"} Remember across sessions
89
94
  </text>
90
- <text fg={api.theme.current.textMuted}>(tab toggle)</text>
95
+ <text fg={api.theme.current.textMuted}>(tab)</text>
91
96
  </box>
92
97
  </box>
93
98
  )}
94
99
  onConfirm={async (value) => {
100
+ if (busy()) return
95
101
  const err = validate(value)
96
102
  if (err) return api.ui.toast({ variant: "error", message: err })
97
103
 
104
+ const abs = resolvePath(value)
105
+ const persist = remember()
106
+
98
107
  setBusy(true)
99
- try {
100
- const sid = await withSession(api)
101
- if (!sid) return api.ui.toast({ variant: "error", message: "Failed to create session" })
102
- const abs = resolvePath(value)
103
- if (remember()) { const d = readDirs(); if (!d.includes(abs)) writeDirs([...d, abs]) }
104
- await grant(api, sid, `Added ${abs} as a working directory (${remember() ? "persistent" : "session"}).`)
105
- api.ui.dialog.clear()
106
- } finally { setBusy(false) }
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(() => {})
107
124
  }}
108
125
  onCancel={() => api.ui.dialog.clear()}
109
126
  />
110
127
  )
111
128
  }
112
129
 
113
- function listDirs(api: TuiPluginApi) {
114
- const dirs = readDirs()
130
+ function showListDirs(api: TuiPluginApi) {
131
+ const dirs = allDirs()
115
132
  if (!dirs.length) return api.ui.toast({ variant: "info", message: "No directories added." })
116
133
  api.ui.dialog.replace(() => (
117
134
  <api.ui.DialogAlert
@@ -122,8 +139,8 @@ function listDirs(api: TuiPluginApi) {
122
139
  ))
123
140
  }
124
141
 
125
- function removeDir(api: TuiPluginApi) {
126
- const dirs = readDirs()
142
+ function showRemoveDir(api: TuiPluginApi) {
143
+ const dirs = allDirs()
127
144
  if (!dirs.length) return api.ui.toast({ variant: "info", message: "No directories to remove." })
128
145
  api.ui.dialog.replace(() => (
129
146
  <api.ui.DialogSelect
@@ -135,11 +152,11 @@ function removeDir(api: TuiPluginApi) {
135
152
  title="Remove directory"
136
153
  message={`Remove ${opt.value}?`}
137
154
  onConfirm={() => {
138
- writeDirs(readDirs().filter((d) => d !== opt.value))
139
- api.ui.toast({ variant: "success", message: `Removed ${opt.value}` })
155
+ removeDir(opt.value as string)
140
156
  api.ui.dialog.clear()
157
+ api.ui.toast({ variant: "success", message: `Removed ${opt.value}` })
141
158
  }}
142
- onCancel={() => removeDir(api)}
159
+ onCancel={() => showRemoveDir(api)}
143
160
  />
144
161
  ))
145
162
  }}
@@ -150,8 +167,8 @@ function removeDir(api: TuiPluginApi) {
150
167
  const tui: TuiPlugin = async (api) => {
151
168
  api.command.register(() => [
152
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} />) },
153
- { title: "List directories", value: "list-dir", description: "Show working directories", category: "Directories", slash: { name: "list-dir" }, onSelect: () => listDirs(api) },
154
- { title: "Remove directory", value: "remove-dir", description: "Remove a working directory", category: "Directories", slash: { name: "remove-dir" }, onSelect: () => removeDir(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) },
155
172
  ])
156
173
  }
157
174
 
package/dist/types.d.ts CHANGED
@@ -1,13 +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
3
  export interface PermissionEvent {
12
4
  id: string;
13
5
  sessionID: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-add-dir",
3
- "version": "1.6.0",
3
+ "version": "1.7.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",
@@ -22,22 +22,16 @@
22
22
  "server",
23
23
  "tui"
24
24
  ],
25
- "bin": {
26
- "opencode-add-dir-setup": "./bin/setup.mjs"
27
- },
28
25
  "files": [
29
26
  "dist/",
30
- "bin/ensure-tui.mjs",
31
- "bin/setup.mjs",
32
27
  "README.md",
33
28
  "LICENSE"
34
29
  ],
35
30
  "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",
31
+ "build": "rm -rf dist && bun run build:server && bun run build:tui && bun x tsc --emitDeclarationOnly",
32
+ "build:server": "bun build ./src/index.ts --outdir ./dist --target node --format esm --external @opencode-ai/plugin --minify",
38
33
  "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",
34
+ "deploy": "rm -rf dist && bun run build:server && bun run build:tui",
41
35
  "test": "bun test",
42
36
  "typecheck": "bun x tsc --noEmit",
43
37
  "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
- }
package/bin/setup.mjs DELETED
@@ -1,101 +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
- const args = process.argv.slice(2)
8
- const isRemove = args.includes("--remove")
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) {
33
- for (const ext of [".jsonc", ".json"]) {
34
- const p = join(dir, baseName + ext)
35
- if (existsSync(p)) return p
36
- }
37
- return join(dir, baseName + ".json")
38
- }
39
-
40
- function readConfig(filePath) {
41
- if (!existsSync(filePath)) return {}
42
- return JSON.parse(stripJsonComments(readFileSync(filePath, "utf-8")))
43
- }
44
-
45
- 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 + "@")
56
- })
57
- }
58
-
59
- function patchConfig(filePath, config, schemaUrl) {
60
- config.plugin = config.plugin || []
61
-
62
- if (isRemove) {
63
- if (!hasPlugin(config.plugin)) return false
64
- config.plugin = withoutPlugin(config.plugin)
65
- writeFileSync(filePath, JSON.stringify(config, null, 2) + "\n")
66
- return true
67
- }
68
-
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")
73
- return true
74
- }
75
-
76
- function run() {
77
- const dir = configDir()
78
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
79
-
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
- }
99
- }
100
-
101
- run()