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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-tui-utils",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Add /disconnect command to opencode - safely disconnect providers without editing auth.json",
5
5
  "main": "src/index.tsx",
6
6
  "type": "module",
@@ -48,6 +48,7 @@ export function createUIAPI(api: TuiPluginApi) {
48
48
  DialogSelect: api.ui.DialogSelect,
49
49
  DialogConfirm: api.ui.DialogConfirm,
50
50
  DialogAlert: api.ui.DialogAlert,
51
+ DialogPrompt: api.ui.DialogPrompt,
51
52
  }
52
53
  }
53
54
 
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