Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/core/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
},
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/plugin/skill/customize-opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/v1/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<worktree>/.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",
}),
Expand Down
5 changes: 4 additions & 1 deletion packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ const layer = Layer.effect(
const state = yield* InstanceState.make<State>(
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* () {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
64 changes: 42 additions & 22 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)))
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
7 changes: 5 additions & 2 deletions packages/opencode/src/session/reminders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(),
Expand All @@ -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({
Expand Down
10 changes: 6 additions & 4 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/tool/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand All @@ -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: [
Expand Down
97 changes: 90 additions & 7 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -182,6 +183,20 @@ const withGlobalConfigDir = <A, E, R>(dir: string, effect: Effect.Effect<A, E, R
}),
)

const withGlobalDataDir = <A, E, R>(dir: string, effect: Effect.Effect<A, E, R>) =>
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 = <A, E, R>(
input: { config?: object; name?: string },
fn: (input: { dir: string }) => Effect.Effect<A, E, R>,
Expand Down Expand Up @@ -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))),
)

Expand All @@ -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))),
Expand All @@ -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))),
)

Expand Down
Loading
Loading