opencode-add-dir 1.4.0 → 1.6.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
@@ -0,0 +1,39 @@
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/dist/index.js CHANGED
@@ -1,15 +1,16 @@
1
1
  // src/state.ts
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "fs";
3
3
  import { join } from "path";
4
4
  function stateDir() {
5
5
  return join(process.env["XDG_DATA_HOME"] || join(process.env["HOME"] || "~", ".local", "share"), "opencode", "add-dir");
6
6
  }
7
+ var dirsFile = () => join(stateDir(), "directories.json");
7
8
  function expandHome(p) {
8
9
  return p.startsWith("~/") ? (process.env["HOME"] || "~") + p.slice(1) : p;
9
10
  }
10
11
  function loadDirs() {
11
12
  const dirs = new Map;
12
- const file = join(stateDir(), "directories.json");
13
+ const file = dirsFile();
13
14
  if (!existsSync(file))
14
15
  return dirs;
15
16
  try {
@@ -18,12 +19,22 @@ function loadDirs() {
18
19
  } catch {}
19
20
  return dirs;
20
21
  }
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));
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
+ }
27
38
  }
28
39
  function isChildOf(parent, child) {
29
40
  return child === parent || child.startsWith(parent + "/");
@@ -113,49 +124,13 @@ function ensureTuiConfig() {
113
124
  } catch {}
114
125
  }
115
126
 
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
127
  // src/permissions.ts
142
- import { join as join2, resolve as resolve2 } from "path";
128
+ import { join as join2, resolve } from "path";
143
129
  var FILE_TOOLS = new Set(["read", "write", "edit", "apply_patch", "multiedit", "glob", "grep", "list", "bash"]);
144
130
  var grantedSessions = new Set;
