diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index c76486968b07..62b79b19afc7 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -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" @@ -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 }) }) @@ -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( diff --git a/packages/core/src/config/plugin/external-skill.ts b/packages/core/src/config/plugin/external-skill.ts new file mode 100644 index 000000000000..a9979752ec75 --- /dev/null +++ b/packages/core/src/config/plugin/external-skill.ts @@ -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) })) + } + }), + ) + }), +}) diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index c5e20631917e..fa62b598123d 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -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" @@ -50,14 +50,18 @@ function protecteds(dir: string) { export const hasNativeBinding = () => !!watcher() -export interface Interface {} +export type Unsubscribe = Effect.Effect + +export interface Interface { + readonly watch: (directory: string, ignore?: string[]) => Effect.Effect +} export class Service extends Context.Service()("@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 @@ -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 @@ -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 ?? []) @@ -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) })), ) }), ), diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index a0eb78a13e2a..61a7fe1e6139 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -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") }, diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index d4ab71cb6b04..54ae4be1cdbc 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -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" @@ -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) diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index be1cd1d49adc..f909f1ca4d58 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -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 @@ -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() + const watches = new Map() + // 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() + // 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) => + 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) => + 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({ initial: () => ({ sources: [] }), @@ -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] @@ -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() const list = Effect.fn("SkillV2.list")(function* () { const skills = new Map() + 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()) @@ -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] }) diff --git a/packages/core/src/state.ts b/packages/core/src/state.ts index ab3457fc1814..9c218fffc492 100644 --- a/packages/core/src/state.ts +++ b/packages/core/src/state.ts @@ -65,8 +65,12 @@ export function create(options: Options): 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, draft: DraftApi) => diff --git a/packages/core/test/config/external-skill.test.ts b/packages/core/test/config/external-skill.test.ts new file mode 100644 index 000000000000..ddf5ba3a12c9 --- /dev/null +++ b/packages/core/test/config/external-skill.test.ts @@ -0,0 +1,123 @@ +import fs from "fs/promises" +import nodeFs from "node:fs" +import path from "path" +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { ConfigExternalSkillPlugin } from "@opencode-ai/core/config/plugin/external-skill" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Global } from "@opencode-ai/core/global" +import { Location } from "@opencode-ai/core/location" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SkillV2 } from "@opencode-ai/core/skill" +import { location } from "../fixture/location" +import { tmpdir } from "../fixture/tmpdir" +import { testEffect } from "../lib/effect" +import { host } from "../plugin/host" + +const it = testEffect(Layer.empty) + +function writeSkill(dir: string, name: string) { + return fs.mkdir(path.join(dir, name), { recursive: true }).then(() => + fs.writeFile( + path.join(dir, name, "SKILL.md"), + `--- +name: ${name} +description: ${name} skill +--- +# ${name}`, + ), + ) +} + +describe("ConfigExternalSkillPlugin.Plugin", () => { + it.live("registers global and project .claude/.agents skill directories", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (home) => Effect.promise(() => home[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((home) => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (project) => Effect.promise(() => project[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((project) => + Effect.gen(function* () { + const globalClaude = path.join(home.path, ".claude", "skills") + const globalAgents = path.join(home.path, ".agents", "skills") + const projectClaude = path.join(project.path, ".claude", "skills") + const projectAgents = path.join(project.path, ".agents", "skills") + yield* Effect.promise(async () => { + await writeSkill(globalClaude, "global-claude") + await writeSkill(globalAgents, "global-agents") + await writeSkill(projectClaude, "project-claude") + await writeSkill(projectAgents, "project-agents") + }) + + const sources: SkillV2.Source[] = [] + const transform = Effect.fnUntraced(function* ( + update: (draft: SkillV2.Draft) => void | Effect.Effect, + ) { + const result = update({ + source: (source) => { + sources.push(source) + }, + list: () => sources, + }) + if (Effect.isEffect(result)) yield* result + return { dispose: Effect.sync(() => (sources.length = 0)) } + }) + + yield* ConfigExternalSkillPlugin.Plugin.effect( + host({ + skill: { transform, reload: () => Effect.void }, + }), + ).pipe( + Effect.provideService( + Global.Service, + Global.Service.of({ ...Global.make(), home: home.path }), + ), + Effect.provideService( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(project.path) })), + ), + Effect.provideService( + FSUtil.Service, + FSUtil.Service.of({ + isDir: (p: string) => Effect.succeed(nodeFs.statSync(p).isDirectory()), + up: ({ targets, start, stop }: { targets: string[]; start: string; stop?: string }) => + Effect.sync(() => { + const result: string[] = [] + let current = start + while (true) { + for (const target of targets) { + const search = path.join(current, target) + try { + if (nodeFs.statSync(search).isDirectory()) result.push(search) + } catch { + // ignore + } + } + if (stop === current) break + const parent = path.dirname(current) + if (parent === current) break + current = parent + } + return result + }), + } as unknown as FSUtil.Interface), + ), + ) + + expect( + sources + .filter((s): s is Extract => s.type === "directory") + .map((s) => String(s.path)) + .toSorted(), + ).toEqual([globalClaude, globalAgents, projectClaude, projectAgents].toSorted()) + }), + ), + ), + ), + ), + ) +}) diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index 0d5b52180aac..183f73e81d1f 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -3,12 +3,14 @@ import { AISDK } from "@opencode-ai/core/aisdk" import { Catalog } from "@opencode-ai/core/catalog" import { CommandV2 } from "@opencode-ai/core/command" import { Credential } from "@opencode-ai/core/credential" +import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { FileSystem } from "@opencode-ai/core/filesystem" import { FSUtil } from "@opencode-ai/core/fs-util" +import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { Integration } from "@opencode-ai/core/integration" import { Location } from "@opencode-ai/core/location" import { Npm } from "@opencode-ai/core/npm" @@ -27,6 +29,11 @@ const npmLayer = Layer.succeed( }), ) +const watcherLayer = Layer.succeed( + Watcher.Service, + Watcher.Service.of({ watch: () => Effect.succeed(Effect.void) }), +) + export const PluginTestLayer = AppNodeBuilder.build( LayerNode.group([ FileSystem.node, @@ -34,6 +41,7 @@ export const PluginTestLayer = AppNodeBuilder.build( Location.node, Npm.node, Credential.node, + Database.node, EventV2.node, LayerNodePlatform.httpClient, PluginV2.node, @@ -48,5 +56,6 @@ export const PluginTestLayer = AppNodeBuilder.build( [ [Location.node, tempLocationLayer], [Npm.node, npmLayer], + [Watcher.node, watcherLayer], ], ) diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index 61bee856b9f3..889440f5d1a8 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -1,12 +1,23 @@ import { describe, expect } from "bun:test" -import { Effect } from "effect" +import { Effect, Layer } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" +import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { SkillPlugin } from "@opencode-ai/core/plugin/skill" import { SkillV2 } from "@opencode-ai/core/skill" import { testEffect } from "../lib/effect" import { host } from "./host" -const it = testEffect(AppNodeBuilder.build(SkillV2.node)) +const watcherLayer = Layer.succeed( + Watcher.Service, + Watcher.Service.of({ watch: () => Effect.succeed(Effect.void) }), +) + +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([SkillV2.node, Database.node, EventV2.node]), [[Watcher.node, watcherLayer]]), +) describe("SkillPlugin.Plugin", () => { it.effect("registers the built-in customize-opencode skill", () => diff --git a/packages/core/test/skill.test.ts b/packages/core/test/skill.test.ts index 0f8e1b3cb3d8..8f9221729e7d 100644 --- a/packages/core/test/skill.test.ts +++ b/packages/core/test/skill.test.ts @@ -1,14 +1,21 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Duration, Effect, Layer } from "effect" +import * as TestClock from "effect/testing/TestClock" import { AgentV2 } from "@opencode-ai/core/agent" +import { Config } from "@opencode-ai/core/config" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" import { FSUtil } from "@opencode-ai/core/fs-util" +import { Watcher } from "@opencode-ai/core/filesystem/watcher" +import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { SkillV2 } from "@opencode-ai/core/skill" import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" +import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" @@ -23,8 +30,17 @@ const discovery = Layer.succeed( }, }), ) + +const watcherLayer = Layer.succeed( + Watcher.Service, + Watcher.Service.of({ watch: () => Effect.succeed(Effect.void) }), +) + const it = testEffect( - AppNodeBuilder.build(LayerNode.group([SkillV2.node, AgentV2.node]), [[SkillDiscovery.node, discovery]]), + AppNodeBuilder.build(LayerNode.group([SkillV2.node, AgentV2.node, Database.node, EventV2.node]), [ + [SkillDiscovery.node, discovery], + [Watcher.node, watcherLayer], + ]), ) function write(directory: string, name: string, description: string) { @@ -122,4 +138,204 @@ describe("SkillV2", () => { ), ), ) + + it.live("keeps directory source cache when the source is replayed without watcher events", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const dir = path.join(tmp.path, "skills") + yield* Effect.promise(async () => { + await fs.mkdir(path.join(dir, "review"), { recursive: true }) + await write(dir, "review", "First") + }) + + const skill = yield* SkillV2.Service + yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(dir) })) + expect((yield* skill.list()).map((item) => item.description)).toEqual(["First"]) + + yield* Effect.promise(async () => { + await fs.mkdir(path.join(dir, "review"), { recursive: true }) + await write(dir, "review", "Second") + }) + + // Replaying the same transform does not refresh directory sources; + // file changes are discovered through watcher events instead. + yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(dir) })) + expect((yield* skill.list()).map((item) => item.description)).toEqual(["First"]) + }), + ), + ), + ) + + it.live("clears cache for removed sources", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const first = path.join(tmp.path, "first") + const second = path.join(tmp.path, "second") + yield* Effect.promise(async () => { + await fs.mkdir(path.join(first, "review"), { recursive: true }) + await fs.mkdir(path.join(second, "review"), { recursive: true }) + await write(first, "review", "First") + await write(second, "review", "Second") + }) + + const skill = yield* SkillV2.Service + const registration = yield* skill.transform((editor) => { + editor.source({ type: "directory", path: AbsolutePath.make(first) }) + editor.source({ type: "directory", path: AbsolutePath.make(second) }) + }) + // Later source wins for duplicate skill names. + expect((yield* skill.list()).map((item) => item.description)).toEqual(["Second"]) + + yield* registration.dispose + expect(yield* skill.list()).toEqual([]) + }), + ), + ), + ) + + it.live("invalidates directory source cache on watcher events", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const dir = path.join(tmp.path, "skills") + yield* Effect.promise(async () => { + await fs.mkdir(path.join(dir, "review"), { recursive: true }) + await write(dir, "review", "First") + }) + + const events = yield* EventV2.Service + const skill = yield* SkillV2.Service + yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(dir) })) + expect((yield* skill.list()).map((item) => item.description)).toEqual(["First"]) + + yield* Effect.promise(async () => { + await fs.mkdir(path.join(dir, "review"), { recursive: true }) + await write(dir, "review", "Second") + }) + + yield* events.publish(Watcher.Event.Updated, { + file: path.join(dir, "review", "SKILL.md"), + event: "change", + }) + yield* Effect.yieldNow + + expect((yield* skill.list()).map((item) => item.description)).toEqual(["Second"]) + }), + ), + ), + ) + + it.effect("refreshes URL source cache on the periodic timer", () => + Effect.gen(function* () { + const url = "https://example.test/refresh/" + pulls = 0 + urls.set(url, []) + + const skill = yield* SkillV2.Service + yield* skill.transform((editor) => editor.source({ type: "url", url })) + + expect(yield* skill.list()).toEqual([]) + expect(pulls).toBe(1) + + const dir = yield* Effect.promise(() => tmpdir()) + yield* Effect.promise(async () => { + await fs.mkdir(path.join(dir.path, "deploy"), { recursive: true }) + await write(dir.path, "deploy", "Deploy production") + }) + // Simulate the remote catalog changing with no local config/plugin reload. + urls.set(url, [AbsolutePath.make(dir.path)]) + + // Still cached until the periodic refresh fires. + expect(yield* skill.list()).toEqual([]) + expect(pulls).toBe(1) + + yield* TestClock.adjust(Duration.minutes(60)) + yield* Effect.yieldNow + + expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"]) + expect(pulls).toBe(2) + + yield* Effect.promise(() => dir[Symbol.asyncDispose]()) + }), + ) +}) + +const describeRealWatcher = Watcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip + +describeRealWatcher("SkillV2 with a real filesystem watcher", () => { + // A separate, minimal `it` is required here: the file-level `it` already + // bakes a stubbed Watcher.node into its base layer (for the other tests + // above), and providing a second, real Watcher.node per-test on top of that + // base does not take effect. Mirrors the pattern in + // test/filesystem/watcher.test.ts, whose shared `it` has no Watcher.node + // (or SkillV2.node) in its base layer either. + const itReal = testEffect(Layer.empty) + + const configLayer = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) + + function provideRealWatcher(directory: string) { + return Effect.provide( + AppNodeBuilder.build(LayerNode.group([SkillV2.node, AgentV2.node, Database.node, EventV2.node]), [ + [SkillDiscovery.node, discovery], + [Config.node, configLayer], + [ + Location.node, + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), + ], + ]), + ) + } + + itReal.live( + "invalidates a symlinked directory source through the real watcher", + () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const real = path.join(tmp.path, "real-skills") + const link = path.join(tmp.path, "linked-skills") + yield* Effect.promise(async () => { + await fs.mkdir(path.join(real, "review"), { recursive: true }) + await write(real, "review", "First") + await fs.symlink(real, link) + }) + + const skill = yield* SkillV2.Service + yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(link) })) + expect((yield* skill.list()).map((item) => item.description)).toEqual(["First"]) + + yield* Effect.promise(async () => { + await fs.mkdir(path.join(real, "review"), { recursive: true }) + await write(real, "review", "Second") + }) + + yield* Effect.gen(function* () { + let attempt = 0 + while (attempt < 20) { + const list = yield* skill.list() + if (list.some((item) => item.description === "Second")) return + yield* Effect.sleep("250 millis") + attempt++ + } + return yield* Effect.fail(new Error("timed out waiting for the real watcher to invalidate the cache")) + }) + }).pipe(provideRealWatcher(tmp.path)), + ), + ), + 10000, + ) }) diff --git a/packages/opencode/src/cli/cmd/debug/index.ts b/packages/opencode/src/cli/cmd/debug/index.ts index 9dcaa33b3646..fd70c4092dae 100644 --- a/packages/opencode/src/cli/cmd/debug/index.ts +++ b/packages/opencode/src/cli/cmd/debug/index.ts @@ -11,6 +11,7 @@ import { LSPCommand } from "./lsp" import { RipgrepCommand } from "./ripgrep" import { ScrapCommand } from "./scrap" import { SkillCommand } from "./skill" +import { SkillV2Command } from "./skill-v2" import { SnapshotCommand } from "./snapshot" import { AgentCommand } from "./agent" import { StartupCommand } from "./startup" @@ -27,6 +28,7 @@ export const DebugCommand = cmd({ .command(FileCommand) .command(ScrapCommand) .command(SkillCommand) + .command(SkillV2Command) .command(SnapshotCommand) .command(StartupCommand) .command(AgentCommand) diff --git a/packages/opencode/src/cli/cmd/debug/skill-v2.ts b/packages/opencode/src/cli/cmd/debug/skill-v2.ts new file mode 100644 index 000000000000..e82d5d496ddf --- /dev/null +++ b/packages/opencode/src/cli/cmd/debug/skill-v2.ts @@ -0,0 +1,43 @@ +import { EOL } from "os" +import { Effect } from "effect" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" +import { Location } from "@opencode-ai/core/location" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SkillV2 } from "@opencode-ai/core/skill" +import { effectCmd } from "../../effect-cmd" + +export const SkillV2Command = effectCmd({ + command: "skill-v2", + describe: "list V2 skills (SkillV2) for the current directory; --watch reprints live", + instance: false, + builder: (yargs) => + yargs.option("watch", { + type: "boolean", + default: false, + describe: "keep running and reprint the list every 2s, so edits to skill files show up live", + }), + handler: (args) => + Effect.gen(function* () { + const skill = yield* SkillV2.Service + if (!args.watch) { + process.stdout.write(JSON.stringify(yield* skill.list(), null, 2) + EOL) + return + } + while (true) { + const list = yield* skill.list() + process.stdout.write(`\n[${new Date().toLocaleTimeString()}] ${list.length} skill(s):\n`) + process.stdout.write(JSON.stringify(list, null, 2) + EOL) + yield* Effect.sleep("2 seconds") + } + }).pipe( + Effect.withSpan("Cli.debug.skillV2"), + Effect.provide( + LocationServiceMap.Service.get( + Location.Ref.make({ + directory: AbsolutePath.make(process.cwd()), + }), + ), + ), + Effect.provide(locationServiceMapLayer), + ), +}) diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 5a04ec213994..d51678506e45 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -5,8 +5,13 @@ import { NamedError } from "@opencode-ai/core/util/error" import type { Agent } from "@/agent/agent" import { EventV2Bridge } from "@/event-v2-bridge" import { InstanceState } from "@/effect/instance-state" +import { AbsolutePath } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" +import { Location } from "@opencode-ai/core/location" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" +import { PluginV2 } from "@opencode-ai/core/plugin" import { SkillPlugin } from "@opencode-ai/core/plugin/skill" +import { SkillV2 } from "@opencode-ai/core/skill" import { Permission } from "@/permission" import { FSUtil } from "@opencode-ai/core/fs-util" import { Config } from "@/config/config" @@ -256,6 +261,7 @@ const layer = Layer.effect( const fsys = yield* FSUtil.Service const global = yield* Global.Service const flags = yield* RuntimeFlags.Service + const locations = yield* LocationServiceMap.Service const discovered = yield* InstanceState.make( Effect.fn("Skill.discovery")(function* (ctx) { return yield* discoverSkills( @@ -270,19 +276,69 @@ const layer = Layer.effect( ) }), ) + // Chain of responsibility: SkillV2 (event-driven, no-restart refresh) is + // the primary handler. Its list() has no typed error channel, so any + // failure surfaces as a defect; on that defect, fall back to this + // service's own disk discovery, unchanged, as it behaved before SkillV2 + // existed. An empty SkillV2 result is not a failure. + const fromV2 = (skills: readonly SkillV2.Info[]): State => ({ + skills: Object.fromEntries( + skills.map((skill): [string, Info] => [ + skill.name, + { + name: skill.name, + description: skill.description, + // SkillV2's embedded (built-in) skills use a /builtin/ pseudo-path; + // normalize to V1's "" sentinel, which command/index.ts + // and this service's own consumers already branch on. + location: skill.location.startsWith("/builtin/") ? "" : skill.location, + content: skill.content, + }, + ]), + ), + dirs: new Set(), + }) + const stateFromV1 = Effect.fn("Skill.stateFromV1")(function* () { + const s: State = { skills: {}, dirs: new Set() } + // Register the built-in skill BEFORE disk discovery so a user-disk + // skill with the same name can override it. + s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = { + name: CUSTOMIZE_OPENCODE_SKILL_NAME, + description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION, + location: "", + content: CUSTOMIZE_OPENCODE_SKILL_BODY, + } + yield* loadSkills(s, yield* InstanceState.get(discovered), events) + return s + }) const state = yield* InstanceState.make( - Effect.fn("Skill.state")(function* () { - const s: State = { skills: {}, dirs: new Set() } - // Register the built-in skill BEFORE disk discovery so a user-disk - // skill with the same name can override it. - s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = { - name: CUSTOMIZE_OPENCODE_SKILL_NAME, - description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION, - location: "", - content: CUSTOMIZE_OPENCODE_SKILL_BODY, - } - yield* loadSkills(s, yield* InstanceState.get(discovered), events) - return s + Effect.fn("Skill.state")(function* (ctx) { + // SkillV2 is Location-scoped; resolve the instance matching this + // Skill instance's own directory rather than binding one fixed copy. + const locationLayer = locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })) + return yield* Effect.gen(function* () { + // The plugins that turn Config entries into skill sources + // (config-skill, config-external-skill) load on a forked fiber. + // plugin.wait only guarantees each plugin's transform is + // *registered*; the transform callback itself only actually runs on + // the next materialize, which the shared plugin-load batch defers + // until every plugin in the same boot sequence has registered. + // Force one explicitly so SkillV2 reflects what was just + // registered instead of whatever it last materialized. + const plugin = yield* PluginV2.Service + yield* plugin.wait(PluginV2.ID.make("config-skill")) + yield* plugin.wait(PluginV2.ID.make("config-external-skill")) + const skillV2 = yield* SkillV2.Service + yield* skillV2.reload() + return fromV2(yield* skillV2.list()) + }).pipe( + Effect.provide(locationLayer), + Effect.catchDefect((defect) => + Effect.logWarning("SkillV2 failed, falling back to V1 skill discovery", { defect }).pipe( + Effect.andThen(stateFromV1()), + ), + ), + ) }), ) @@ -345,10 +401,24 @@ export function fmt(list: Info[], opts: { verbose: boolean }) { ].join("\n") } +const locationServiceMapNode = LayerNode.make({ + service: LocationServiceMap.Service, + layer: locationServiceMapLayer, + deps: [], +}) + export const node = LayerNode.make({ service: Service, layer: layer, - deps: [Discovery.node, Config.node, EventV2Bridge.node, FSUtil.node, Global.node, RuntimeFlags.node], + deps: [ + Discovery.node, + Config.node, + EventV2Bridge.node, + FSUtil.node, + Global.node, + RuntimeFlags.node, + locationServiceMapNode, + ], }) export * as Skill from "." diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index e9d3ad233807..85db07b8a3f7 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -119,6 +119,8 @@ Commands: opencode debug file file system debugging utilities opencode debug scrap list all known projects opencode debug skill list all available skills + opencode debug skill-v2 list V2 skills (SkillV2) for the current directory; --watch reprints + live opencode debug snapshot snapshot debugging utilities opencode debug startup print startup timing opencode debug agent show agent configuration details diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index f365b63f0d72..3e0f194a5a0c 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -63,6 +63,24 @@ const withHome = (home: string, self: Effect.Effect) => }), ) +// SkillV2's discovery reads these as raw env vars (see packages/core/src/flag/flag.ts), +// independent of RuntimeFlags' layer-injected value, so tests exercising the +// disable behavior must set both to cover the primary V2 path and the V1 fallback. +const withEnv = (name: string, value: string, self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const prev = process.env[name] + process.env[name] = value + return prev + }), + () => self, + (prev) => + Effect.sync(() => { + if (prev === undefined) delete process.env[name] + else process.env[name] = prev + }), + ) + describe("skill", () => { it.effect("formats verbose locations as XML-safe filesystem paths", () => Effect.sync(() => { @@ -475,7 +493,7 @@ description: A skill in the .agents/skills directory. const skill = yield* Skill.Service const list = (yield* skill.all()).filter((s) => s.location !== "") expect(list.map((s) => s.name)).toEqual(["agent-skill"]) - }), + }).pipe((self) => withEnv("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS", "true", self)), { git: true }, ), ) @@ -522,7 +540,7 @@ description: A skill in the .opencode/skill directory. const skill = yield* Skill.Service const list = (yield* skill.all()).filter((s) => s.location !== "") expect(list.map((s) => s.name)).toEqual(["opencode-skill"]) - }), + }).pipe((self) => withEnv("OPENCODE_DISABLE_EXTERNAL_SKILLS", "true", self)), { git: true }, ), )