Răsfoiți Sursa

feat: unwrap uskill namespace to flat exports + barrel (#22714)

Kit Langton 1 zi în urmă
părinte
comite
62ddb9d3ad
2 a modificat fișierele cu 263 adăugiri și 264 ștergeri
  1. 1 264
      packages/opencode/src/skill/index.ts
  2. 262 0
      packages/opencode/src/skill/skill.ts

+ 1 - 264
packages/opencode/src/skill/index.ts

@@ -1,264 +1 @@
-import os from "os"
-import path from "path"
-import { pathToFileURL } from "url"
-import z from "zod"
-import { Effect, Layer, Context } from "effect"
-import { NamedError } from "@opencode-ai/shared/util/error"
-import type { Agent } from "@/agent/agent"
-import { Bus } from "@/bus"
-import { InstanceState } from "@/effect/instance-state"
-import { Flag } from "@/flag/flag"
-import { Global } from "@/global"
-import { Permission } from "@/permission"
-import { AppFileSystem } from "@opencode-ai/shared/filesystem"
-import { Config } from "../config"
-import { ConfigMarkdown } from "../config/markdown"
-import { Glob } from "@opencode-ai/shared/util/glob"
-import { Log } from "../util/log"
-import { Discovery } from "./discovery"
-
-export namespace Skill {
-  const log = Log.create({ service: "skill" })
-  const EXTERNAL_DIRS = [".claude", ".agents"]
-  const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
-  const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
-  const SKILL_PATTERN = "**/SKILL.md"
-
-  export const Info = z.object({
-    name: z.string(),
-    description: z.string(),
-    location: z.string(),
-    content: z.string(),
-  })
-  export type Info = z.infer<typeof Info>
-
-  export const InvalidError = NamedError.create(
-    "SkillInvalidError",
-    z.object({
-      path: z.string(),
-      message: z.string().optional(),
-      issues: z.custom<z.core.$ZodIssue[]>().optional(),
-    }),
-  )
-
-  export const NameMismatchError = NamedError.create(
-    "SkillNameMismatchError",
-    z.object({
-      path: z.string(),
-      expected: z.string(),
-      actual: z.string(),
-    }),
-  )
-
-  type State = {
-    skills: Record<string, Info>
-    dirs: Set<string>
-  }
-
-  export interface Interface {
-    readonly get: (name: string) => Effect.Effect<Info | undefined>
-    readonly all: () => Effect.Effect<Info[]>
-    readonly dirs: () => Effect.Effect<string[]>
-    readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
-  }
-
-  const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.Interface) {
-    const md = yield* Effect.tryPromise({
-      try: () => ConfigMarkdown.parse(match),
-      catch: (err) => err,
-    }).pipe(
-      Effect.catch(
-        Effect.fnUntraced(function* (err) {
-          const message = ConfigMarkdown.FrontmatterError.isInstance(err)
-            ? err.data.message
-            : `Failed to parse skill ${match}`
-          const { Session } = yield* Effect.promise(() => import("@/session"))
-          yield* bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
-          log.error("failed to load skill", { skill: match, err })
-          return undefined
-        }),
-      ),
-    )
-
-    if (!md) return
-
-    const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
-    if (!parsed.success) return
-
-    if (state.skills[parsed.data.name]) {
-      log.warn("duplicate skill name", {
-        name: parsed.data.name,
-        existing: state.skills[parsed.data.name].location,
-        duplicate: match,
-      })
-    }
-
-    state.dirs.add(path.dirname(match))
-    state.skills[parsed.data.name] = {
-      name: parsed.data.name,
-      description: parsed.data.description,
-      location: match,
-      content: md.content,
-    }
-  })
-
-  const scan = Effect.fnUntraced(function* (
-    state: State,
-    bus: Bus.Interface,
-    root: string,
-    pattern: string,
-    opts?: { dot?: boolean; scope?: string },
-  ) {
-    const matches = yield* Effect.tryPromise({
-      try: () =>
-        Glob.scan(pattern, {
-          cwd: root,
-          absolute: true,
-          include: "file",
-          symlink: true,
-          dot: opts?.dot,
-        }),
-      catch: (error) => error,
-    }).pipe(
-      Effect.catch((error) => {
-        if (!opts?.scope) return Effect.die(error)
-        log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
-        return Effect.succeed([] as string[])
-      }),
-    )
-
-    yield* Effect.forEach(matches, (match) => add(state, match, bus), {
-      concurrency: "unbounded",
-      discard: true,
-    })
-  })
-
-  const loadSkills = Effect.fnUntraced(function* (
-    state: State,
-    config: Config.Interface,
-    discovery: Discovery.Interface,
-    bus: Bus.Interface,
-    fsys: AppFileSystem.Interface,
-    directory: string,
-    worktree: string,
-  ) {
-    if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
-      for (const dir of EXTERNAL_DIRS) {
-        const root = path.join(Global.Path.home, dir)
-        if (!(yield* fsys.isDir(root))) continue
-        yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
-      }
-
-      const upDirs = yield* fsys
-        .up({ targets: EXTERNAL_DIRS, start: directory, stop: worktree })
-        .pipe(Effect.catch(() => Effect.succeed([] as string[])))
-
-      for (const root of upDirs) {
-        yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
-      }
-    }
-
-    const configDirs = yield* config.directories()
-    for (const dir of configDirs) {
-      yield* scan(state, bus, dir, OPENCODE_SKILL_PATTERN)
-    }
-
-    const cfg = yield* config.get()
-    for (const item of cfg.skills?.paths ?? []) {
-      const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
-      const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
-      if (!(yield* fsys.isDir(dir))) {
-        log.warn("skill path not found", { path: dir })
-        continue
-      }
-
-      yield* scan(state, bus, dir, SKILL_PATTERN)
-    }
-
-    for (const url of cfg.skills?.urls ?? []) {
-      const pulledDirs = yield* discovery.pull(url)
-      for (const dir of pulledDirs) {
-        state.dirs.add(dir)
-        yield* scan(state, bus, dir, SKILL_PATTERN)
-      }
-    }
-
-    log.info("init", { count: Object.keys(state.skills).length })
-  })
-
-  export class Service extends Context.Service<Service, Interface>()("@opencode/Skill") {}
-
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const discovery = yield* Discovery.Service
-      const config = yield* Config.Service
-      const bus = yield* Bus.Service
-      const fsys = yield* AppFileSystem.Service
-      const state = yield* InstanceState.make(
-        Effect.fn("Skill.state")(function* (ctx) {
-          const s: State = { skills: {}, dirs: new Set() }
-          yield* loadSkills(s, config, discovery, bus, fsys, ctx.directory, ctx.worktree)
-          return s
-        }),
-      )
-
-      const get = Effect.fn("Skill.get")(function* (name: string) {
-        const s = yield* InstanceState.get(state)
-        return s.skills[name]
-      })
-
-      const all = Effect.fn("Skill.all")(function* () {
-        const s = yield* InstanceState.get(state)
-        return Object.values(s.skills)
-      })
-
-      const dirs = Effect.fn("Skill.dirs")(function* () {
-        const s = yield* InstanceState.get(state)
-        return Array.from(s.dirs)
-      })
-
-      const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
-        const s = yield* InstanceState.get(state)
-        const list = Object.values(s.skills).toSorted((a, b) => a.name.localeCompare(b.name))
-        if (!agent) return list
-        return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
-      })
-
-      return Service.of({ get, all, dirs, available })
-    }),
-  )
-
-  export const defaultLayer = layer.pipe(
-    Layer.provide(Discovery.defaultLayer),
-    Layer.provide(Config.defaultLayer),
-    Layer.provide(Bus.layer),
-    Layer.provide(AppFileSystem.defaultLayer),
-  )
-
-  export function fmt(list: Info[], opts: { verbose: boolean }) {
-    if (list.length === 0) return "No skills are currently available."
-    if (opts.verbose) {
-      return [
-        "<available_skills>",
-        ...list
-          .sort((a, b) => a.name.localeCompare(b.name))
-          .flatMap((skill) => [
-            "  <skill>",
-            `    <name>${skill.name}</name>`,
-            `    <description>${skill.description}</description>`,
-            `    <location>${pathToFileURL(skill.location).href}</location>`,
-            "  </skill>",
-          ]),
-        "</available_skills>",
-      ].join("\n")
-    }
-
-    return [
-      "## Available Skills",
-      ...list
-        .toSorted((a, b) => a.name.localeCompare(b.name))
-        .map((skill) => `- **${skill.name}**: ${skill.description}`),
-    ].join("\n")
-  }
-}
+export * as Skill from "./skill"

