opencode-tui-utils 1.1.0 → 1.2.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/package.json +1 -1
- package/src/core/api-wrapper.ts +1 -0
- package/src/index.tsx +2 -1
- package/src/plugins/permissions.tsx +398 -0
package/package.json
CHANGED
package/src/core/api-wrapper.ts
CHANGED
package/src/index.tsx
CHANGED
|
@@ -9,8 +9,9 @@ import pluginListPlugin from "./plugins/plugin-list"
|
|
|
9
9
|
import exportChatPlugin from "./plugins/export-chat"
|
|
10
10
|
import sessionDiffPlugin from "./plugins/session-diff"
|
|
11
11
|
import sessionTodosPlugin from "./plugins/session-todos"
|
|
12
|
+
import permissionsPlugin from "./plugins/permissions"
|
|
12
13
|
|
|
13
|
-
const plugins: TuiPluginModule[] = [disconnectPlugin, lspTogglePlugin, pluginListPlugin, exportChatPlugin, sessionDiffPlugin, sessionTodosPlugin]
|
|
14
|
+
const plugins: TuiPluginModule[] = [disconnectPlugin, lspTogglePlugin, pluginListPlugin, exportChatPlugin, sessionDiffPlugin, sessionTodosPlugin, permissionsPlugin]
|
|
14
15
|
|
|
15
16
|
export async function initializePlugins(...args: Parameters<TuiPluginModule["tui"]>) {
|
|
16
17
|
for (const plugin of plugins) {
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
/**
|
|
3
|
+
* /permissions
|
|
4
|
+
*
|
|
5
|
+
* Manage opencode permission settings via TUI instead of hand-editing
|
|
6
|
+
* ~/.config/opencode/opencode.json.
|
|
7
|
+
*
|
|
8
|
+
* Supports:
|
|
9
|
+
* - View current permission config
|
|
10
|
+
* - Set global permission mode (ask / allow / deny)
|
|
11
|
+
* - Toggle per-tool permission
|
|
12
|
+
* - Manage pattern-based permissions (e.g. external_directory)
|
|
13
|
+
* - Reset (remove) permission config
|
|
14
|
+
*/
|
|
15
|
+
import type { TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
16
|
+
import { homedir } from "node:os"
|
|
17
|
+
import { join } from "node:path"
|
|
18
|
+
import { readFile, writeFile } from "node:fs/promises"
|
|
19
|
+
import { createWrappedAPI } from "../core/api-wrapper"
|
|
20
|
+
|
|
21
|
+
function getConfigPath() {
|
|
22
|
+
if (process.env.OPENCODE_CONFIG_DIR) {
|
|
23
|
+
return join(process.env.OPENCODE_CONFIG_DIR, "opencode.json")
|
|
24
|
+
}
|
|
25
|
+
return join(homedir(), ".config", "opencode", "opencode.json")
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function loadConfig() {
|
|
29
|
+
try {
|
|
30
|
+
const content = await readFile(getConfigPath(), "utf-8")
|
|
31
|
+
return JSON.parse(content) as Record<string, any>
|
|
32
|
+
} catch {
|
|
33
|
+
return {}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function saveConfig(data: Record<string, any>) {
|
|
38
|
+
await writeFile(getConfigPath(), JSON.stringify(data, null, 2))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const TOOLS = [
|
|
42
|
+
"read",
|
|
43
|
+
"edit",
|
|
44
|
+
"glob",
|
|
45
|
+
"grep",
|
|
46
|
+
"list",
|
|
47
|
+
"bash",
|
|
48
|
+
"task",
|
|
49
|
+
"external_directory",
|
|
50
|
+
"todowrite",
|
|
51
|
+
"question",
|
|
52
|
+
"webfetch",
|
|
53
|
+
"websearch",
|
|
54
|
+
"codesearch",
|
|
55
|
+
"repo_clone",
|
|
56
|
+
"repo_overview",
|
|
57
|
+
"lsp",
|
|
58
|
+
"doom_loop",
|
|
59
|
+
"skill",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
const PATTERN_TOOLS = [
|
|
63
|
+
"read",
|
|
64
|
+
"edit",
|
|
65
|
+
"glob",
|
|
66
|
+
"grep",
|
|
67
|
+
"list",
|
|
68
|
+
"bash",
|
|
69
|
+
"task",
|
|
70
|
+
"external_directory",
|
|
71
|
+
"repo_clone",
|
|
72
|
+
"repo_overview",
|
|
73
|
+
"lsp",
|
|
74
|
+
"skill",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
const ACTIONS: Array<{ title: string; value: string }> = [
|
|
78
|
+
{ title: "ask - always prompt", value: "ask" },
|
|
79
|
+
{ title: "allow - auto approve", value: "allow" },
|
|
80
|
+
{ title: "deny - always reject", value: "deny" },
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
function getPermission(config: Record<string, any>) {
|
|
84
|
+
return config.permission
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getToolCurrent(config: Record<string, any>, tool: string): string {
|
|
88
|
+
const perm = getPermission(config)
|
|
89
|
+
if (typeof perm === "string") return perm
|
|
90
|
+
if (perm && typeof perm === "object") {
|
|
91
|
+
const val = perm[tool]
|
|
92
|
+
if (typeof val === "string") return val
|
|
93
|
+
if (typeof val === "object" && val !== null) return "(pattern-based)"
|
|
94
|
+
}
|
|
95
|
+
return "not set (default: ask)"
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function ensureObjectPermission(config: Record<string, any>) {
|
|
99
|
+
if (!config.permission || typeof config.permission !== "object") {
|
|
100
|
+
config.permission = {}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function setToolAction(config: Record<string, any>, tool: string, action: string) {
|
|
105
|
+
ensureObjectPermission(config)
|
|
106
|
+
config.permission[tool] = action
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function setToolPattern(config: Record<string, any>, tool: string, pattern: string, action: string) {
|
|
110
|
+
ensureObjectPermission(config)
|
|
111
|
+
if (!config.permission[tool] || typeof config.permission[tool] !== "object") {
|
|
112
|
+
config.permission[tool] = {}
|
|
113
|
+
}
|
|
114
|
+
config.permission[tool][pattern] = action
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function removeToolPattern(config: Record<string, any>, tool: string, pattern: string) {
|
|
118
|
+
ensureObjectPermission(config)
|
|
119
|
+
const toolCfg = config.permission[tool]
|
|
120
|
+
if (toolCfg && typeof toolCfg === "object") {
|
|
121
|
+
delete toolCfg[pattern]
|
|
122
|
+
if (Object.keys(toolCfg).length === 0) {
|
|
123
|
+
delete config.permission[tool]
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function formatPermission(config: Record<string, any>): string {
|
|
129
|
+
const perm = getPermission(config)
|
|
130
|
+
if (perm === undefined) return "No permission config set."
|
|
131
|
+
return JSON.stringify(perm, null, 2)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const plugin: TuiPluginModule & { id: string } = {
|
|
135
|
+
id: "opencode-tui-utils.permissions",
|
|
136
|
+
async tui(rawApi) {
|
|
137
|
+
const api = createWrappedAPI(rawApi)
|
|
138
|
+
const { DialogSelect, DialogConfirm, DialogAlert, DialogPrompt } = api.ui
|
|
139
|
+
|
|
140
|
+
const mainMenu = async () => {
|
|
141
|
+
const config = await loadConfig()
|
|
142
|
+
|
|
143
|
+
api.ui.dialog.replace(() => (
|
|
144
|
+
<DialogSelect
|
|
145
|
+
title="Permission Manager"
|
|
146
|
+
options={[
|
|
147
|
+
{ title: "View current permissions", value: "view" },
|
|
148
|
+
{ title: "Set global permission mode", value: "global" },
|
|
149
|
+
{ title: "Toggle per-tool permission", value: "tool" },
|
|
150
|
+
{ title: "Manage pattern permissions", value: "pattern" },
|
|
151
|
+
{ title: "Reset permissions", value: "reset" },
|
|
152
|
+
]}
|
|
153
|
+
onSelect={(option) => {
|
|
154
|
+
if (!option) return
|
|
155
|
+
switch (option.value) {
|
|
156
|
+
case "view":
|
|
157
|
+
viewPermissions(config)
|
|
158
|
+
break
|
|
159
|
+
case "global":
|
|
160
|
+
setGlobalMode(config)
|
|
161
|
+
break
|
|
162
|
+
case "tool":
|
|
163
|
+
selectTool(config)
|
|
164
|
+
break
|
|
165
|
+
case "pattern":
|
|
166
|
+
selectPatternTool(config)
|
|
167
|
+
break
|
|
168
|
+
case "reset":
|
|
169
|
+
confirmReset(config)
|
|
170
|
+
break
|
|
171
|
+
}
|
|
172
|
+
}}
|
|
173
|
+
/>
|
|
174
|
+
))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const viewPermissions = (config: Record<string, any>) => {
|
|
178
|
+
api.ui.dialog.replace(() => (
|
|
179
|
+
<DialogAlert
|
|
180
|
+
title="Current Permissions"
|
|
181
|
+
message={formatPermission(config)}
|
|
182
|
+
/>
|
|
183
|
+
))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const setGlobalMode = (config: Record<string, any>) => {
|
|
187
|
+
api.ui.dialog.replace(() => (
|
|
188
|
+
<DialogSelect
|
|
189
|
+
title="Set global permission mode"
|
|
190
|
+
placeholder="This overrides every tool unless individually configured"
|
|
191
|
+
options={ACTIONS}
|
|
192
|
+
onSelect={(option) => {
|
|
193
|
+
if (!option) return
|
|
194
|
+
void (async () => {
|
|
195
|
+
config.permission = option.value
|
|
196
|
+
await saveConfig(config)
|
|
197
|
+
api.ui.dialog.clear()
|
|
198
|
+
api.ui.toast({
|
|
199
|
+
variant: "success",
|
|
200
|
+
title: "Global permission set",
|
|
201
|
+
message: `All tools default to "${option.value}".`,
|
|
202
|
+
})
|
|
203
|
+
})()
|
|
204
|
+
}}
|
|
205
|
+
/>
|
|
206
|
+
))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const selectTool = (config: Record<string, any>) => {
|
|
210
|
+
const options = TOOLS.map((t) => ({
|
|
211
|
+
title: `${t} [${getToolCurrent(config, t)}]`,
|
|
212
|
+
value: t,
|
|
213
|
+
}))
|
|
214
|
+
|
|
215
|
+
api.ui.dialog.replace(() => (
|
|
216
|
+
<DialogSelect
|
|
217
|
+
title="Select tool to configure"
|
|
218
|
+
options={options}
|
|
219
|
+
onSelect={(option) => {
|
|
220
|
+
if (!option) return
|
|
221
|
+
setToolMode(config, option.value)
|
|
222
|
+
}}
|
|
223
|
+
/>
|
|
224
|
+
))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const setToolMode = (config: Record<string, any>, tool: string) => {
|
|
228
|
+
api.ui.dialog.replace(() => (
|
|
229
|
+
<DialogSelect
|
|
230
|
+
title={`Set permission for "${tool}"`}
|
|
231
|
+
options={ACTIONS}
|
|
232
|
+
onSelect={(option) => {
|
|
233
|
+
if (!option) return
|
|
234
|
+
void (async () => {
|
|
235
|
+
setToolAction(config, tool, option.value)
|
|
236
|
+
await saveConfig(config)
|
|
237
|
+
api.ui.dialog.clear()
|
|
238
|
+
api.ui.toast({
|
|
239
|
+
variant: "success",
|
|
240
|
+
title: "Permission updated",
|
|
241
|
+
message: `"${tool}" is now "${option.value}".`,
|
|
242
|
+
})
|
|
243
|
+
})()
|
|
244
|
+
}}
|
|
245
|
+
/>
|
|
246
|
+
))
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const selectPatternTool = (config: Record<string, any>) => {
|
|
250
|
+
const options = PATTERN_TOOLS.map((t) => ({
|
|
251
|
+
title: `${t} [${getToolCurrent(config, t)}]`,
|
|
252
|
+
value: t,
|
|
253
|
+
}))
|
|
254
|
+
|
|
255
|
+
api.ui.dialog.replace(() => (
|
|
256
|
+
<DialogSelect
|
|
257
|
+
title="Select tool for pattern management"
|
|
258
|
+
options={options}
|
|
259
|
+
onSelect={(option) => {
|
|
260
|
+
if (!option) return
|
|
261
|
+
managePatterns(config, option.value)
|
|
262
|
+
}}
|
|
263
|
+
/>
|
|
264
|
+
))
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const managePatterns = (config: Record<string, any>, tool: string) => {
|
|
268
|
+
const toolCfg = config.permission?.[tool]
|
|
269
|
+
const patterns =
|
|
270
|
+
toolCfg && typeof toolCfg === "object"
|
|
271
|
+
? Object.entries(toolCfg).map(([k, v]) => ({ title: `${k} -> ${v}`, value: k }))
|
|
272
|
+
: []
|
|
273
|
+
|
|
274
|
+
const options = [
|
|
275
|
+
{ title: "Add new pattern", value: "__add__" },
|
|
276
|
+
...patterns,
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
api.ui.dialog.replace(() => (
|
|
280
|
+
<DialogSelect
|
|
281
|
+
title={`Patterns for "${tool}"${patterns.length === 0 ? " (none set)" : ""}`}
|
|
282
|
+
options={options}
|
|
283
|
+
onSelect={(option) => {
|
|
284
|
+
if (!option) return
|
|
285
|
+
if (option.value === "__add__") {
|
|
286
|
+
addPattern(config, tool)
|
|
287
|
+
} else {
|
|
288
|
+
confirmRemovePattern(config, tool, option.value)
|
|
289
|
+
}
|
|
290
|
+
}}
|
|
291
|
+
/>
|
|
292
|
+
))
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const addPattern = (config: Record<string, any>, tool: string) => {
|
|
296
|
+
api.ui.dialog.replace(() => (
|
|
297
|
+
<DialogPrompt
|
|
298
|
+
title={`Add pattern for "${tool}"`}
|
|
299
|
+
placeholder="/tmp/workspace/* or /home/shell/*"
|
|
300
|
+
onConfirm={(patternValue: string) => {
|
|
301
|
+
const trimmed = patternValue.trim()
|
|
302
|
+
if (!trimmed) {
|
|
303
|
+
api.ui.dialog.clear()
|
|
304
|
+
api.ui.toast({ title: "Cancelled", message: "Empty pattern." })
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
api.ui.dialog.replace(() => (
|
|
308
|
+
<DialogSelect
|
|
309
|
+
title={`Action for "${trimmed}"`}
|
|
310
|
+
options={ACTIONS}
|
|
311
|
+
onSelect={(actionOpt) => {
|
|
312
|
+
if (!actionOpt) {
|
|
313
|
+
api.ui.dialog.clear()
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
void (async () => {
|
|
317
|
+
setToolPattern(config, tool, trimmed, actionOpt.value)
|
|
318
|
+
await saveConfig(config)
|
|
319
|
+
api.ui.dialog.clear()
|
|
320
|
+
api.ui.toast({
|
|
321
|
+
variant: "success",
|
|
322
|
+
title: "Pattern added",
|
|
323
|
+
message: `"${trimmed}" -> ${actionOpt.value}`,
|
|
324
|
+
})
|
|
325
|
+
})()
|
|
326
|
+
}}
|
|
327
|
+
/>
|
|
328
|
+
))
|
|
329
|
+
}}
|
|
330
|
+
onCancel={() => {
|
|
331
|
+
api.ui.dialog.clear()
|
|
332
|
+
}}
|
|
333
|
+
/>
|
|
334
|
+
))
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const confirmRemovePattern = (config: Record<string, any>, tool: string, pattern: string) => {
|
|
338
|
+
api.ui.dialog.replace(() => (
|
|
339
|
+
<DialogConfirm
|
|
340
|
+
title="Remove pattern?"
|
|
341
|
+
message={`Delete "${pattern}" from "${tool}"?`}
|
|
342
|
+
onConfirm={async () => {
|
|
343
|
+
removeToolPattern(config, tool, pattern)
|
|
344
|
+
await saveConfig(config)
|
|
345
|
+
api.ui.dialog.clear()
|
|
346
|
+
api.ui.toast({
|
|
347
|
+
variant: "success",
|
|
348
|
+
title: "Pattern removed",
|
|
349
|
+
message: `"${pattern}" deleted from "${tool}".`,
|
|
350
|
+
})
|
|
351
|
+
}}
|
|
352
|
+
onCancel={() => {
|
|
353
|
+
api.ui.dialog.clear()
|
|
354
|
+
}}
|
|
355
|
+
/>
|
|
356
|
+
))
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const confirmReset = (config: Record<string, any>) => {
|
|
360
|
+
api.ui.dialog.replace(() => (
|
|
361
|
+
<DialogConfirm
|
|
362
|
+
title="Reset permissions?"
|
|
363
|
+
message="This removes the entire 'permission' field from opencode.json."
|
|
364
|
+
onConfirm={async () => {
|
|
365
|
+
delete config.permission
|
|
366
|
+
await saveConfig(config)
|
|
367
|
+
api.ui.dialog.clear()
|
|
368
|
+
api.ui.toast({
|
|
369
|
+
variant: "success",
|
|
370
|
+
title: "Permissions reset",
|
|
371
|
+
message: "Permission config removed. Default behavior restored.",
|
|
372
|
+
})
|
|
373
|
+
}}
|
|
374
|
+
onCancel={() => {
|
|
375
|
+
api.ui.dialog.clear()
|
|
376
|
+
}}
|
|
377
|
+
/>
|
|
378
|
+
))
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
api.keymap.registerLayer({
|
|
382
|
+
commands: [
|
|
383
|
+
{
|
|
384
|
+
name: "opencode-tui-utils.permissions",
|
|
385
|
+
title: "Manage Permissions",
|
|
386
|
+
category: "Config",
|
|
387
|
+
namespace: "palette",
|
|
388
|
+
slashName: "permissions",
|
|
389
|
+
async run() {
|
|
390
|
+
await mainMenu()
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
],
|
|
394
|
+
})
|
|
395
|
+
},
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export default plugin
|