opencode-add-dir 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/setup.mjs +30 -73
- 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 -6
- package/bin/ensure-tui.mjs +0 -39
package/bin/setup.mjs
CHANGED
|
@@ -4,98 +4,55 @@ import { join } from "path"
|
|
|
4
4
|
import { homedir } from "os"
|
|
5
5
|
|
|
6
6
|
const PKG = "opencode-add-dir"
|
|
7
|
-
const
|
|
8
|
-
const
|
|
7
|
+
const isRemove = process.argv.includes("--remove")
|
|
8
|
+
const dir = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode")
|
|
9
9
|
|
|
10
|
-
function
|
|
11
|
-
if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, "opencode")
|
|
12
|
-
return join(homedir(), ".config", "opencode")
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function stripJsonComments(text) {
|
|
16
|
-
let result = ""
|
|
17
|
-
let inString = false
|
|
18
|
-
let escape = false
|
|
19
|
-
for (let i = 0; i < text.length; i++) {
|
|
20
|
-
const ch = text[i]
|
|
21
|
-
if (escape) { result += ch; escape = false; continue }
|
|
22
|
-
if (ch === "\\" && inString) { result += ch; escape = true; continue }
|
|
23
|
-
if (ch === '"') { inString = !inString; result += ch; continue }
|
|
24
|
-
if (inString) { result += ch; continue }
|
|
25
|
-
if (ch === "/" && text[i + 1] === "/") { while (i < text.length && text[i] !== "\n") i++; continue }
|
|
26
|
-
if (ch === "/" && text[i + 1] === "*") { i += 2; while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++; i++; continue }
|
|
27
|
-
result += ch
|
|
28
|
-
}
|
|
29
|
-
return result
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function findConfigFile(dir, baseName) {
|
|
10
|
+
function findFile(base) {
|
|
33
11
|
for (const ext of [".jsonc", ".json"]) {
|
|
34
|
-
const p = join(dir,
|
|
12
|
+
const p = join(dir, base + ext)
|
|
35
13
|
if (existsSync(p)) return p
|
|
36
14
|
}
|
|
37
|
-
return join(dir,
|
|
15
|
+
return join(dir, base + ".json")
|
|
38
16
|
}
|
|
39
17
|
|
|
40
|
-
function readConfig(
|
|
41
|
-
if (!existsSync(
|
|
42
|
-
return JSON.parse(
|
|
18
|
+
function readConfig(path) {
|
|
19
|
+
if (!existsSync(path)) return {}
|
|
20
|
+
return JSON.parse(readFileSync(path, "utf-8").replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""))
|
|
43
21
|
}
|
|
44
22
|
|
|
45
23
|
function hasPlugin(plugins) {
|
|
46
|
-
return
|
|
47
|
-
const
|
|
48
|
-
return
|
|
49
|
-
})
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function withoutPlugin(plugins) {
|
|
53
|
-
return (plugins || []).filter((p) => {
|
|
54
|
-
const name = Array.isArray(p) ? p[0] : p
|
|
55
|
-
return name !== PKG && !name.startsWith(PKG + "@")
|
|
24
|
+
return plugins.some((p) => {
|
|
25
|
+
const n = Array.isArray(p) ? p[0] : p
|
|
26
|
+
return n === PKG || n.startsWith(PKG + "@")
|
|
56
27
|
})
|
|
57
28
|
}
|
|
58
29
|
|
|
59
|
-
function
|
|
60
|
-
config
|
|
30
|
+
function patch(path, schema) {
|
|
31
|
+
const config = readConfig(path)
|
|
32
|
+
config.plugin ??= []
|
|
61
33
|
|
|
62
34
|
if (isRemove) {
|
|
63
35
|
if (!hasPlugin(config.plugin)) return false
|
|
64
|
-
config.plugin =
|
|
65
|
-
|
|
66
|
-
|
|
36
|
+
config.plugin = config.plugin.filter((p) => {
|
|
37
|
+
const n = Array.isArray(p) ? p[0] : p
|
|
38
|
+
return n !== PKG && !n.startsWith(PKG + "@")
|
|
39
|
+
})
|
|
40
|
+
} else {
|
|
41
|
+
if (hasPlugin(config.plugin)) return false
|
|
42
|
+
config.plugin.push(PKG)
|
|
43
|
+
if (schema && !config.$schema) config.$schema = schema
|
|
67
44
|
}
|
|
68
45
|
|
|
69
|
-
|
|
70
|
-
config.plugin.push(PKG)
|
|
71
|
-
if (schemaUrl && !config.$schema) config.$schema = schemaUrl
|
|
72
|
-
writeFileSync(filePath, JSON.stringify(config, null, 2) + "\n")
|
|
46
|
+
writeFileSync(path, JSON.stringify(config, null, 2) + "\n")
|
|
73
47
|
return true
|
|
74
48
|
}
|
|
75
49
|
|
|
76
|
-
|
|
77
|
-
const dir = configDir()
|
|
78
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
50
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
79
51
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const verb = isRemove ? "Removed" : "Added"
|
|
87
|
-
const serverChanged = patchConfig(serverPath, serverConfig, "https://opencode.ai/config.json")
|
|
88
|
-
const tuiChanged = patchConfig(tuiPath, tuiConfig)
|
|
89
|
-
|
|
90
|
-
if (serverChanged) console.log(`${verb} ${PKG} in ${serverPath}`)
|
|
91
|
-
else console.log(`${PKG} already ${isRemove ? "absent from" : "in"} ${serverPath}`)
|
|
92
|
-
|
|
93
|
-
if (tuiChanged) console.log(`${verb} ${PKG} in ${tuiPath}`)
|
|
94
|
-
else console.log(`${PKG} already ${isRemove ? "absent from" : "in"} ${tuiPath}`)
|
|
95
|
-
|
|
96
|
-
if (serverChanged || tuiChanged) {
|
|
97
|
-
console.log("Restart OpenCode to activate the plugin.")
|
|
98
|
-
}
|
|
52
|
+
for (const [label, path, schema] of [
|
|
53
|
+
["server", findFile("opencode"), "https://opencode.ai/config.json"],
|
|
54
|
+
["tui", findFile("tui")],
|
|
55
|
+
]) {
|
|
56
|
+
if (patch(path, schema)) console.log(`${isRemove ? "Removed from" : "Added to"} ${label}: ${path}`)
|
|
57
|
+
else console.log(`${label}: already ${isRemove ? "absent" : "configured"}`)
|
|
99
58
|
}
|
|
100
|
-
|
|
101
|
-
run()
|
package/dist/index.js
CHANGED
|
@@ -1,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.0",
|
|
4
4
|
"description": "Add working directories to your OpenCode session with auto-approved permissions",
|
|
5
5
|
"author": "Cristian Fonseca <cfonsecacomas@gmail.com>",
|
|
6
6
|
"type": "module",
|
|
@@ -27,17 +27,15 @@
|
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
29
|
"dist/",
|
|
30
|
-
"bin/ensure-tui.mjs",
|
|
31
30
|
"bin/setup.mjs",
|
|
32
31
|
"README.md",
|
|
33
32
|
"LICENSE"
|
|
34
33
|
],
|
|
35
34
|
"scripts": {
|
|
36
|
-
"build": "bun run build:server && bun run build:tui && bun x tsc --emitDeclarationOnly",
|
|
37
|
-
"build:server": "bun build ./src/index.ts --outdir ./dist --target node --format esm --external @opencode-ai/plugin",
|
|
35
|
+
"build": "rm -rf dist && bun run build:server && bun run build:tui && bun x tsc --emitDeclarationOnly",
|
|
36
|
+
"build:server": "bun build ./src/index.ts --outdir ./dist --target node --format esm --external @opencode-ai/plugin --minify",
|
|
38
37
|
"build:tui": "cp ./src/tui-plugin.tsx ./dist/tui.tsx",
|
|
39
|
-
"
|
|
40
|
-
"deploy": "bun run build:server && bun run build:tui",
|
|
38
|
+
"deploy": "rm -rf dist && bun run build:server && bun run build:tui",
|
|
41
39
|
"test": "bun test",
|
|
42
40
|
"typecheck": "bun x tsc --noEmit",
|
|
43
41
|
"prepublishOnly": "bun run typecheck && bun test && bun run build"
|
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
|
-
}
|