opencode-dir 0.1.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.
Files changed (4) hide show
  1. package/README.md +44 -0
  2. package/index.ts +109 -0
  3. package/lib.ts +319 -0
  4. package/package.json +37 -0
package/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # opencode-dir
2
+
3
+ Directory operations for [opencode](https://opencode.ai) sessions.
4
+
5
+ When working across monorepos or multiple repositories, sessions get stuck in the directory they were started in. This plugin adds `/cd` and `/mv` commands to move sessions between directories.
6
+
7
+ ## Commands
8
+
9
+ ### `/cd <path>`
10
+
11
+ Change the session's working directory. Updates the session metadata without rewriting message history. Useful when you want the session to continue in a new location but don't need old file references updated.
12
+
13
+ ### `/mv <path>`
14
+
15
+ Move the session to a new directory. Updates the session metadata **and** rewrites `path.cwd` and `path.root` in all assistant messages to point to the new location. Use this when relocating a session and its full context to a different repo.
16
+
17
+ ## Install
18
+
19
+ Add to your `opencode.json`:
20
+
21
+ ```json
22
+ {
23
+ "plugin": ["opencode-dir"]
24
+ }
25
+ ```
26
+
27
+ ## Requirements
28
+
29
+ - The **target** directory must be inside a git repository. Moving to a non-git directory is not supported because opencode groups all non-git sessions under a single `"global"` project, making reliable moves impossible.
30
+ - Sessions started in non-git directories can be moved **into** a git repo — this is a valid way to "adopt" a global session into a proper project.
31
+
32
+ ## How it works
33
+
34
+ 1. Resolves the target directory and computes its project ID (the repo's initial commit hash, matching opencode's own logic)
35
+ 2. Updates the session's `directory` and `project_id` in the database
36
+ 3. For `/mv`, rewrites `path.cwd` and `path.root` in all assistant messages from the old directory to the new one
37
+ 4. Intercepts subsequent tool calls (`bash`, `read`, `write`, `edit`, `glob`, `grep`) and rewrites file paths from the old directory to the new one — tools operate in the new directory immediately without a restart
38
+ 5. Writes an `external_directory` permission rule on the session so tools can access the new directory without prompts
39
+
40
+ For a fully clean environment, restart opencode in the target directory after the move.
41
+
42
+ ## License
43
+
44
+ MIT
package/index.ts ADDED
@@ -0,0 +1,109 @@
1
+ import { type Plugin } from "@opencode-ai/plugin"
2
+ import { mkdirSync, appendFileSync } from "fs"
3
+ import {
4
+ type Override,
5
+ type ExecResult,
6
+ loadOverrides,
7
+ persistOverrides,
8
+ execMove,
9
+ rewritePath,
10
+ PATH_TOOLS,
11
+ } from "./lib"
12
+
13
+ const STATE_DIR = `${process.env.XDG_DATA_HOME || process.env.HOME + "/.local/share"}/opencode`
14
+ const LOG_FILE = `${STATE_DIR}/opencode-dir-debug.log`
15
+ const OVERRIDES_FILE = `${STATE_DIR}/opencode-dir-overrides.json`
16
+ const DEBUG = !!process.env.OPENCODE_DIR_DEBUG
17
+
18
+ function log(...args: unknown[]) {
19
+ if (!DEBUG) return
20
+ const ts = new Date().toISOString()
21
+ const line = args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")
22
+ appendFileSync(LOG_FILE, `[${ts}] ${line}\n`)
23
+ }
24
+
25
+ const dirOverrides: Map<string, Override> = loadOverrides(OVERRIDES_FILE)
26
+
27
+ /**
28
+ * opencode-dir plugin — adds `/cd` and `/mv` commands for moving sessions
29
+ * between directories at runtime.
30
+ *
31
+ * `/cd <path>` changes where tools operate without touching message history.
32
+ * `/mv <path>` does the same and rewrites historical paths in messages.
33
+ */
34
+ export const OpencodeDir: Plugin = async ({ client }) => {
35
+ mkdirSync(STATE_DIR, { recursive: true })
36
+ log("plugin loaded", { overridesRecovered: dirOverrides.size })
37
+
38
+ return {
39
+ "command.execute.before": async (input, output) => {
40
+ log("command.execute.before", { command: input.command, sessionID: input.sessionID })
41
+ if (input.command !== "cd" && input.command !== "mv") return
42
+
43
+ const targetPath = input.arguments.trim()
44
+ if (!targetPath) {
45
+ output.parts.splice(0)
46
+ output.parts.push({ type: "text", text: `Usage: /${input.command} <path>` })
47
+ return
48
+ }
49
+
50
+ let exec: ExecResult
51
+ try {
52
+ exec = execMove(input.sessionID, targetPath, input.command === "mv")
53
+ } catch (e: unknown) {
54
+ exec = { result: `Error: ${e instanceof Error ? e.message : String(e)}` }
55
+ }
56
+
57
+ output.parts.splice(0)
58
+ output.parts.push({ type: "text", text: exec.result })
59
+
60
+ if (exec.oldDir && exec.newDir) {
61
+ log("storing override", { sessionID: input.sessionID, oldDir: exec.oldDir, newDir: exec.newDir })
62
+ dirOverrides.set(input.sessionID, { oldDir: exec.oldDir, newDir: exec.newDir })
63
+ persistOverrides(OVERRIDES_FILE, dirOverrides)
64
+
65
+ await client.tui.showToast({
66
+ body: {
67
+ title: "Session directory changed",
68
+ message: `Now operating in ${exec.newDir}.\nRestart opencode there for a clean slate.`,
69
+ variant: "info",
70
+ duration: 8000,
71
+ },
72
+ }).catch(() => {})
73
+ }
74
+ },
75
+
76
+ "tool.execute.before": async (input, output) => {
77
+ const override = dirOverrides.get(input.sessionID)
78
+ if (!override) return
79
+ log("tool.execute.before", { tool: input.tool, sessionID: input.sessionID })
80
+
81
+ const { oldDir, newDir } = override
82
+ const pathKeys = PATH_TOOLS[input.tool]
83
+ if (pathKeys) {
84
+ for (const key of pathKeys) {
85
+ if (typeof output.args[key] === "string") {
86
+ output.args[key] = rewritePath(output.args[key], oldDir, newDir)
87
+ }
88
+ }
89
+ }
90
+
91
+ if (input.tool === "bash") {
92
+ if (!output.args.workdir) output.args.workdir = newDir
93
+ if (typeof output.args.command === "string") {
94
+ output.args.command = output.args.command.replaceAll(oldDir, newDir)
95
+ }
96
+ }
97
+ },
98
+
99
+ "shell.env": async (input, output) => {
100
+ const override = dirOverrides.get(input.sessionID ?? "")
101
+ if (!override) return
102
+
103
+ const { oldDir, newDir } = override
104
+ if (input.cwd === oldDir || input.cwd.startsWith(oldDir + "/")) {
105
+ output.env.PWD = rewritePath(input.cwd, oldDir, newDir)
106
+ }
107
+ },
108
+ }
109
+ }
package/lib.ts ADDED
@@ -0,0 +1,319 @@
1
+ import { Database } from "bun:sqlite"
2
+ import { resolve } from "path"
3
+ import { existsSync, readFileSync, writeFileSync } from "fs"
4
+ import { execSync } from "child_process"
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Overrides
8
+ // ---------------------------------------------------------------------------
9
+
10
+ export interface Override {
11
+ oldDir: string
12
+ newDir: string
13
+ }
14
+
15
+ /** Loads session directory overrides from a JSON file on disk. */
16
+ export function loadOverrides(path: string): Map<string, Override> {
17
+ try {
18
+ const entries = JSON.parse(readFileSync(path, "utf-8")) as [string, Override][]
19
+ return new Map(entries)
20
+ } catch {
21
+ return new Map()
22
+ }
23
+ }
24
+
25
+ /** Persists session directory overrides to disk (owner-only). */
26
+ export function persistOverrides(path: string, map: Map<string, Override>) {
27
+ writeFileSync(path, JSON.stringify([...map.entries()]), { mode: 0o600 })
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Git
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * Resolves the initial commit hash for a git repository, matching
36
+ * opencode's `Project.fromDirectory` logic:
37
+ * `git rev-list --max-parents=0 --all`, split/filter/sort, take first.
38
+ */
39
+ export function getInitialCommit(dir: string): string | null {
40
+ try {
41
+ const output = execSync("git rev-list --max-parents=0 --all", {
42
+ cwd: dir,
43
+ encoding: "utf-8",
44
+ stdio: ["pipe", "pipe", "ignore"],
45
+ })
46
+ return output.split("\n").filter(Boolean).map((x) => x.trim()).sort()[0] ?? null
47
+ } catch {
48
+ return null
49
+ }
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Database
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /** Creates the minimal schema required by the plugin in a fresh database. */
57
+ export function createSchema(db: Database) {
58
+ db.exec(`
59
+ CREATE TABLE IF NOT EXISTS project (
60
+ id TEXT PRIMARY KEY,
61
+ worktree TEXT NOT NULL,
62
+ vcs TEXT NOT NULL,
63
+ name TEXT,
64
+ icon_url TEXT,
65
+ icon_color TEXT,
66
+ time_created INTEGER NOT NULL,
67
+ time_updated INTEGER NOT NULL,
68
+ time_initialized INTEGER,
69
+ sandboxes TEXT NOT NULL DEFAULT '[]',
70
+ commands TEXT
71
+ );
72
+ CREATE TABLE IF NOT EXISTS session (
73
+ id TEXT PRIMARY KEY,
74
+ project_id TEXT NOT NULL REFERENCES project(id) ON DELETE CASCADE,
75
+ workspace_id TEXT,
76
+ parent_id TEXT,
77
+ slug TEXT NOT NULL,
78
+ directory TEXT NOT NULL,
79
+ title TEXT NOT NULL,
80
+ version TEXT NOT NULL,
81
+ share_url TEXT,
82
+ summary_additions INTEGER,
83
+ summary_deletions INTEGER,
84
+ summary_files INTEGER,
85
+ summary_diffs TEXT,
86
+ revert TEXT,
87
+ permission TEXT,
88
+ time_created INTEGER NOT NULL,
89
+ time_updated INTEGER NOT NULL,
90
+ time_compacting INTEGER,
91
+ time_archived INTEGER
92
+ );
93
+ CREATE TABLE IF NOT EXISTS message (
94
+ id TEXT PRIMARY KEY,
95
+ session_id TEXT NOT NULL REFERENCES session(id) ON DELETE CASCADE,
96
+ time_created INTEGER NOT NULL,
97
+ time_updated INTEGER NOT NULL,
98
+ data TEXT NOT NULL
99
+ );
100
+ `)
101
+ }
102
+
103
+ /** Creates a project row if one does not already exist. */
104
+ export function ensureProject(db: Database, projectId: string, worktree: string) {
105
+ if (db.query("SELECT id FROM project WHERE id = ?").get(projectId)) return
106
+
107
+ const now = Date.now()
108
+ db.run(
109
+ `INSERT INTO project (id, worktree, vcs, name, icon_url, icon_color, time_created, time_updated, time_initialized, sandboxes, commands)
110
+ VALUES (?, ?, 'git', NULL, NULL, 'blue', ?, ?, NULL, '[]', NULL)`,
111
+ [projectId, worktree, now, now],
112
+ )
113
+ }
114
+
115
+ /**
116
+ * Updates a session's directory, project, and permission in one statement.
117
+ *
118
+ * Writes an `external_directory` allow rule to `session.permission` so
119
+ * `PermissionNext` auto-allows tool access to the target tree.
120
+ * `prompt()` loads the session AFTER `command.execute.before` fires,
121
+ * so the rule is available when tools run.
122
+ */
123
+ export function updateSession(db: Database, sessionId: string, newDir: string, newProjectId: string): number {
124
+ const permission = JSON.stringify([
125
+ { permission: "external_directory", pattern: newDir + "/*", action: "allow" },
126
+ ])
127
+ return db.run(
128
+ `UPDATE session SET directory = ?, project_id = ?, permission = ?, time_updated = ? WHERE id = ?`,
129
+ [newDir, newProjectId, permission, Date.now(), sessionId],
130
+ ).changes
131
+ }
132
+
133
+ /**
134
+ * Rewrites `path.cwd` and `path.root` in message data from `oldDir` to
135
+ * `newDir`. Runs inside a transaction for atomicity.
136
+ */
137
+ export function rewriteMessages(
138
+ db: Database,
139
+ sessionId: string,
140
+ oldDir: string,
141
+ newDir: string,
142
+ ): { total: number; rewritten: number } {
143
+ const messages = db
144
+ .query("SELECT id, data FROM message WHERE session_id = ?")
145
+ .all(sessionId) as { id: string; data: string }[]
146
+
147
+ let rewritten = 0
148
+ const update = db.prepare("UPDATE message SET data = ? WHERE id = ?")
149
+ const tx = db.transaction(() => {
150
+ for (const msg of messages) {
151
+ const data = JSON.parse(msg.data)
152
+ let changed = false
153
+
154
+ if (data.path) {
155
+ if (data.path.cwd === oldDir) {
156
+ data.path.cwd = newDir
157
+ changed = true
158
+ }
159
+ if (data.path.root === oldDir) {
160
+ data.path.root = newDir
161
+ changed = true
162
+ }
163
+ }
164
+
165
+ if (changed) {
166
+ update.run(JSON.stringify(data), msg.id)
167
+ rewritten++
168
+ }
169
+ }
170
+ })
171
+ tx()
172
+
173
+ return { total: messages.length, rewritten }
174
+ }
175
+
176
+ /**
177
+ * Resolves a user-provided path to an absolute directory and its git
178
+ * project ID. Throws if the path does not exist or is not inside a
179
+ * git repository.
180
+ */
181
+ export function resolveTarget(targetPath: string): { dir: string; projectId: string } {
182
+ const dir = resolve(targetPath.replace(/^~/, process.env.HOME || "/root"))
183
+
184
+ if (!existsSync(dir)) {
185
+ throw new Error(`Directory does not exist: ${dir}`)
186
+ }
187
+
188
+ const projectId = getInitialCommit(dir)
189
+ if (!projectId) {
190
+ throw new Error(
191
+ `Target directory is not inside a git repository.\n` +
192
+ `/cd and /mv only work with git repos — non-git directories ` +
193
+ `share a single "global" project in opencode, making session moves unreliable.`,
194
+ )
195
+ }
196
+
197
+ return { dir, projectId }
198
+ }
199
+
200
+ /** Reads session directory and project ID from the database. */
201
+ export function getSessionInfo(
202
+ db: Database,
203
+ sessionId: string,
204
+ ): { directory: string; projectId: string } | null {
205
+ const row = db
206
+ .query("SELECT directory, project_id FROM session WHERE id = ?")
207
+ .get(sessionId) as { directory: string; project_id: string } | null
208
+ if (!row) return null
209
+ return { directory: row.directory, projectId: row.project_id }
210
+ }
211
+
212
+ /**
213
+ * Scans early assistant messages for `path.cwd` to determine the
214
+ * directory the session was originally operating in.
215
+ */
216
+ export function getCurrentDirectory(db: Database, sessionId: string): string | null {
217
+ const rows = db
218
+ .query("SELECT data FROM message WHERE session_id = ? ORDER BY rowid ASC LIMIT 10")
219
+ .all(sessionId) as { data: string }[]
220
+
221
+ for (const row of rows) {
222
+ try {
223
+ const data = JSON.parse(row.data)
224
+ if (data.path?.cwd) return data.path.cwd
225
+ } catch {
226
+ continue
227
+ }
228
+ }
229
+
230
+ return null
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Core operation
235
+ // ---------------------------------------------------------------------------
236
+
237
+ export interface ExecResult {
238
+ result: string
239
+ oldDir?: string
240
+ newDir?: string
241
+ }
242
+
243
+ /**
244
+ * Moves a session to a new directory.
245
+ *
246
+ * @param rewrite - When true (`/mv`), rewrites message history paths.
247
+ * When false (`/cd`), leaves history intact.
248
+ * @param db - Optional database instance (uses default path if omitted).
249
+ */
250
+ export function execMove(
251
+ sessionId: string,
252
+ targetPath: string,
253
+ rewrite: boolean,
254
+ db?: Database,
255
+ ): ExecResult {
256
+ const { dir, projectId } = resolveTarget(targetPath)
257
+ const owned = !db
258
+ if (!db) {
259
+ const stateDir = `${process.env.XDG_DATA_HOME || process.env.HOME + "/.local/share"}/opencode`
260
+ db = new Database(`${stateDir}/opencode.db`)
261
+ }
262
+
263
+ try {
264
+ const session = getSessionInfo(db, sessionId)
265
+ if (!session) {
266
+ return { result: `Error: session ${sessionId} not found in database.` }
267
+ }
268
+
269
+ const currentDir = getCurrentDirectory(db, sessionId) ?? session.directory
270
+ if (dir === currentDir) {
271
+ return { result: `Already in ${dir} — no change needed.` }
272
+ }
273
+
274
+ ensureProject(db, projectId, dir)
275
+ const changes = updateSession(db, sessionId, dir, projectId)
276
+ if (changes === 0) {
277
+ return { result: `Error: session ${sessionId} not found in database.` }
278
+ }
279
+
280
+ const lines = rewrite
281
+ ? (() => {
282
+ const { total, rewritten } = rewriteMessages(db!, sessionId, currentDir, dir)
283
+ return [
284
+ `Session moved: ${currentDir} -> ${dir}`,
285
+ `Project: ${projectId}`,
286
+ `Messages: ${rewritten}/${total} rewritten`,
287
+ ]
288
+ })()
289
+ : [`Session directory changed: ${currentDir} -> ${dir}`, `Project: ${projectId}`]
290
+
291
+ lines.push("", `Tools will now operate in ${dir} for this session.`)
292
+ lines.push(`Restart opencode in the new directory for a clean slate.`)
293
+
294
+ return { oldDir: currentDir, newDir: dir, result: lines.join("\n") }
295
+ } finally {
296
+ if (owned) db.close()
297
+ }
298
+ }
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // Path rewriting
302
+ // ---------------------------------------------------------------------------
303
+
304
+ /** Replaces `oldDir` prefix with `newDir` in a file path. */
305
+ export function rewritePath(filePath: string, oldDir: string, newDir: string): string {
306
+ if (filePath === oldDir) return newDir
307
+ if (filePath.startsWith(oldDir + "/")) return newDir + filePath.slice(oldDir.length)
308
+ return filePath
309
+ }
310
+
311
+ /** Map of tool IDs to arg keys that carry file paths. */
312
+ export const PATH_TOOLS: Record<string, string[]> = {
313
+ read: ["filePath"],
314
+ write: ["filePath"],
315
+ edit: ["filePath"],
316
+ glob: ["path"],
317
+ grep: ["path"],
318
+ bash: ["workdir"],
319
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "opencode-dir",
3
+ "version": "0.1.0",
4
+ "description": "Directory operations for opencode sessions",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "main": "index.ts",
8
+ "files": [
9
+ "index.ts",
10
+ "lib.ts"
11
+ ],
12
+ "scripts": {
13
+ "test": "bun test"
14
+ },
15
+ "keywords": [
16
+ "opencode",
17
+ "opencode-plugin",
18
+ "cd",
19
+ "mv",
20
+ "directory",
21
+ "monorepo"
22
+ ],
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/adiled/opencode-dir.git"
26
+ },
27
+ "homepage": "https://github.com/adiled/opencode-dir#readme",
28
+ "bugs": {
29
+ "url": "https://github.com/adiled/opencode-dir/issues"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "peerDependencies": {
35
+ "@opencode-ai/plugin": ">=1.2.0"
36
+ }
37
+ }