diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index a0eb78a13e2a..64ba1d94687e 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -54,6 +54,9 @@ export const Flag = { get OPENCODE_DISABLE_PROJECT_CONFIG() { return truthy("OPENCODE_DISABLE_PROJECT_CONFIG") }, + get OPENCODE_DISABLE_PLUGIN_DEPS() { + return truthy("OPENCODE_DISABLE_PLUGIN_DEPS") + }, get OPENCODE_EXPERIMENTAL_REFERENCES() { return enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES") }, diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index a192a4b4684f..56dbe7ce63fa 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -30,6 +30,10 @@ const paths = { export const Path = paths +export function expandTilde(input: string) { + return input.startsWith("~") ? path.join(Path.home, input.slice(1)) : input +} + Flock.setGlobal({ state }) await Promise.all([ diff --git a/packages/core/src/plugin/skill/customize-opencode.md b/packages/core/src/plugin/skill/customize-opencode.md index c02ed72efb74..bef24dd4bfc8 100644 --- a/packages/core/src/plugin/skill/customize-opencode.md +++ b/packages/core/src/plugin/skill/customize-opencode.md @@ -63,6 +63,7 @@ Every field is optional. "model": "provider/model-id", "small_model": "provider/model-id", "default_agent": "agent-name", + "plans_directory": "~/plans", "shell": "/bin/zsh", "logLevel": "DEBUG" | "INFO" | "WARN" | "ERROR", "share": "manual" | "auto" | "disabled", @@ -430,6 +431,10 @@ When a user's config is broken and opencode won't start, these env vars help: and start from globals only. Run from the project directory, opencode loads, the user edits the broken file, then they restart without the flag. - `OPENCODE_CONFIG=/path/to/file.json`: load an additional explicit config. +- `OPENCODE_DISABLE_PLUGIN_DEPS=1`: skip the automatic `@opencode-ai/plugin` + dependency install (and generated `package.json`/`.gitignore`) into every + discovered `.opencode` config directory. Useful when these generated files + are unwanted in project directories. - `OPENCODE_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`: inject inline JSON as a final local-scope merge. - `OPENCODE_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins. diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 7ebb4b69b023..209646715d60 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -84,6 +84,10 @@ export const Info = Schema.Struct({ subagent_depth: Schema.optional(NonNegativeInt).annotate({ description: "Maximum subagent nesting depth. Defaults to 1, which prevents subagents from launching subagents.", }), + plans_directory: Schema.optional(Schema.String).annotate({ + description: + "Directory where plan mode files are written. Supports `~` expansion. Defaults to `/.opencode/plans` for git projects, or the global data directory otherwise.", + }), username: Schema.optional(Schema.String).annotate({ description: "Custom username to display in conversations instead of system username", }), diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 536a642fe49f..d4917bad28ba 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -98,6 +98,7 @@ const layer = Layer.effect( const state = yield* InstanceState.make( Effect.fn("Agent.state")(function* (ctx) { const cfg = yield* config.get() + const plansDir = cfg.plans_directory ? path.resolve(Global.expandTilde(cfg.plans_directory)) : undefined const skillDirs = yield* skill.dirs() const referenceDirs = Object.keys(cfg.references ?? cfg.reference ?? {}).length ? yield* Effect.gen(function* () { @@ -167,11 +168,13 @@ const layer = Layer.effect( }, external_directory: { [path.join(Global.Path.data, "plans", "*")]: "allow", + ...(plansDir ? { [path.join(plansDir, "*")]: "allow" } : {}), }, edit: { "*": "deny", [path.join(".opencode", "plans", "*.md")]: "allow", [path.relative(ctx.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow", + ...(plansDir ? { [path.join(plansDir, "*.md")]: "allow" } : {}), }, }), user, @@ -314,7 +317,7 @@ const layer = Layer.effect( }) const list = Effect.fnUntraced(function* () { - const cfg = yield* config.get() + const cfg = yield* config.get() return pipe( agents, values(), diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 9e10b67fe703..e7669d107749 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -12,6 +12,7 @@ import { Auth } from "../auth" import { Env } from "../env" import { applyEdits, modify } from "jsonc-parser" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" +import { Hash } from "@opencode-ai/core/util/hash" import { existsSync } from "fs" import { Account } from "@/account/account" import { isRecord } from "@/util/record" @@ -447,28 +448,47 @@ const layer = Layer.effect( } } - yield* ensureGitignore(dir).pipe(Effect.orDie) - - const dep = yield* npmSvc - .install(dir, { - add: [ - { - name: "@opencode-ai/plugin", - version: InstallationLocal ? undefined : InstallationVersion, - }, - ], - }) - .pipe( - Effect.exit, - Effect.tap((exit) => - Exit.isFailure(exit) - ? Effect.logWarning("background dependency install failed", { dir, error: String(exit.cause) }) - : Effect.void, - ), - Effect.asVoid, - Effect.forkDetach, - ) - deps.push(dep) + if (!Flag.OPENCODE_DISABLE_PLUGIN_DEPS) { + yield* ensureGitignore(dir).pipe(Effect.orDie) + + // Relocate the dependency tree into the XDG data dir so config + // dirs only get a node_modules symlink: bare imports from local + // plugins still resolve through parent-directory traversal while + // node_modules and manifests live outside ~/.config and project + // trees. Falls back to the config dir when symlinks are + // unavailable (e.g. unprivileged Windows) or when a real + // node_modules directory already exists there. + const store = path.join(Global.Path.data, "deps", Hash.fast(dir)) + const link = path.join(dir, "node_modules") + const linked = yield* Effect.tryPromise(async () => { + const existing = await fsNode.lstat(link).catch(() => undefined) + if (existing?.isSymbolicLink()) return + if (existing) throw new Error("unmanaged node_modules in config dir") + await fsNode.mkdir(store, { recursive: true }) + await fsNode.symlink(path.join(store, "node_modules"), link) + }).pipe(Effect.option) + + const dep = yield* npmSvc + .install(linked._tag === "Some" ? store : dir, { + add: [ + { + name: "@opencode-ai/plugin", + version: InstallationLocal ? undefined : InstallationVersion, + }, + ], + }) + .pipe( + Effect.exit, + Effect.tap((exit) => + Exit.isFailure(exit) + ? Effect.logWarning("background dependency install failed", { dir, error: String(exit.cause) }) + : Effect.void, + ), + Effect.asVoid, + Effect.forkDetach, + ) + deps.push(dep) + } result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir))) result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir))) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0f85d44f209b..06cb8e5fad84 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1181,6 +1181,7 @@ const layer = Layer.effect( Effect.provideService(RuntimeFlags.Service, flags), Effect.provideService(FSUtil.Service, fsys), Effect.provideService(Session.Service, sessions), + Effect.provideService(Config.Service, config), ) const msg: SessionV1.Assistant = { diff --git a/packages/opencode/src/session/reminders.ts b/packages/opencode/src/session/reminders.ts index f5484b8e9ba4..6c36fb12276d 100644 --- a/packages/opencode/src/session/reminders.ts +++ b/packages/opencode/src/session/reminders.ts @@ -4,6 +4,7 @@ import { Effect } from "effect" import { Agent } from "@/agent/agent" import { FSUtil } from "@opencode-ai/core/fs-util" import { InstanceState } from "@/effect/instance-state" +import { Config } from "@/config/config" import { RuntimeFlags } from "@/effect/runtime-flags" import { PartID } from "./schema" import { MessageV2 } from "./message-v2" @@ -51,7 +52,8 @@ export const apply = Effect.fn("SessionReminders.apply")(function* (input: { const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant") if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") { const ctx = yield* InstanceState.context - const plan = Session.plan(input.session, ctx) + const config = yield* Config.Service + const plan = Session.plan(input.session, ctx, (yield* config.get()).plans_directory) const exists = yield* fsys.existsSafe(plan) const part = yield* sessions.updatePart({ id: PartID.ascending(), @@ -70,7 +72,8 @@ export const apply = Effect.fn("SessionReminders.apply")(function* (input: { if (input.agent.name !== "plan" || assistantMessage?.info.agent === "plan") return input.messages const ctx = yield* InstanceState.context - const plan = Session.plan(input.session, ctx) + const config = yield* Config.Service + const plan = Session.plan(input.session, ctx, (yield* config.get()).plans_directory) const exists = yield* fsys.existsSafe(plan) if (!exists) yield* fsys.ensureDir(path.dirname(plan)).pipe(Effect.catch(Effect.die)) const part = yield* sessions.updatePart({ diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index a2a91cd47b5e..b94c18f84276 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -328,10 +328,12 @@ export const Event = { Error: SessionV1.Event.Error, } -export function plan(input: { slug: string; time: { created: number } }, instance: InstanceContext) { - const base = instance.project.vcs - ? path.join(instance.worktree, ".opencode", "plans") - : path.join(Global.Path.data, "plans") +export function plan(input: { slug: string; time: { created: number } }, instance: InstanceContext, plansDirectory?: string) { + const base = plansDirectory + ? path.resolve(Global.expandTilde(plansDirectory)) + : instance.project.vcs + ? path.join(instance.worktree, ".opencode", "plans") + : path.join(Global.Path.data, "plans") return path.join(base, [input.time.created, input.slug].join("-") + ".md") } diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index 3b5ed978545a..aae9b9e10ae2 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -5,6 +5,7 @@ import * as Tool from "./tool" import { Question } from "../question" import { Session } from "@/session/session" import { MessageV2 } from "../session/message-v2" +import { Config } from "@/config/config" import { Provider } from "@/provider/provider" import { InstanceState } from "@/effect/instance-state" import { MessageID, PartID } from "../session/schema" @@ -18,6 +19,7 @@ export const PlanExitTool = Tool.define( const session = yield* Session.Service const question = yield* Question.Service const provider = yield* Provider.Service + const config = yield* Config.Service return { description: EXIT_DESCRIPTION, @@ -26,7 +28,7 @@ export const PlanExitTool = Tool.define( Effect.gen(function* () { const instance = yield* InstanceState.context const info = yield* session.get(ctx.sessionID) - const plan = path.relative(instance.worktree, Session.plan(info, instance)) + const plan = path.relative(instance.worktree, Session.plan(info, instance, (yield* config.get()).plans_directory)) const answers = yield* question.ask({ sessionID: ctx.sessionID, questions: [ diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 8d5baede50fd..9f311d11c51d 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -36,6 +36,7 @@ import fs from "fs/promises" import os from "os" import { pathToFileURL } from "url" import { Global } from "@opencode-ai/core/global" +import { Hash } from "@opencode-ai/core/util/hash" import { ProjectV2 } from "@opencode-ai/core/project" import { Filesystem } from "@/util/filesystem" import { ConfigPlugin } from "@/config/plugin" @@ -182,6 +183,20 @@ const withGlobalConfigDir = (dir: string, effect: Effect.Effect(dir: string, effect: Effect.Effect) => + Effect.acquireUseRelease( + Effect.gen(function* () { + const previous = Global.Path.data + ;(Global.Path as { data: string }).data = dir + return previous + }), + () => effect, + (previous) => + Effect.gen(function* () { + ;(Global.Path as { data: string }).data = previous + }), + ) + const withGlobalConfig = ( input: { config?: object; name?: string }, fn: (input: { dir: string }) => Effect.Effect, @@ -1101,12 +1116,17 @@ it.effect("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR if (process.platform === "win32") return const dir = yield* tmpdirScoped() + const dataDir = yield* tmpdirScoped() const readonly = path.join(dir, "readonly") yield* FSUtil.use.ensureDir(readonly) yield* FSUtil.use.chmod(readonly, 0o555) yield* Effect.addFinalizer(() => FSUtil.use.chmod(readonly, 0o755).pipe(Effect.ignore)) - yield* withProcessEnv("OPENCODE_CONFIG_DIR", readonly, Config.use.get().pipe(provideInstanceEffect(dir))) + yield* withProcessEnv( + "OPENCODE_CONFIG_DIR", + readonly, + withGlobalDataDir(dataDir, Config.use.get().pipe(provideInstanceEffect(dir))), + ) }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))), ) @@ -1115,21 +1135,30 @@ it.effect("ignores an inaccessible OPENCODE_CONFIG_DIR", () => if (process.platform === "win32") return const dir = yield* tmpdirScoped() + const dataDir = yield* tmpdirScoped() const configDir = path.join(dir, "inaccessible") yield* FSUtil.use.ensureDir(configDir) yield* FSUtil.use.chmod(configDir, 0o000) yield* Effect.addFinalizer(() => FSUtil.use.chmod(configDir, 0o755).pipe(Effect.ignore)) - yield* withProcessEnv("OPENCODE_CONFIG_DIR", configDir, Config.use.get().pipe(provideInstanceEffect(dir))) + yield* withProcessEnvs( + { OPENCODE_CONFIG_DIR: configDir }, + withGlobalDataDir(dataDir, Config.use.get().pipe(provideInstanceEffect(dir))), + ) }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))), ) it.effect("creates a missing OPENCODE_CONFIG_DIR", () => Effect.gen(function* () { const dir = yield* tmpdirScoped() + const dataDir = yield* tmpdirScoped() const configDir = path.join(dir, "configdir") - yield* withProcessEnv("OPENCODE_CONFIG_DIR", configDir, Config.use.get().pipe(provideInstanceEffect(dir))) + yield* withProcessEnv( + "OPENCODE_CONFIG_DIR", + configDir, + withGlobalDataDir(dataDir, Config.use.get().pipe(provideInstanceEffect(dir))), + ) expect(yield* FSUtil.use.readFileString(path.join(configDir, ".gitignore"))).toContain("node_modules") }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))), @@ -1138,18 +1167,72 @@ it.effect("creates a missing OPENCODE_CONFIG_DIR", () => it.effect("installs dependencies in writable OPENCODE_CONFIG_DIR", () => Effect.gen(function* () { const dir = yield* tmpdirScoped() + const dataDir = yield* tmpdirScoped() const configDir = path.join(dir, "configdir") yield* FSUtil.use.ensureDir(configDir) - yield* withProcessEnv( - "OPENCODE_CONFIG_DIR", - configDir, + yield* withGlobalDataDir( + dataDir, + withProcessEnv( + "OPENCODE_CONFIG_DIR", + configDir, + Config.Service.use((svc) => svc.get().pipe(Effect.andThen(svc.waitForDependencies()))).pipe( + provideInstanceEffect(dir), + ), + ), + ) + + expect(yield* FSUtil.use.readFileString(path.join(configDir, ".gitignore"))).toContain("package-lock.json") + + const link = path.join(configDir, "node_modules") + expect((yield* Effect.promise(() => fs.lstat(link))).isSymbolicLink()).toBe(true) + const store = path.join(dataDir, "deps", Hash.fast(configDir)) + expect(yield* Effect.promise(() => fs.readlink(link))).toBe(path.join(store, "node_modules")) + expect(yield* FSUtil.use.isDir(store)).toBe(true) + expect(yield* FSUtil.use.existsSafe(path.join(configDir, "package.json"))).toBe(false) + }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))), +) + +it.effect("existing real node_modules in a config dir is left untouched", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const dataDir = yield* tmpdirScoped() + const configDir = path.join(dir, "configdir") + yield* FSUtil.use.ensureDir(configDir) + yield* FSUtil.use.writeWithDirs(path.join(configDir, "node_modules", "marker.txt"), "keep") + + yield* withGlobalDataDir( + dataDir, + withProcessEnv( + "OPENCODE_CONFIG_DIR", + configDir, + Config.Service.use((svc) => svc.get().pipe(Effect.andThen(svc.waitForDependencies()))).pipe( + provideInstanceEffect(dir), + ), + ), + ) + + expect(yield* FSUtil.use.readFileString(path.join(configDir, "node_modules", "marker.txt"))).toBe("keep") + expect(yield* FSUtil.use.existsSafe(path.join(dataDir, "deps", Hash.fast(configDir)))).toBe(false) + }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))), +) + +it.effect("OPENCODE_DISABLE_PLUGIN_DEPS skips dependency install and gitignore", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const configDir = path.join(dir, "configdir") + yield* FSUtil.use.ensureDir(configDir) + + yield* withProcessEnvs( + { OPENCODE_DISABLE_PLUGIN_DEPS: "1", OPENCODE_CONFIG_DIR: configDir }, Config.Service.use((svc) => svc.get().pipe(Effect.andThen(svc.waitForDependencies()))).pipe( provideInstanceEffect(dir), ), ) - expect(yield* FSUtil.use.readFileString(path.join(configDir, ".gitignore"))).toContain("package-lock.json") + expect(yield* FSUtil.use.existsSafe(path.join(configDir, ".gitignore"))).toBe(false) + expect(yield* FSUtil.use.existsSafe(path.join(configDir, "package.json"))).toBe(false) + expect(yield* FSUtil.use.existsSafe(path.join(configDir, "node_modules"))).toBe(false) }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))), ) diff --git a/packages/opencode/test/session/plan.test.ts b/packages/opencode/test/session/plan.test.ts new file mode 100644 index 000000000000..31adf4b16b52 --- /dev/null +++ b/packages/opencode/test/session/plan.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Global } from "@opencode-ai/core/global" +import type { InstanceContext } from "../../src/project/instance-context" +import { Session } from "../../src/session/session" + +const instance = (vcs: string | undefined, worktree = "/tmp/project") => + ({ directory: worktree, worktree, project: { vcs } }) as unknown as InstanceContext + +const input = { slug: "test-plan", time: { created: 1700000000000 } } + +describe("Session.plan", () => { + test("defaults to /.opencode/plans for git projects", () => { + const file = Session.plan(input, instance("git")) + expect(file).toBe(path.join("/tmp/project", ".opencode", "plans", "1700000000000-test-plan.md")) + }) + + test("defaults to the global data dir for non-git projects", () => { + const file = Session.plan(input, instance(undefined)) + expect(file).toBe(path.join(Global.Path.data, "plans", "1700000000000-test-plan.md")) + }) + + test("honors plans_directory override", () => { + const file = Session.plan(input, instance("git"), "/custom/plans") + expect(file).toBe(path.join("/custom/plans", "1700000000000-test-plan.md")) + }) + + test("honors plans_directory override for non-git projects too", () => { + const file = Session.plan(input, instance(undefined), "/custom/plans") + expect(file).toBe(path.join("/custom/plans", "1700000000000-test-plan.md")) + }) + + test("expands ~ in plans_directory", () => { + process.env.OPENCODE_TEST_HOME = "/home/testuser" + try { + const file = Session.plan(input, instance("git"), "~/plans") + expect(file).toBe(path.join("/home/testuser", "plans", "1700000000000-test-plan.md")) + } finally { + delete process.env.OPENCODE_TEST_HOME + } + }) + + test("expands bare ~ in plans_directory", () => { + process.env.OPENCODE_TEST_HOME = "/home/testuser" + try { + const file = Session.plan(input, instance("git"), "~") + expect(file).toBe(path.join("/home/testuser", "1700000000000-test-plan.md")) + } finally { + delete process.env.OPENCODE_TEST_HOME + } + }) + + test("resolves relative plans_directory to an absolute path", () => { + const file = Session.plan(input, instance("git"), "relative/plans") + expect(path.isAbsolute(file)).toBe(true) + expect(file.endsWith(path.join("relative", "plans", "1700000000000-test-plan.md"))).toBe(true) + }) +}) diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 318f013b4119..6ce1fae8040f 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -642,6 +642,19 @@ Note that disabling snapshots means changes made by the agent cannot be rolled b --- +### Plans directory + +Plan files are written to `/.opencode/plans/` in git projects and to the OpenCode data directory otherwise. Use the `plans_directory` option to store plan files in a custom location instead, for example the global data directory, so project trees stay untouched. Relative and `~` paths are supported. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "plans_directory": "~/.local/share/opencode/plans" +} +``` + +--- + ### Autoupdate OpenCode will automatically download any new updates when it starts up. You can disable this with the `autoupdate` option. @@ -811,6 +824,14 @@ Place plugin files in `.opencode/plugins/` or `~/.config/opencode/plugins/`. You --- +### Plugin dependencies + +When OpenCode starts, it installs the plugin SDK (`@opencode-ai/plugin`) required by local plugins into a per-directory store under the OpenCode data directory (`~/.local/share/opencode/deps/`), and config directories only get a `node_modules` symlink pointing there. Nothing is written into project trees beyond that symlink, and the generated files are covered by the `.gitignore` inside each config directory. + +Set `OPENCODE_DISABLE_PLUGIN_DEPS=1` in your environment to skip the automatic dependency install entirely. Config discovery (agents, commands, plugins) is unaffected. + +--- + ### Instructions You can configure the instructions for the model you're using through the `instructions` option.