Skip to content
Open
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
31 changes: 24 additions & 7 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import path from "path"
import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { Permission } from "@opencode-ai/schema/permission"
import { Flag } from "./flag/flag"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { Location } from "./location"
Expand Down Expand Up @@ -144,19 +145,23 @@ const layer = Layer.effect(
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)

const loadFile = Effect.fnUntraced(function* (filepath: string) {
const text = yield* fs.readFileStringSafe(filepath)
if (!text) return

const parseInfo = (text: string) => {
const errors: ParseError[] = []
const input: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return
if (errors.length) return undefined

const info = Option.getOrUndefined(
return Option.getOrUndefined(
ConfigMigrateV1.isV1(input)
? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo))
: decodeInfo(input),
)
}

const loadFile = Effect.fnUntraced(function* (filepath: string) {
const text = yield* fs.readFileStringSafe(filepath)
if (!text) return

const info = parseInfo(text)
if (!info) return
return new Document({ type: "document", path: filepath, info })
})
Expand Down Expand Up @@ -198,9 +203,21 @@ const layer = Layer.effect(
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
)
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
// Test/injection affordance shared with packages/opencode's own config
// loader: config content passed directly via env instead of a file on
// disk. Applied last (highest priority), same as the opencode-side loader.
const injectedInfo = Flag.OPENCODE_CONFIG_CONTENT ? parseInfo(Flag.OPENCODE_CONFIG_CONTENT) : undefined
const injected = injectedInfo
? new Document({ type: "document", path: "OPENCODE_CONFIG_CONTENT", info: injectedInfo })
: undefined
// Apply general settings first and more specific settings last:
// global config, project files, then `.opencode` files.
const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()]
const configs = [
...(supplementary[0] ?? []),
...direct,
...supplementary.slice(1).flat(),
...(injected ? [injected] : []),
]
// Rules use the opposite order so a user-global rule can override a
// repository rule. Statement order inside each file stays unchanged.
yield* policy.load(
Expand Down
51 changes: 51 additions & 0 deletions packages/core/src/config/plugin/external-skill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
export * as ConfigExternalSkillPlugin from "./external-skill"

import { define } from "../../plugin/internal"
import path from "path"
import { Effect } from "effect"
import { Flag } from "../../flag/flag"
import { FSUtil } from "../../fs-util"
import { Global } from "../../global"
import { Location } from "../../location"
import { AbsolutePath } from "../../schema"
import { SkillV2 } from "../../skill"

const CLAUDE_DIR = ".claude"
const AGENTS_DIR = ".agents"

export const Plugin = define({
id: "config-external-skill",
effect: Effect.fn(function* (ctx) {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const disableExternalSkills = Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS
const disableClaudeCodeSkills = Flag.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS

yield* ctx.skill.transform(
Effect.fn(function* (draft) {
if (disableExternalSkills) return

const externalDirs: string[] = []
if (!disableClaudeCodeSkills) externalDirs.push(CLAUDE_DIR)
externalDirs.push(AGENTS_DIR)

for (const dir of externalDirs) {
const root = path.join(global.home, dir, "skills")
if (!(yield* fs.isDir(root))) continue
draft.source(SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(root) }))
}

const upDirs = yield* fs
.up({ targets: externalDirs, start: location.directory, stop: location.project.directory })
.pipe(Effect.catch(() => Effect.succeed([] as string[])))

for (const root of upDirs) {
const skillsRoot = path.join(root, "skills")
if (!(yield* fs.isDir(skillsRoot))) continue
draft.source(SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(skillsRoot) }))
}
}),
)
}),
})
39 changes: 32 additions & 7 deletions packages/core/src/filesystem/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export * as Watcher from "./watcher"
import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher"
import { makeLocationNode } from "../effect/app-node"
import { Cause, Context, Effect, Layer } from "effect"
import { Cause, Context, Effect, Layer, Scope } from "effect"
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
import path from "path"
import { Config } from "../config"
Expand Down Expand Up @@ -50,14 +50,18 @@ function protecteds(dir: string) {

export const hasNativeBinding = () => !!watcher()

export interface Interface {}
export type Unsubscribe = Effect.Effect<void>

export interface Interface {
readonly watch: (directory: string, ignore?: string[]) => Effect.Effect<Unsubscribe, never, Scope.Scope>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileWatcher") {}

const layer = Layer.effect(
Service,
Effect.gen(function* () {
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({})
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({ watch: () => Effect.succeed(Effect.void) })

const backend = getBackend()
const location = yield* Location.Service
Expand All @@ -66,11 +70,11 @@ const layer = Layer.effect(
directory: location.directory,
platform: process.platform,
})
return Service.of({})
return Service.of({ watch: () => Effect.succeed(Effect.void) })
}

const w = watcher()
if (!w) return Service.of({})
if (!w) return Service.of({ watch: () => Effect.succeed(Effect.void) })

yield* Effect.logInfo("watcher backend", { directory: location.directory, platform: process.platform, backend })
const events = yield* EventV2.Service
Expand Down Expand Up @@ -103,6 +107,27 @@ const layer = Layer.effect(
)
}

const watch = (directory: string, ignore: string[] = []) =>
Effect.acquireRelease(
Effect.suspend(() => {
const pending = w.subscribe(directory, callback, { ignore, backend })
return Effect.promise(() => pending).pipe(
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.map((subscription) => Effect.promise(() => subscription.unsubscribe()).pipe(Effect.ignore)),
Effect.catchCause((cause) => {
// A late-resolving subscribe() after the timeout must still be
// unsubscribed, or its native watch handle leaks for the
// process lifetime (mirrors subscribe() above).
pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
return Effect.logError("failed to watch directory", { directory, cause: Cause.pretty(cause) }).pipe(
Effect.as(Effect.void),
)
}),
)
}),
(unsubscribe) => unsubscribe,
)

const config = (yield* (yield* Config.Service).entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
Expand All @@ -123,11 +148,11 @@ const layer = Layer.effect(
}
}

return Service.of({})
return Service.of({ watch })
}).pipe(
Effect.catchCause((cause) => {
return Effect.logError("failed to init watcher service", { cause: Cause.pretty(cause) }).pipe(
Effect.as(Service.of({})),
Effect.as(Service.of({ watch: () => Effect.succeed(Effect.void) })),
)
}),
),
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ export const Flag = {
get OPENCODE_DISABLE_PROJECT_CONFIG() {
return truthy("OPENCODE_DISABLE_PROJECT_CONFIG")
},
// Mirrors packages/opencode/src/effect/runtime-flags.ts's
// disableExternalSkills/disableClaudeCodeSkills (same env vars, V1 skill
// discovery). Core cannot depend on opencode's Effect Config-based
// RuntimeFlags, so this reads process.env directly like the rest of this
// file; keep both definitions' semantics in sync if either changes.
get OPENCODE_DISABLE_EXTERNAL_SKILLS() {
return truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS")
},
get OPENCODE_DISABLE_CLAUDE_CODE_SKILLS() {
const disabled = truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
return disabled || truthy("OPENCODE_DISABLE_CLAUDE_CODE")
},
get OPENCODE_EXPERIMENTAL_REFERENCES() {
return enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES")
},
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/plugin/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Config } from "../config"
import { ConfigAgentPlugin } from "../config/plugin/agent"
import { ConfigCommandPlugin } from "../config/plugin/command"
import { ConfigExternalPlugin } from "../config/plugin/external"
import { ConfigExternalSkillPlugin } from "../config/plugin/external-skill"
import { ConfigProviderPlugin } from "../config/plugin/provider"
import { ConfigReferencePlugin } from "../config/plugin/reference"
import { ConfigSkillPlugin } from "../config/plugin/skill"
Expand Down Expand Up @@ -115,6 +116,7 @@ const layer = Layer.effectDiscard(
yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigCommandPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin)
yield* add(ConfigExternalSkillPlugin.Plugin)
for (const item of ProviderPlugins) yield* add(item)
yield* add(ConfigExternalPlugin.Plugin)
yield* add(ConfigProviderPlugin.Plugin)
Expand Down
91 changes: 85 additions & 6 deletions packages/core/src/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@ export * as SkillV2 from "./skill"