145
131
  function permissionGlob(dirPath) {
146
132
  return join2(dirPath, "*");
147
133
  }
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
134
  async function grantSession(sdk, sessionID, text) {
160
135
  if (grantedSessions.has(sessionID))
161
136
  return;
@@ -167,7 +142,7 @@ function shouldGrantBeforeTool(dirs, tool, args) {
167
142
  if (!dirs.size || !FILE_TOOLS.has(tool))
168
143
  return false;
169
144
  const p = extractPath(tool, args);
170
- return !!p && matchesDirs(dirs, resolve2(expandHome(p)));
145
+ return !!p && matchesDirs(dirs, resolve(expandHome(p)));
171
146
  }
172
147
  async function autoApprovePermission(sdk, props, dirs) {
173
148
  if (props.permission !== "external_directory")
@@ -214,61 +189,12 @@ ${content}`);
214
189
  }
215
190
 
216
191
  // src/plugin.ts
217
- var SENTINEL = Object.assign(new Error("__ADD_DIR_HANDLED__"), { stack: "" });
218
- var AddDirPlugin = async ({ client, worktree, directory }) => {
219
- const root = worktree || directory;
220
- const dirs = loadDirs();
192
+ ensureTuiConfig();
193
+ var AddDirPlugin = async ({ client }) => {
221
194
  const sdk = client;
222
- ensureTuiConfig();
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
- "add-dir": (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
195
  return {
266
196
  config: async (cfg) => {
267
- cfg.command ??= {};
268
- const cmd = cfg.command;
269
- cmd["add-dir"] = { template: "/add-dir", description: "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" };
197
+ const dirs = freshDirs();
272
198
  if (!dirs.size)
273
199
  return;
274
200
  const perm = cfg.permission ??= {};
@@ -276,23 +202,20 @@ var AddDirPlugin = async ({ client, worktree, directory }) => {
276
202
  for (const entry of dirs.values())
277
203
  extDir[permissionGlob(entry.path)] = "allow";
278
204
  },
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
205
  "tool.execute.before": async (input, output) => {
206
+ const dirs = freshDirs();
287
207
  if (shouldGrantBeforeTool(dirs, input.tool, output.args))
288
208
  await grantSession(sdk, input.sessionID, "Directory access granted by add-dir plugin.");
289
209
  },
290
210
  event: async ({ event }) => {
291
211
  const e = event;
292
- if (e.type === "permission.asked" && e.properties)
212
+ if (e.type === "permission.asked" && e.properties) {
213
+ const dirs = freshDirs();
293
214
  await autoApprovePermission(sdk, e.properties, dirs);
215
+ }
294
216
  },
295
217
  "experimental.chat.system.transform": async (_input, output) => {
218
+ const dirs = freshDirs();
296
219
  output.system.push(...collectAgentContext(dirs));
297
220
  }
298
221
  };
@@ -1,8 +1,7 @@
1
1
  import type { DirEntry } from "./state.js";
2
2
  import type { SDK, PermissionEvent, ToolArgs } from "./types.js";
3
3
  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
4
  export declare function grantSession(sdk: SDK, sessionID: string, text: string): Promise<void>;
7
5
  export declare function shouldGrantBeforeTool(dirs: Map<string, DirEntry>, tool: string, args: ToolArgs): boolean;
8
6
  export declare function autoApprovePermission(sdk: SDK, props: PermissionEvent, dirs: Map<string, DirEntry>): Promise<void>;
7
+ 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,158 @@
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 COMMAND_NAME = "add-dir"
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")
6
9
 
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
10
+ function readDirs(): string[] {
11
+ try { return JSON.parse(readFileSync(DIRS_FILE(), "utf-8")) } catch { return [] }
11
12
  }
12
13
 
13
- async function ensureSession(api: TuiPluginApi): Promise<string | undefined> {
14
- const existing = activeSessionID(api)
15
- if (existing) return existing
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))
18
+ }
19
+
20
+ function resolvePath(input: string) {
21
+ const p = input.trim()
22
+ return resolve(p.startsWith("~/") ? (process.env["HOME"] || "~") + p.slice(1) : p)
23
+ }
24
+
25
+ function validate(input: string): string | undefined {
26
+ if (!input.trim()) return "No directory path provided."
27
+ 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.`
31
+ }
32
+
33
+ function sessionID(api: TuiPluginApi): string | undefined {
34
+ const r = api.route.current
35
+ return r.name === "session" && r.params ? r.params.sessionID as string : undefined
36
+ }
16
37
 
38
+ async function withSession(api: TuiPluginApi): Promise<string | undefined> {
39
+ const id = sessionID(api)
40
+ if (id) return id
17
41
  const res = await api.client.session.create({})
18
42
  if (res.error) return
19
-
20
43
  api.route.navigate("session", { sessionID: res.data.id })
21
44
  return res.data.id
22
45
  }
23
46
 
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
29
- }
30
-
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: COMMAND_NAME,
37
- arguments: dirPath,
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 },
38
58
  }).catch(() => {})
39
59
  }
40
60
 
41
61
  function AddDirDialog(props: { api: TuiPluginApi }) {
42
62
  const [busy, setBusy] = createSignal(false)
63
+ const [remember, setRemember] = createSignal(false)
64
+ const { api } = props
43
65
 
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
- }
66
+ useKeyboard((e) => {
67
+ if (e.name !== "tab" || busy()) return
68
+ e.preventDefault(); e.stopPropagation()
69
+ setRemember((v) => !v)
70
+ })
59
71
 
60
72
  return (
61
- <props.api.ui.DialogPrompt
73
+ <api.ui.DialogPrompt
62
74
  title="Add directory"
63
75
  placeholder="/path/to/directory"
64
76
  busy={busy()}
65
77
  busyText="Adding directory..."
66
78
  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>
79
+ <box gap={1}>
80
+ <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>
84
+ <text fg={api.theme.current.textMuted}> 3. Paste below</text>
85
+ </box>
86
+ <box flexDirection="row" gap={1}>
87
+ <text fg={remember() ? api.theme.current.text : api.theme.current.textMuted}>
88
+ {remember() ? "[x]" : "[ ]"} Remember across sessions
89
+ </text>
90
+ <text fg={api.theme.current.textMuted}>(tab toggle)</text>
91
+ </box>
80
92
  </box>
81
93
  )}
