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 +2 -21
- package/dist/index.js +6 -231
- package/dist/permissions.d.ts +2 -1
- package/dist/tui.tsx +68 -51
- package/dist/types.d.ts +0 -8
- package/package.json +4 -10
- package/bin/ensure-tui.mjs +0 -39
- package/bin/setup.mjs +0 -101
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
|
|
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 #
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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};
|
package/dist/permissions.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
11
|
-
try { return JSON.parse(readFileSync(
|
|
12
|
+
function readJsonArray(file: string): string[] {
|
|
13
|
+
try { return JSON.parse(readFileSync(file, "utf-8")) } catch { return [] }
|
|
12
14
|
}
|
|
13
15
|
|
|
14
|
-
function
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 "
|
|
31
|
+
if (!input.trim()) return "Path is required."
|
|
27
32
|
const abs = resolvePath(input)
|
|
28
|
-
try { if (!statSync(abs).isDirectory()) return
|
|
29
|
-
catch { return `
|
|
30
|
-
if (
|
|
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
|
|
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
|
|
39
|
-
const id =
|
|
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()
|
|
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
|
|
82
|
+
busyText="Adding..."
|
|
78
83
|
description={() => (
|
|
79
84
|
<box gap={1}>
|
|
80
85
|
<box gap={0}>
|
|
81
|
-
<text fg={api.theme.current.textMuted}>
|
|
82
|
-
<text fg={api.theme.current.textMuted}> 1.
|
|
83
|
-
<text fg={api.theme.current.textMuted}> 2. Run "pwd"
|
|
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
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
114
|
-
const dirs =
|
|
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
|
|
126
|
-
const dirs =
|
|
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
|
-
|
|
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={() =>
|
|
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: () =>
|
|
154
|
-
{ title: "Remove directory", value: "remove-dir", description: "Remove a working directory", category: "Directories", slash: { name: "remove-dir" }, onSelect: () =>
|
|
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.
|
|
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
|
-
"
|
|
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"
|
package/bin/ensure-tui.mjs
DELETED
|
@@ -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()
|