import { makeLocationNode } from "./effect/app-node"
import path from "path"
import { Context, Effect, Layer, Schema, Types } from "effect"
import { Context, Effect, Exit, Layer, Schedule, Scope, Schema, Stream, Types } from "effect"
import { Skill } from "@opencode-ai/schema/skill"
import { AgentV2 } from "./agent"
import { ConfigMarkdown } from "./config/markdown"
import { EventV2 } from "./event"
import { FSUtil } from "./fs-util"
import { PermissionV2 } from "./permission"
import { AbsolutePath } from "./schema"
import { SkillDiscovery } from "./skill/discovery"
import { State } from "./state"
import { Watcher } from "./filesystem/watcher"

export const DirectorySource = Skill.DirectorySource
export type DirectorySource = Skill.DirectorySource
Expand Down Expand Up @@ -58,6 +60,63 @@ const layer = Layer.effect(
Effect.gen(function* () {
const discovery = yield* SkillDiscovery.Service
const fs = yield* FSUtil.Service
const scope = yield* Scope.Scope

const watcher = yield* Watcher.Service
const cache = new Map<string, Info[]>()
const watches = new Map<string, Scope.Closeable>()
// Directory sources are watched by their real path (parcel-watcher/native
// backends report events using the resolved path), so a symlinked source
// (e.g. a dotfiles-managed ~/.claude) still matches on invalidation.
const realPaths = new Map<string, string>()
// Bumped on every reconcile so an in-flight list() load started before a
// reconcile doesn't resurrect a cache entry that reconcile just cleared.
let generation = 0

const invalidate = (source: Source) => {
cache.delete(Source.key(source))
}

const invalidateDirectory = (file: string) => {
for (const source of state.get().sources) {
if (source.type !== "directory") continue
const real = realPaths.get(source.path) ?? source.path
if (!file.startsWith(real + path.sep)) continue
invalidate(source)
}
}

const syncWatches = (sources: ReadonlyArray<Source>) =>
Effect.gen(function* () {
const directories = new Set(sources.filter((s) => s.type === "directory").map((s) => s.path as string))
for (const [directory, childScope] of watches) {
if (directories.has(directory)) continue
watches.delete(directory)
realPaths.delete(directory)
// Closing the child scope detaches it from the parent `scope`, so
// this doesn't leave a stale finalizer behind on repeated
// add/remove cycles the way manually running `unsubscribe` did.
yield* Scope.close(childScope, Exit.void)
}
for (const directory of directories) {
if (watches.has(directory)) continue
const real = yield* fs.realPath(directory).pipe(Effect.catch(() => Effect.succeed(directory)))
realPaths.set(directory, real)
const childScope = yield* Scope.fork(scope)
yield* watcher.watch(directory).pipe(Scope.provide(childScope))
watches.set(directory, childScope)
}
})

const reconcile = (sources: ReadonlyArray<Source>) =>
Effect.gen(function* () {
generation++
const keys = new Set(sources.map(Source.key))
for (const key of cache.keys()) {
if (!keys.has(key)) cache.delete(key)
}
yield* syncWatches(sources)
})

const state = State.create<Data, Draft>({
initial: () => ({ sources: [] }),
Expand All @@ -68,8 +127,27 @@ const layer = Layer.effect(
},
list: () => draft.sources as Source[],
}),
finalize: (draft) => reconcile(draft.list()),
})

const events = yield* EventV2.Service

yield* events.subscribe(Watcher.Event.Updated).pipe(
Stream.runForEach((event) => Effect.sync(() => invalidateDirectory(event.data.file))),
Effect.forkScoped({ startImmediately: true }),
)

// HTTP/URL sources have no push-based change notification, so this is the
// only automatic way to observe a changed remote catalog while running.
yield* Effect.repeat(
Effect.sync(() => {
for (const source of state.get().sources) {
if (source.type === "url") cache.delete(Source.key(source))
}
}),
Schedule.spaced("60 minutes"),
).pipe(Effect.forkScoped({ startImmediately: true }))

const load = Effect.fn("SkillV2.load")(function* (source: Source) {
const skills: Info[] = []
if (source.type === "embedded") return [source.skill]
Expand Down Expand Up @@ -104,15 +182,16 @@ const layer = Layer.effect(
return skills
})

// QUESTION(Dax): Should local skill sources invalidate on filesystem watch
// events, following the reload policy chosen for other context sources?
const cache = new Map<string, Info[]>()
const list = Effect.fn("SkillV2.list")(function* () {
const skills = new Map<string, Info>()
const startGeneration = generation
for (const source of state.get().sources) {
const key = Source.key(source)
const loaded = cache.get(key) ?? (yield* load(source))
cache.set(key, loaded)
// Only cache the result if no reconcile ran while this load was in
// flight; otherwise a stale load could resurrect a cache entry that
// reconcile just invalidated for a removed/changed source.
if (generation === startGeneration) cache.set(key, loaded)
for (const skill of loaded) skills.set(skill.name, skill)
}
return Array.from(skills.values())
Expand All @@ -129,4 +208,4 @@ const layer = Layer.effect(
}),
)

export const node = makeLocationNode({ service: Service, layer, deps: [SkillDiscovery.node, FSUtil.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [SkillDiscovery.node, FSUtil.node, EventV2.node, Watcher.node] })
6 changes: 5 additions & 1 deletion packages/core/src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,12 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte

const commit = Effect.fn("State.commit")(function* (next: State) {
const api = options.draft(next)
if (options.finalize) yield* options.finalize(api)
// Assign before finalize (which publishes *.updated for catalog/reference/
// integration): a listener reacting to that event and immediately calling
// get() must observe the rebuilt state, not the state it is about to
// replace.
state = next
if (options.finalize) yield* options.finalize(api)
})

const apply = (transform: TransformCallback<DraftApi>, draft: DraftApi) =>
Expand Down
Loading
Loading