82
- onConfirm={handleConfirm}
83
- onCancel={() => props.api.ui.dialog.clear()}
94
+ onConfirm={async (value) => {
95
+ const err = validate(value)
96
+ if (err) return api.ui.toast({ variant: "error", message: err })
97
+
98
+ 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) }
107
+ }}
108
+ onCancel={() => api.ui.dialog.clear()}
84
109
  />
85
110
  )
86
111
  }
87
112
 
88
- function showDialog(api: TuiPluginApi) {
89
- api.ui.dialog.replace(() => <AddDirDialog api={api} />)
113
+ function listDirs(api: TuiPluginApi) {
114
+ const dirs = readDirs()
115
+ if (!dirs.length) return api.ui.toast({ variant: "info", message: "No directories added." })
116
+ api.ui.dialog.replace(() => (
117
+ <api.ui.DialogAlert
118
+ title={`Directories (${dirs.length})`}
119
+ message={dirs.join("\n")}
120
+ onConfirm={() => api.ui.dialog.clear()}
121
+ />
122
+ ))
123
+ }
124
+
125
+ function removeDir(api: TuiPluginApi) {
126
+ const dirs = readDirs()
127
+ if (!dirs.length) return api.ui.toast({ variant: "info", message: "No directories to remove." })
128
+ api.ui.dialog.replace(() => (
129
+ <api.ui.DialogSelect
130
+ title="Remove directory"
131
+ options={dirs.map((d) => ({ title: d, value: d }))}
132
+ onSelect={(opt) => {
133
+ api.ui.dialog.replace(() => (
134
+ <api.ui.DialogConfirm
135
+ title="Remove directory"
136
+ message={`Remove ${opt.value}?`}
137
+ onConfirm={() => {
138
+ writeDirs(readDirs().filter((d) => d !== opt.value))
139
+ api.ui.toast({ variant: "success", message: `Removed ${opt.value}` })
140
+ api.ui.dialog.clear()
141
+ }}
142
+ onCancel={() => removeDir(api)}
143
+ />
144
+ ))
145
+ }}
146
+ />
147
+ ))
90
148
  }
91
149
 
92
150
  const tui: TuiPlugin = async (api) => {
93
151
  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: COMMAND_NAME },
100
- onSelect: () => showDialog(api),
101
- },
152
+ { 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) },
102
155
  ])
103
156
  }
104
157
 
105
- const plugin: TuiPluginModule & { id: string } = {
106
- id: PLUGIN_ID,
107
- tui,
108
- }
109
-
110
- export default plugin
158
+ export default { id: ID, tui } satisfies TuiPluginModule & { id: string }
package/dist/types.d.ts CHANGED
@@ -8,9 +8,6 @@ export interface PromptBody {
8
8
  text: string;
9
9
  }>;
10
10
  }
11
- export interface PermissionReplyBody {
12
- response: "once" | "always" | "reject";
13
- }
14
11
  export interface PermissionEvent {
15
12
  id: string;
16
13
  sessionID: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-add-dir",
3
- "version": "1.4.0",
3
+ "version": "1.6.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,7 +27,8 @@
27
27
  },
28
28
  "files": [
29
29
  "dist/",
30
- "bin/",
30
+ "bin/ensure-tui.mjs",
31
+ "bin/setup.mjs",
31
32
  "README.md",
32
33
  "LICENSE"
33
34
  ],
@@ -35,6 +36,7 @@
35
36
  "build": "bun run build:server && bun run build:tui && bun x tsc --emitDeclarationOnly",
36
37
  "build:server": "bun build ./src/index.ts --outdir ./dist --target node --format esm --external @opencode-ai/plugin",
37
38
  "build:tui": "cp ./src/tui-plugin.tsx ./dist/tui.tsx",
39
+ "postinstall": "node ./bin/ensure-tui.mjs",
38
40
  "deploy": "bun run build:server && bun run build:tui",
39
41
  "test": "bun test",
40
42
  "typecheck": "bun x tsc --noEmit",
@@ -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;