opencode-add-dir 1.5.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/README.md +44 -49
- package/bin/setup.mjs +30 -73
- package/dist/index.js +6 -308
- package/dist/permissions.d.ts +3 -3
- package/dist/state.d.ts +2 -2
- package/dist/tui-plugin.d.ts +4 -3
- package/dist/tui.tsx +139 -74
- package/dist/types.d.ts +0 -11
- package/package.json +4 -6
- package/bin/ensure-tui.mjs +0 -39
- package/dist/validate.d.ts +0 -8
package/README.md
CHANGED
|
@@ -6,12 +6,8 @@ When you need an agent to read, edit, or search files outside the current projec
|
|
|
6
6
|
|
|
7
7
|
## Quick Start
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
```json
|
|
12
|
-
{
|
|
13
|
-
"plugin": ["opencode-add-dir"]
|
|
14
|
-
}
|
|
9
|
+
```bash
|
|
10
|
+
opencode plugin opencode-add-dir -g
|
|
15
11
|
```
|
|
16
12
|
|
|
17
13
|
Restart OpenCode. Done.
|
|
@@ -20,15 +16,13 @@ Restart OpenCode. Done.
|
|
|
20
16
|
<summary>Alternative: setup CLI</summary>
|
|
21
17
|
|
|
22
18
|
```bash
|
|
23
|
-
|
|
19
|
+
npx opencode-add-dir-setup
|
|
24
20
|
```
|
|
25
21
|
|
|
26
|
-
Automatically adds the plugin to your global `opencode.json`.
|
|
27
|
-
|
|
28
22
|
</details>
|
|
29
23
|
|
|
30
24
|
<details>
|
|
31
|
-
<summary>Alternative: local
|
|
25
|
+
<summary>Alternative: local development</summary>
|
|
32
26
|
|
|
33
27
|
```bash
|
|
34
28
|
git clone https://github.com/kuzeofficial/add-dir-opencode.git
|
|
@@ -36,77 +30,78 @@ cd add-dir-opencode
|
|
|
36
30
|
bun install && bun run deploy
|
|
37
31
|
```
|
|
38
32
|
|
|
39
|
-
|
|
33
|
+
Add the local path to both configs:
|
|
34
|
+
|
|
35
|
+
```jsonc
|
|
36
|
+
// ~/.config/opencode/opencode.json
|
|
37
|
+
{ "plugin": ["/path/to/add-dir-opencode"] }
|
|
38
|
+
|
|
39
|
+
// ~/.config/opencode/tui.json
|
|
40
|
+
{ "plugin": ["/path/to/add-dir-opencode"] }
|
|
41
|
+
```
|
|
40
42
|
|
|
41
43
|
</details>
|
|
42
44
|
|
|
43
45
|
## Commands
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
All commands are interactive TUI dialogs — type the command and select from autocomplete.
|
|
48
|
+
|
|
49
|
+
| Command | Dialog | Description |
|
|
50
|
+
|---------|--------|-------------|
|
|
51
|
+
| `/add-dir` | Text input + remember checkbox | Add a working directory. Toggle `[x] Remember` with tab to persist across sessions. |
|
|
52
|
+
| `/list-dir` | Alert | Shows all added directories. |
|
|
53
|
+
| `/remove-dir` | Select list + confirm | Pick a directory to remove, then confirm. |
|
|
51
54
|
|
|
52
55
|
## How It Works
|
|
53
56
|
|
|
54
|
-
The plugin
|
|
57
|
+
The plugin has two parts: a **TUI plugin** for the interactive dialogs and a **server plugin** for silent permission handling.
|
|
55
58
|
|
|
56
|
-
|
|
57
|
-
|-------|------|-------|
|
|
58
|
-
| **Config hook** | Startup | Injects `external_directory: "allow"` rules for persisted dirs into all agents |
|
|
59
|
-
| **Session permission** | `/add-dir` | Sets `external_directory: true` on the current session |
|
|
60
|
-
| **tool.execute.before** | Every file tool | Detects subagent sessions accessing added dirs, grants permission before execution |
|
|
61
|
-
| **Event auto-approve** | Permission popup | Catches any remaining `external_directory` requests and auto-approves via SDK |
|
|
59
|
+
### TUI Plugin
|
|
62
60
|
|
|
63
|
-
|
|
61
|
+
Handles all three slash commands via dialogs. Writes persisted directories to `~/.local/share/opencode/add-dir/directories.json` and grants session permissions via the SDK.
|
|
64
62
|
|
|
65
|
-
|
|
63
|
+
### Server Plugin
|
|
66
64
|
|
|
67
|
-
|
|
65
|
+
Runs in the background — no commands, only hooks:
|
|
68
66
|
|
|
69
|
-
|
|
67
|
+
| Hook | What it does |
|
|
68
|
+
|------|-------------|
|
|
69
|
+
| `config` | Injects `external_directory: "allow"` permission rules for persisted dirs at startup |
|
|
70
|
+
| `tool.execute.before` | Auto-grants permissions when subagents access added directories |
|
|
71
|
+
| `event` | Auto-approves any remaining permission popups for added directories |
|
|
72
|
+
| `system.transform` | Injects `AGENTS.md` / `CLAUDE.md` content from added directories into the system prompt |
|
|
70
73
|
|
|
71
|
-
|
|
72
|
-
~/.local/share/opencode/add-dir/directories.json
|
|
73
|
-
```
|
|
74
|
+
### Context Injection
|
|
74
75
|
|
|
75
|
-
|
|
76
|
+
If an added directory contains `AGENTS.md`, `CLAUDE.md`, or `.agents/AGENTS.md`, the content is automatically injected into the system prompt.
|
|
76
77
|
|
|
77
78
|
## Development
|
|
78
79
|
|
|
79
80
|
```bash
|
|
80
81
|
bun install
|
|
81
|
-
bun test #
|
|
82
|
+
bun test # Run tests
|
|
82
83
|
bun run typecheck # Type check
|
|
83
84
|
bun run build # Build npm package
|
|
84
|
-
bun run deploy #
|
|
85
|
+
bun run deploy # Build server + TUI locally
|
|
85
86
|
```
|
|
86
87
|
|
|
87
88
|
### Project Structure
|
|
88
89
|
|
|
89
90
|
```
|
|
90
91
|
src/
|
|
91
|
-
├── index.ts
|
|
92
|
-
├── plugin.ts
|
|
93
|
-
├──
|
|
94
|
-
├──
|
|
95
|
-
├──
|
|
96
|
-
├──
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
## Debugging
|
|
101
|
-
|
|
102
|
-
```bash
|
|
103
|
-
opencode --print-logs 2>debug.log
|
|
104
|
-
grep "\[add-dir\]" debug.log
|
|
92
|
+
├── index.ts # Server plugin entry
|
|
93
|
+
├── plugin.ts # Server hooks (permissions, context injection)
|
|
94
|
+
├── tui-plugin.tsx # TUI plugin (dialogs for add/list/remove)
|
|
95
|
+
├── state.ts # Persistence, path utils, tui.json auto-config
|
|
96
|
+
├── validate.ts # Directory validation
|
|
97
|
+
├── permissions.ts # Session grants + auto-approve
|
|
98
|
+
├── context.ts # AGENTS.md injection
|
|
99
|
+
└── types.ts # Shared type definitions
|
|
105
100
|
```
|
|
106
101
|
|
|
107
102
|
## Limitations
|
|
108
103
|
|
|
109
|
-
- Directories added
|
|
104
|
+
- Directories added without "Remember" rely on session-level permissions. The first access by a subagent may briefly show a permission popup before auto-dismissing.
|
|
110
105
|
- The `permission.ask` plugin hook is defined in the OpenCode SDK but [not invoked](https://github.com/sst/opencode/blob/main/packages/opencode/src/permission/index.ts) in the source — this plugin works around it using `tool.execute.before` and event-based auto-approval.
|
|
111
106
|
|
|
112
107
|
## License
|
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,309 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
7
|
-
function expandHome(p) {
|
|
8
|
-
return p.startsWith("~/") ? (process.env["HOME"] || "~") + p.slice(1) : p;
|
|
9
|
-
}
|
|
10
|
-
function loadDirs() {
|
|
11
|
-
const dirs = new Map;
|
|
12
|
-
const file = join(stateDir(), "directories.json");
|
|
13
|
-
if (!existsSync(file))
|
|
14
|
-
return dirs;
|
|
15
|
-
try {
|
|
16
|
-
for (const p of JSON.parse(readFileSync(file, "utf-8")))
|
|
17
|
-
dirs.set(p, { path: p, persist: true });
|
|
18
|
-
} catch {}
|
|
19
|
-
return dirs;
|
|
20
|
-
}
|
|
21
|
-
function saveDirs(dirs) {
|
|
22
|
-
const list = [...dirs.values()].filter((d) => d.persist).map((d) => d.path);
|
|
23
|
-
const dir = stateDir();
|
|
24
|
-
if (!existsSync(dir))
|
|
25
|
-
mkdirSync(dir, { recursive: true });
|
|
26
|
-
writeFileSync(join(dir, "directories.json"), JSON.stringify(list, null, 2));
|
|
27
|
-
}
|
|
28
|
-
function isChildOf(parent, child) {
|
|
29
|
-
return child === parent || child.startsWith(parent + "/");
|
|
30
|
-
}
|
|
31
|
-
function matchesDirs(dirs, filepath) {
|
|
32
|
-
for (const entry of dirs.values()) {
|
|
33
|
-
if (isChildOf(entry.path, filepath))
|
|
34
|
-
return true;
|
|
35
|
-
}
|
|
36
|
-
return false;
|
|
37
|
-
}
|
|
38
|
-
var PKG = "opencode-add-dir";
|
|
39
|
-
function stripJsonComments(text) {
|
|
40
|
-
let result = "";
|
|
41
|
-
let inString = false;
|
|
42
|
-
let escape = false;
|
|
43
|
-
for (let i = 0;i < text.length; i++) {
|
|
44
|
-
const ch = text[i];
|
|
45
|
-
if (escape) {
|
|
46
|
-
result += ch;
|
|
47
|
-
escape = false;
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
|
-
if (ch === "\\" && inString) {
|
|
51
|
-
result += ch;
|
|
52
|
-
escape = true;
|
|
53
|
-
continue;
|
|
54
|
-
}
|
|
55
|
-
if (ch === '"') {
|
|
56
|
-
inString = !inString;
|
|
57
|
-
result += ch;
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
if (inString) {
|
|
61
|
-
result += ch;
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
if (ch === "/" && text[i + 1] === "/") {
|
|
65
|
-
while (i < text.length && text[i] !== `
|
|
66
|
-
`)
|
|
67
|
-
i++;
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
if (ch === "/" && text[i + 1] === "*") {
|
|
71
|
-
i += 2;
|
|
72
|
-
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
|
|
73
|
-
i++;
|
|
74
|
-
i++;
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
result += ch;
|
|
78
|
-
}
|
|
79
|
-
return result;
|
|
80
|
-
}
|
|
81
|
-
function configDir() {
|
|
82
|
-
return join(process.env["XDG_CONFIG_HOME"] || join(process.env["HOME"] || "~", ".config"), "opencode");
|
|
83
|
-
}
|
|
84
|
-
function findTuiConfig() {
|
|
85
|
-
const dir = configDir();
|
|
86
|
-
for (const name of ["tui.jsonc", "tui.json"]) {
|
|
87
|
-
const p = join(dir, name);
|
|
88
|
-
if (existsSync(p))
|
|
89
|
-
return p;
|
|
90
|
-
}
|
|
91
|
-
return join(dir, "tui.json");
|
|
92
|
-
}
|
|
93
|
-
function ensureTuiConfig() {
|
|
94
|
-
try {
|
|
95
|
-
const dir = configDir();
|
|
96
|
-
if (!existsSync(dir))
|
|
97
|
-
mkdirSync(dir, { recursive: true });
|
|
98
|
-
const filePath = findTuiConfig();
|
|
99
|
-
let config = {};
|
|
100
|
-
if (existsSync(filePath)) {
|
|
101
|
-
config = JSON.parse(stripJsonComments(readFileSync(filePath, "utf-8")));
|
|
102
|
-
}
|
|
103
|
-
const plugins = config.plugin ?? [];
|
|
104
|
-
const hasEntry = plugins.some((p) => {
|
|
105
|
-
const name = Array.isArray(p) ? p[0] : p;
|
|
106
|
-
return name === PKG || typeof name === "string" && name.startsWith(PKG + "@");
|
|
107
|
-
});
|
|
108
|
-
if (hasEntry)
|
|
109
|
-
return;
|
|
110
|
-
config.plugin = [...plugins, PKG];
|
|
111
|
-
writeFileSync(filePath, JSON.stringify(config, null, 2) + `
|
|
112
|
-
`);
|
|
113
|
-
} catch {}
|
|
114
|
-
}
|
|
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}
|
|
115
6
|
|
|
116
|
-
|
|
117
|
-
import { statSync } from "fs";
|
|
118
|
-
import { resolve } from "path";
|
|
119
|
-
function validateDir(input, worktree, existing) {
|
|
120
|
-
const trimmed = input.trim();
|
|
121
|
-
if (!trimmed)
|
|
122
|
-
return { ok: false, reason: "No directory path provided." };
|
|
123
|
-
const abs = resolve(expandHome(trimmed));
|
|
124
|
-
try {
|
|
125
|
-
if (!statSync(abs).isDirectory())
|
|
126
|
-
return { ok: false, reason: `${abs} is not a directory.` };
|
|
127
|
-
} catch (e) {
|
|
128
|
-
const code = e.code;
|
|
129
|
-
if (code && ["ENOENT", "ENOTDIR", "EACCES", "EPERM"].includes(code))
|
|
130
|
-
return { ok: false, reason: `Path ${abs} was not found.` };
|
|
131
|
-
throw e;
|
|
132
|
-
}
|
|
133
|
-
if (isChildOf(worktree, abs))
|
|
134
|
-
return { ok: false, reason: `${abs} is already within the project directory ${worktree}.` };
|
|
135
|
-
for (const dir of existing)
|
|
136
|
-
if (isChildOf(dir, abs))
|
|
137
|
-
return { ok: false, reason: `${abs} is already accessible within ${dir}.` };
|
|
138
|
-
return { ok: true, absolutePath: abs };
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// src/permissions.ts
|
|
142
|
-
import { join as join2, resolve as resolve2 } from "path";
|
|
143
|
-
var FILE_TOOLS = new Set(["read", "write", "edit", "apply_patch", "multiedit", "glob", "grep", "list", "bash"]);
|
|
144
|
-
var grantedSessions = new Set;
|
|
145
|
-
function permissionGlob(dirPath) {
|
|
146
|
-
return join2(dirPath, "*");
|
|
147
|
-
}
|
|
148
|
-
function sendPrompt(sdk, sessionID, text, tools) {
|
|
149
|
-
const body = { noReply: true, ...tools && { tools }, parts: [{ type: "text", text }] };
|
|
150
|
-
return sdk.session.promptAsync({ path: { id: sessionID }, body })?.then?.(() => {})?.catch?.(() => {}) ?? Promise.resolve();
|
|
151
|
-
}
|
|
152
|
-
function notify(sdk, sessionID, text) {
|
|
153
|
-
sendPrompt(sdk, sessionID, text);
|
|
154
|
-
}
|
|
155
|
-
function grantSessionAsync(sdk, sessionID, text) {
|
|
156
|
-
grantedSessions.add(sessionID);
|
|
157
|
-
sendPrompt(sdk, sessionID, text, { external_directory: true });
|
|
158
|
-
}
|
|
159
|
-
async function grantSession(sdk, sessionID, text) {
|
|
160
|
-
if (grantedSessions.has(sessionID))
|
|
161
|
-
return;
|
|
162
|
-
grantedSessions.add(sessionID);
|
|
163
|
-
const body = { noReply: true, tools: { external_directory: true }, parts: [{ type: "text", text }] };
|
|
164
|
-
await sdk.session.prompt({ path: { id: sessionID }, body }).catch(() => {});
|
|
165
|
-
}
|
|
166
|
-
function shouldGrantBeforeTool(dirs, tool, args) {
|
|
167
|
-
if (!dirs.size || !FILE_TOOLS.has(tool))
|
|
168
|
-
return false;
|
|
169
|
-
const p = extractPath(tool, args);
|
|
170
|
-
return !!p && matchesDirs(dirs, resolve2(expandHome(p)));
|
|
171
|
-
}
|
|
172
|
-
async function autoApprovePermission(sdk, props, dirs) {
|
|
173
|
-
if (props.permission !== "external_directory")
|
|
174
|
-
return;
|
|
175
|
-
const meta = props.metadata;
|
|
176
|
-
const filepath = meta.filepath ?? "";
|
|
177
|
-
const parentDir = meta.parentDir ?? "";
|
|
178
|
-
const patterns = props.patterns ?? [];
|
|
179
|
-
const matches = matchesDirs(dirs, filepath) || matchesDirs(dirs, parentDir) || patterns.some((p) => matchesDirs(dirs, p.replace(/\/?\*$/, "")));
|
|
180
|
-
if (!matches || !props.id || !props.sessionID)
|
|
181
|
-
return;
|
|
182
|
-
await sdk.postSessionIdPermissionsPermissionId({
|
|
183
|
-
path: { id: props.sessionID, permissionID: props.id },
|
|
184
|
-
body: { response: "always" }
|
|
185
|
-
}).catch(() => {});
|
|
186
|
-
}
|
|
187
|
-
function extractPath(tool, args) {
|
|
188
|
-
if (!args)
|
|
189
|
-
return "";
|
|
190
|
-
if (tool === "bash")
|
|
191
|
-
return args.workdir || args.command || "";
|
|
192
|
-
return args.filePath || args.path || args.pattern || "";
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// src/context.ts
|
|
196
|
-
import { readFileSync as readFileSync2 } from "fs";
|
|
197
|
-
import { join as join3 } from "path";
|
|
198
|
-
var CONTEXT_FILES = ["AGENTS.md", "CLAUDE.md", ".agents/AGENTS.md"];
|
|
199
|
-
function collectAgentContext(dirs) {
|
|
200
|
-
const sections = [];
|
|
201
|
-
for (const entry of dirs.values()) {
|
|
202
|
-
for (const name of CONTEXT_FILES) {
|
|
203
|
-
const fp = join3(entry.path, name);
|
|
204
|
-
try {
|
|
205
|
-
const content = readFileSync2(fp, "utf-8").trim();
|
|
206
|
-
if (content)
|
|
207
|
-
sections.push(`# Context from ${fp}
|
|
208
|
-
|
|
209
|
-
${content}`);
|
|
210
|
-
} catch {}
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
return sections;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// src/plugin.ts
|
|
217
|
-
var SENTINEL = Object.assign(new Error("__ADD_DIR_HANDLED__"), { stack: "" });
|
|
218
|
-
ensureTuiConfig();
|
|
219
|
-
var AddDirPlugin = async ({ client, worktree, directory }) => {
|
|
220
|
-
const root = worktree || directory;
|
|
221
|
-
const dirs = loadDirs();
|
|
222
|
-
const sdk = client;
|
|
223
|
-
function add(dirPath, persist) {
|
|
224
|
-
const result = validateDir(dirPath, root, [...dirs.values()].map((d) => d.path));
|
|
225
|
-
if (!result.ok)
|
|
226
|
-
return { ok: false, message: result.reason };
|
|
227
|
-
dirs.set(result.absolutePath, { path: result.absolutePath, persist });
|
|
228
|
-
if (persist)
|
|
229
|
-
saveDirs(dirs);
|
|
230
|
-
const label = persist ? "persistent" : "session";
|
|
231
|
-
return { ok: true, message: `Added ${result.absolutePath} as a working directory (${label}).` };
|
|
232
|
-
}
|
|
233
|
-
function remove(path) {
|
|
234
|
-
if (!path?.trim())
|
|
235
|
-
return "Usage: /remove-dir <path>";
|
|
236
|
-
if (!dirs.has(path))
|
|
237
|
-
return `${path} is not in the directory list.`;
|
|
238
|
-
dirs.delete(path);
|
|
239
|
-
saveDirs(dirs);
|
|
240
|
-
return `Removed ${path} from working directories.`;
|
|
241
|
-
}
|
|
242
|
-
function list() {
|
|
243
|
-
if (!dirs.size)
|
|
244
|
-
return "No additional directories added.";
|
|
245
|
-
return [...dirs.values()].map((d) => `${d.path} (${d.persist ? "persistent" : "session"})`).join(`
|
|
246
|
-
`);
|
|
247
|
-
}
|
|
248
|
-
function handleAdd(args, sessionID) {
|
|
249
|
-
const tokens = args.trim().split(/\s+/);
|
|
250
|
-
const flags = new Set(tokens.filter((t) => t.startsWith("--")));
|
|
251
|
-
const pos = tokens.filter((t) => !t.startsWith("--"));
|
|
252
|
-
if (!pos[0])
|
|
253
|
-
return notify(sdk, sessionID, "Usage: /add-dir <path> [--remember]");
|
|
254
|
-
const result = add(pos[0], flags.has("--remember"));
|
|
255
|
-
if (result.ok)
|
|
256
|
-
grantSessionAsync(sdk, sessionID, result.message);
|
|
257
|
-
else
|
|
258
|
-
notify(sdk, sessionID, result.message);
|
|
259
|
-
}
|
|
260
|
-
const commands = {
|
|
261
|
-
__adddir: (args, sid) => handleAdd(args, sid),
|
|
262
|
-
"list-dir": (_, sid) => notify(sdk, sid, list()),
|
|
263
|
-
"remove-dir": (args, sid) => notify(sdk, sid, remove(args))
|
|
264
|
-
};
|
|
265
|
-
return {
|
|
266
|
-
config: async (cfg) => {
|
|
267
|
-
cfg.command ??= {};
|
|
268
|
-
const cmd = cfg.command;
|
|
269
|
-
cmd["__adddir"] = { template: "/__adddir", description: "Internal: add a working directory" };
|
|
270
|
-
cmd["list-dir"] = { template: "/list-dir", description: "List added working directories" };
|
|
271
|
-
cmd["remove-dir"] = { template: "/remove-dir", description: "Remove a working directory" };
|
|
272
|
-
if (!dirs.size)
|
|
273
|
-
return;
|
|
274
|
-
const perm = cfg.permission ??= {};
|
|
275
|
-
const extDir = perm.external_directory ??= {};
|
|
276
|
-
for (const entry of dirs.values())
|
|
277
|
-
extDir[permissionGlob(entry.path)] = "allow";
|
|
278
|
-
},
|
|
279
|
-
"command.execute.before": async (input) => {
|
|
280
|
-
const handler = commands[input.command];
|
|
281
|
-
if (!handler)
|
|
282
|
-
return;
|
|
283
|
-
handler(input.arguments || "", input.sessionID);
|
|
284
|
-
throw SENTINEL;
|
|
285
|
-
},
|
|
286
|
-
"tool.execute.before": async (input, output) => {
|
|
287
|
-
if (shouldGrantBeforeTool(dirs, input.tool, output.args))
|
|
288
|
-
await grantSession(sdk, input.sessionID, "Directory access granted by add-dir plugin.");
|
|
289
|
-
},
|
|
290
|
-
event: async ({ event }) => {
|
|
291
|
-
const e = event;
|
|
292
|
-
if (e.type === "permission.asked" && e.properties)
|
|
293
|
-
await autoApprovePermission(sdk, e.properties, dirs);
|
|
294
|
-
},
|
|
295
|
-
"experimental.chat.system.transform": async (_input, output) => {
|
|
296
|
-
output.system.push(...collectAgentContext(dirs));
|
|
297
|
-
}
|
|
298
|
-
};
|
|
299
|
-
};
|
|
300
|
-
|
|
301
|
-
// src/index.ts
|
|
302
|
-
var plugin = {
|
|
303
|
-
id: "opencode-add-dir",
|
|
304
|
-
server: AddDirPlugin
|
|
305
|
-
};
|
|
306
|
-
var src_default = plugin;
|
|
307
|
-
export {
|
|
308
|
-
src_default as default
|
|
309
|
-
};
|
|
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,8 +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
|
|
5
|
-
export declare function grantSessionAsync(sdk: SDK, sessionID: string, text: string): void;
|
|
6
|
-
export declare function grantSession(sdk: SDK, sessionID: string, text: string): Promise<void>;
|
|
5
|
+
export declare function grantSession(sdk: SDK, sessionID: string): Promise<void>;
|
|
7
6
|
export declare function shouldGrantBeforeTool(dirs: Map<string, DirEntry>, tool: string, args: ToolArgs): boolean;
|
|
8
7
|
export declare function autoApprovePermission(sdk: SDK, props: PermissionEvent, dirs: Map<string, DirEntry>): Promise<void>;
|
|
8
|
+
export declare function extractPath(tool: string, args: ToolArgs): string;
|
package/dist/state.d.ts
CHANGED
|
@@ -3,8 +3,8 @@ export interface DirEntry {
|
|
|
3
3
|
persist: boolean;
|
|
4
4
|
}
|
|
5
5
|
export declare function expandHome(p: string): string;
|
|
6
|
-
export declare function
|
|
7
|
-
export declare function
|
|
6
|
+
export declare function freshDirs(): Map<string, DirEntry>;
|
|
7
|
+
export declare function invalidateCache(): void;
|
|
8
8
|
export declare function isChildOf(parent: string, child: string): boolean;
|
|
9
9
|
export declare function matchesDirs(dirs: Map<string, DirEntry>, filepath: string): boolean;
|
|
10
10
|
export declare function ensureTuiConfig(): void;
|
package/dist/tui-plugin.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
declare const
|
|
1
|
+
import type { TuiPlugin } from "@opencode-ai/plugin/tui";
|
|
2
|
+
declare const _default: {
|
|
3
3
|
id: string;
|
|
4
|
+
tui: TuiPlugin;
|
|
4
5
|
};
|
|
5
|
-
export default
|
|
6
|
+
export default _default;
|
package/dist/tui.tsx
CHANGED
|
@@ -1,110 +1,175 @@
|
|
|
1
1
|
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
2
|
+
import { useKeyboard } from "@opentui/solid"
|
|
2
3
|
import { createSignal } from "solid-js"
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "fs"
|
|
5
|
+
import { join, resolve } from "path"
|
|
3
6
|
|
|
4
|
-
const
|
|
5
|
-
const
|
|
7
|
+
const ID = "opencode-add-dir"
|
|
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")
|
|
6
11
|
|
|
7
|
-
function
|
|
8
|
-
|
|
9
|
-
if (route.name !== "session" || !route.params) return
|
|
10
|
-
return route.params.sessionID as string
|
|
12
|
+
function readJsonArray(file: string): string[] {
|
|
13
|
+
try { return JSON.parse(readFileSync(file, "utf-8")) } catch { return [] }
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
+
}
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
function allDirs(): string[] {
|
|
22
|
+
return [...new Set([...readJsonArray(PERSISTED_FILE), ...readJsonArray(SESSION_FILE)])]
|
|
23
|
+
}
|
|
19
24
|
|
|
20
|
-
|
|
21
|
-
|
|
25
|
+
function resolvePath(input: string) {
|
|
26
|
+
const p = input.trim()
|
|
27
|
+
return resolve(p.startsWith("~/") ? (process.env["HOME"] || "~") + p.slice(1) : p)
|
|
22
28
|
}
|
|
23
29
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
function validate(input: string): string | undefined {
|
|
31
|
+
if (!input.trim()) return "Path is required."
|
|
32
|
+
const abs = resolvePath(input)
|
|
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))
|
|
29
48
|
}
|
|
49
|
+
}
|
|
30
50
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
51
|
+
function getSessionID(api: TuiPluginApi): string | undefined {
|
|
52
|
+
const r = api.route.current
|
|
53
|
+
return r.name === "session" && r.params ? r.params.sessionID as string : undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function ensureSession(api: TuiPluginApi): Promise<string | undefined> {
|
|
57
|
+
const id = getSessionID(api)
|
|
58
|
+
if (id) return id
|
|
59
|
+
const res = await api.client.session.create({})
|
|
60
|
+
if (res.error) return
|
|
61
|
+
api.route.navigate("session", { sessionID: res.data.id })
|
|
62
|
+
return res.data.id
|
|
39
63
|
}
|
|
40
64
|
|
|
41
65
|
function AddDirDialog(props: { api: TuiPluginApi }) {
|
|
42
66
|
const [busy, setBusy] = createSignal(false)
|
|
67
|
+
const [remember, setRemember] = createSignal(false)
|
|
68
|
+
const { api } = props
|
|
43
69
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
setBusy(true)
|
|
52
|
-
try {
|
|
53
|
-
await executeAddDir(props.api, dirPath)
|
|
54
|
-
props.api.ui.dialog.clear()
|
|
55
|
-
} finally {
|
|
56
|
-
setBusy(false)
|
|
57
|
-
}
|
|
58
|
-
}
|
|
70
|
+
useKeyboard((e) => {
|
|
71
|
+
if (e.name !== "tab" || busy()) return
|
|
72
|
+
e.preventDefault()
|
|
73
|
+
e.stopPropagation()
|
|
74
|
+
setRemember((v) => !v)
|
|
75
|
+
})
|
|
59
76
|
|
|
60
77
|
return (
|
|
61
|
-
<
|
|
78
|
+
<api.ui.DialogPrompt
|
|
62
79
|
title="Add directory"
|
|
63
80
|
placeholder="/path/to/directory"
|
|
64
81
|
busy={busy()}
|
|
65
|
-
busyText="Adding
|
|
82
|
+
busyText="Adding..."
|
|
66
83
|
description={() => (
|
|
67
|
-
<box gap={
|
|
68
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
{
|
|
73
|
-
</
|
|
74
|
-
<
|
|
75
|
-
{
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
{
|
|
79
|
-
</
|
|
84
|
+
<box gap={1}>
|
|
85
|
+
<box gap={0}>
|
|
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>
|
|
89
|
+
<text fg={api.theme.current.textMuted}> 3. Paste below</text>
|
|
90
|
+
</box>
|
|
91
|
+
<box flexDirection="row" gap={1}>
|
|
92
|
+
<text fg={remember() ? api.theme.current.text : api.theme.current.textMuted}>
|
|
93
|
+
{remember() ? "[x]" : "[ ]"} Remember across sessions
|
|
94
|
+
</text>
|
|
95
|
+
<text fg={api.theme.current.textMuted}>(tab)</text>
|
|
96
|
+
</box>
|
|
80
97
|
</box>
|
|
81
98
|
)}
|
|
82
|
-
onConfirm={
|
|
83
|
-
|
|
99
|
+
onConfirm={async (value) => {
|
|
100
|
+
if (busy()) return
|
|
101
|
+
const err = validate(value)
|
|
102
|
+
if (err) return api.ui.toast({ variant: "error", message: err })
|
|
103
|
+
|
|
104
|
+
const abs = resolvePath(value)
|
|
105
|
+
const persist = remember()
|
|
106
|
+
|
|
107
|
+
setBusy(true)
|
|
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(() => {})
|
|
124
|
+
}}
|
|
125
|
+
onCancel={() => api.ui.dialog.clear()}
|
|
84
126
|
/>
|
|
85
127
|
)
|
|
86
128
|
}
|
|
87
129
|
|
|
88
|
-
function
|
|
89
|
-
|
|
130
|
+
function showListDirs(api: TuiPluginApi) {
|
|
131
|
+
const dirs = allDirs()
|
|
132
|
+
if (!dirs.length) return api.ui.toast({ variant: "info", message: "No directories added." })
|
|
133
|
+
api.ui.dialog.replace(() => (
|
|
134
|
+
<api.ui.DialogAlert
|
|
135
|
+
title={`Directories (${dirs.length})`}
|
|
136
|
+
message={dirs.join("\n")}
|
|
137
|
+
onConfirm={() => api.ui.dialog.clear()}
|
|
138
|
+
/>
|
|
139
|
+
))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function showRemoveDir(api: TuiPluginApi) {
|
|
143
|
+
const dirs = allDirs()
|
|
144
|
+
if (!dirs.length) return api.ui.toast({ variant: "info", message: "No directories to remove." })
|
|
145
|
+
api.ui.dialog.replace(() => (
|
|
146
|
+
<api.ui.DialogSelect
|
|
147
|
+
title="Remove directory"
|
|
148
|
+
options={dirs.map((d) => ({ title: d, value: d }))}
|
|
149
|
+
onSelect={(opt) => {
|
|
150
|
+
api.ui.dialog.replace(() => (
|
|
151
|
+
<api.ui.DialogConfirm
|
|
152
|
+
title="Remove directory"
|
|
153
|
+
message={`Remove ${opt.value}?`}
|
|
154
|
+
onConfirm={() => {
|
|
155
|
+
removeDir(opt.value as string)
|
|
156
|
+
api.ui.dialog.clear()
|
|
157
|
+
api.ui.toast({ variant: "success", message: `Removed ${opt.value}` })
|
|
158
|
+
}}
|
|
159
|
+
onCancel={() => showRemoveDir(api)}
|
|
160
|
+
/>
|
|
161
|
+
))
|
|
162
|
+
}}
|
|
163
|
+
/>
|
|
164
|
+
))
|
|
90
165
|
}
|
|
91
166
|
|
|
92
167
|
const tui: TuiPlugin = async (api) => {
|
|
93
168
|
api.command.register(() => [
|
|
94
|
-
{
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
description: "Add a working directory to the session",
|
|
98
|
-
category: "Directories",
|
|
99
|
-
slash: { name: "add-dir" },
|
|
100
|
-
onSelect: () => showDialog(api),
|
|
101
|
-
},
|
|
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} />) },
|
|
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) },
|
|
102
172
|
])
|
|
103
173
|
}
|
|
104
174
|
|
|
105
|
-
|
|
106
|
-
id: PLUGIN_ID,
|
|
107
|
-
tui,
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export default plugin
|
|
175
|
+
export default { id: ID, tui } satisfies TuiPluginModule & { id: string }
|
package/dist/types.d.ts
CHANGED
|
@@ -1,16 +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
|
-
export interface PermissionReplyBody {
|
|
12
|
-
response: "once" | "always" | "reject";
|
|
13
|
-
}
|
|
14
3
|
export interface PermissionEvent {
|
|
15
4
|
id: string;
|
|
16
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
|
-
}
|