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..28eba74ee98c 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -447,28 +447,30 @@ 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) + + 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) + } 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..f5f91c3ce6de 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1153,6 +1153,25 @@ it.effect("installs dependencies in writable OPENCODE_CONFIG_DIR", () => }).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.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))), +) + // Note: deduplication and serialization of npm installs is now handled by the // core Npm.Service (via EffectFlock). Those behaviors are tested in the core // package's npm tests, not here. 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) + }) +})