+ 262 - 0
packages/opencode/src/skill/skill.ts

@@ -0,0 +1,262 @@
+import os from "os"
+import path from "path"
+import { pathToFileURL } from "url"
+import z from "zod"
+import { Effect, Layer, Context } from "effect"
+import { NamedError } from "@opencode-ai/shared/util/error"
+import type { Agent } from "@/agent/agent"
+import { Bus } from "@/bus"
+import { InstanceState } from "@/effect/instance-state"
+import { Flag } from "@/flag/flag"
+import { Global } from "@/global"
+import { Permission } from "@/permission"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { Config } from "../config"
+import { ConfigMarkdown } from "../config/markdown"
+import { Glob } from "@opencode-ai/shared/util/glob"
+import { Log } from "../util/log"
+import { Discovery } from "./discovery"
+
+const log = Log.create({ service: "skill" })
+const EXTERNAL_DIRS = [".claude", ".agents"]
+const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
+const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
+const SKILL_PATTERN = "**/SKILL.md"
+
+export const Info = z.object({
+  name: z.string(),
+  description: z.string(),
+  location: z.string(),
+  content: z.string(),
+})
+export type Info = z.infer<typeof Info>
+
+export const InvalidError = NamedError.create(
+  "SkillInvalidError",
+  z.object({
+    path: z.string(),
+    message: z.string().optional(),
+    issues: z.custom<z.core.$ZodIssue[]>().optional(),
+  }),
+)
+
+export const NameMismatchError = NamedError.create(
+  "SkillNameMismatchError",
+  z.object({
+    path: z.string(),
+    expected: z.string(),
+    actual: z.string(),
+  }),
+)
+
+type State = {
+  skills: Record<string, Info>
+  dirs: Set<string>
+}
+
+export interface Interface {
+  readonly get: (name: string) => Effect.Effect<Info | undefined>
+  readonly all: () => Effect.Effect<Info[]>
+  readonly dirs: () => Effect.Effect<string[]>
+  readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
+}
+
+const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.Interface) {
+  const md = yield* Effect.tryPromise({
+    try: () => ConfigMarkdown.parse(match),
+    catch: (err) => err,
+  }).pipe(
+    Effect.catch(
+      Effect.fnUntraced(function* (err) {
+        const message = ConfigMarkdown.FrontmatterError.isInstance(err)
+          ? err.data.message
+          : `Failed to parse skill ${match}`
+        const { Session } = yield* Effect.promise(() => import("@/session"))
+        yield* bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
+        log.error("failed to load skill", { skill: match, err })
+        return undefined
+      }),
+    ),
+  )
+
+  if (!md) return
+
+  const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
+  if (!parsed.success) return
+
+  if (state.skills[parsed.data.name]) {
+    log.warn("duplicate skill name", {
+      name: parsed.data.name,
+      existing: state.skills[parsed.data.name].location,
+      duplicate: match,
+    })
+  }
+
+  state.dirs.add(path.dirname(match))
+  state.skills[parsed.data.name] = {
+    name: parsed.data.name,
+    description: parsed.data.description,
+    location: match,
+    content: md.content,
+  }
+})
+
+const scan = Effect.fnUntraced(function* (
+  state: State,
+  bus: Bus.Interface,
+  root: string,
+  pattern: string,
+  opts?: { dot?: boolean; scope?: string },
+) {
+  const matches = yield* Effect.tryPromise({
+    try: () =>
+      Glob.scan(pattern, {
+        cwd: root,
+        absolute: true,
+        include: "file",
+        symlink: true,
+        dot: opts?.dot,
+      }),
+    catch: (error) => error,
+  }).pipe(
+    Effect.catch((error) => {
+      if (!opts?.scope) return Effect.die(error)
+      log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
+      return Effect.succeed([] as string[])
+    }),
+  )
+
+  yield* Effect.forEach(matches, (match) => add(state, match, bus), {
+    concurrency: "unbounded",
+    discard: true,
+  })
+})
+
+const loadSkills = Effect.fnUntraced(function* (
+  state: State,
+  config: Config.Interface,
+  discovery: Discovery.Interface,
+  bus: Bus.Interface,
+  fsys: AppFileSystem.Interface,
+  directory: string,
+  worktree: string,
+) {
+  if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
+    for (const dir of EXTERNAL_DIRS) {
+      const root = path.join(Global.Path.home, dir)
+      if (!(yield* fsys.isDir(root))) continue
+      yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
+    }
+
+    const upDirs = yield* fsys
+      .up({ targets: EXTERNAL_DIRS, start: directory, stop: worktree })
+      .pipe(Effect.catch(() => Effect.succeed([] as string[])))
+
+    for (const root of upDirs) {
+      yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
+    }
+  }
+
+  const configDirs = yield* config.directories()
+  for (const dir of configDirs) {
+    yield* scan(state, bus, dir, OPENCODE_SKILL_PATTERN)
+  }
+
+  const cfg = yield* config.get()
+  for (const item of cfg.skills?.paths ?? []) {
+    const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
+    const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
+    if (!(yield* fsys.isDir(dir))) {
+      log.warn("skill path not found", { path: dir })
+      continue
+    }
+
+    yield* scan(state, bus, dir, SKILL_PATTERN)
+  }
+
+  for (const url of cfg.skills?.urls ?? []) {
+    const pulledDirs = yield* discovery.pull(url)
+    for (const dir of pulledDirs) {
+      state.dirs.add(dir)
+      yield* scan(state, bus, dir, SKILL_PATTERN)
+    }
+  }
+
+  log.info("init", { count: Object.keys(state.skills).length })
+})
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/Skill") {}
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const discovery = yield* Discovery.Service
+    const config = yield* Config.Service
+    const bus = yield* Bus.Service
+    const fsys = yield* AppFileSystem.Service
+    const state = yield* InstanceState.make(
+      Effect.fn("Skill.state")(function* (ctx) {
+        const s: State = { skills: {}, dirs: new Set() }
+        yield* loadSkills(s, config, discovery, bus, fsys, ctx.directory, ctx.worktree)
+        return s
+      }),
+    )
+
+    const get = Effect.fn("Skill.get")(function* (name: string) {
+      const s = yield* InstanceState.get(state)
+      return s.skills[name]
+    })
+
+    const all = Effect.fn("Skill.all")(function* () {
+      const s = yield* InstanceState.get(state)
+      return Object.values(s.skills)
+    })
+
+    const dirs = Effect.fn("Skill.dirs")(function* () {
+      const s = yield* InstanceState.get(state)
+      return Array.from(s.dirs)
+    })
+
+    const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
+      const s = yield* InstanceState.get(state)
+      const list = Object.values(s.skills).toSorted((a, b) => a.name.localeCompare(b.name))
+      if (!agent) return list
+      return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
+    })
+
+    return Service.of({ get, all, dirs, available })
+  }),
+)
+
+export const defaultLayer = layer.pipe(
+  Layer.provide(Discovery.defaultLayer),
+  Layer.provide(Config.defaultLayer),
+  Layer.provide(Bus.layer),
+  Layer.provide(AppFileSystem.defaultLayer),
+)
+
+export function fmt(list: Info[], opts: { verbose: boolean }) {
+  if (list.length === 0) return "No skills are currently available."
+  if (opts.verbose) {
+    return [
+      "<available_skills>",
+      ...list
+        .sort((a, b) => a.name.localeCompare(b.name))
+        .flatMap((skill) => [
+          "  <skill>",
+          `    <name>${skill.name}</name>`,
+          `    <description>${skill.description}</description>`,
+          `    <location>${pathToFileURL(skill.location).href}</location>`,
+          "  </skill>",
+        ]),
+      "</available_skills>",
+    ].join("\n")
+  }
+
+  return [
+    "## Available Skills",
+    ...list
+      .toSorted((a, b) => a.name.localeCompare(b.name))
+      .map((skill) => `- **${skill.name}**: ${skill.description}`),
+  ].join("\n")
+}