mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-20 13:44:27 +00:00
Compare commits
7 Commits
kit/skill-
...
kit/effect
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6e94fd2a8 | ||
|
|
3d5c041129 | ||
|
|
3af557512b | ||
|
|
b53a95fd81 | ||
|
|
8e11a46fe0 | ||
|
|
8ab4d84057 | ||
|
|
4066247988 |
@@ -132,7 +132,7 @@ Still open and likely worth migrating:
|
||||
- [ ] `Worktree`
|
||||
- [ ] `Installation`
|
||||
- [ ] `Bus`
|
||||
- [ ] `Command`
|
||||
- [x] `Command`
|
||||
- [ ] `Config`
|
||||
- [ ] `Session`
|
||||
- [ ] `SessionProcessor`
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { Effect, Fiber, Layer, ServiceMap } from "effect"
|
||||
import z from "zod"
|
||||
import { Config } from "../config/config"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Identifier } from "../id/id"
|
||||
import PROMPT_INITIALIZE from "./template/initialize.txt"
|
||||
import PROMPT_REVIEW from "./template/review.txt"
|
||||
import { MCP } from "../mcp"
|
||||
import { Skill } from "../skill"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
export namespace Command {
|
||||
const log = Log.create({ service: "command" })
|
||||
export const Event = {
|
||||
Executed: BusEvent.define(
|
||||
"command.executed",
|
||||
@@ -57,95 +60,126 @@ export namespace Command {
|
||||
REVIEW: "review",
|
||||
} as const
|
||||
|
||||
const state = Instance.state(async () => {
|
||||
const cfg = await Config.get()
|
||||
export interface Interface {
|
||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
const result: Record<string, Info> = {
|
||||
[Default.INIT]: {
|
||||
name: Default.INIT,
|
||||
description: "create/update AGENTS.md",
|
||||
source: "command",
|
||||
get template() {
|
||||
return PROMPT_INITIALIZE.replace("${path}", Instance.worktree)
|
||||
},
|
||||
hints: hints(PROMPT_INITIALIZE),
|
||||
},
|
||||
[Default.REVIEW]: {
|
||||
name: Default.REVIEW,
|
||||
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
||||
source: "command",
|
||||
get template() {
|
||||
return PROMPT_REVIEW.replace("${path}", Instance.worktree)
|
||||
},
|
||||
subtask: true,
|
||||
hints: hints(PROMPT_REVIEW),
|
||||
},
|
||||
}
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Command") {}
|
||||
|
||||
for (const [name, command] of Object.entries(cfg.command ?? {})) {
|
||||
result[name] = {
|
||||
name,
|
||||
agent: command.agent,
|
||||
model: command.model,
|
||||
description: command.description,
|
||||
source: "command",
|
||||
get template() {
|
||||
return command.template
|
||||
},
|
||||
subtask: command.subtask,
|
||||
hints: hints(command.template),
|
||||
}
|
||||
}
|
||||
for (const [name, prompt] of Object.entries(await MCP.prompts())) {
|
||||
result[name] = {
|
||||
name,
|
||||
source: "mcp",
|
||||
description: prompt.description,
|
||||
get template() {
|
||||
// since a getter can't be async we need to manually return a promise here
|
||||
return new Promise<string>(async (resolve, reject) => {
|
||||
const template = await MCP.getPrompt(
|
||||
prompt.client,
|
||||
prompt.name,
|
||||
prompt.arguments
|
||||
? // substitute each argument with $1, $2, etc.
|
||||
Object.fromEntries(prompt.arguments?.map((argument, i) => [argument.name, `$${i + 1}`]))
|
||||
: {},
|
||||
).catch(reject)
|
||||
resolve(
|
||||
template?.messages
|
||||
.map((message) => (message.content.type === "text" ? message.content.text : ""))
|
||||
.join("\n") || "",
|
||||
)
|
||||
})
|
||||
},
|
||||
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
|
||||
}
|
||||
}
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
|
||||
// Add skills as invokable commands
|
||||
for (const skill of await Skill.all()) {
|
||||
// Skip if a command with this name already exists
|
||||
if (result[skill.name]) continue
|
||||
result[skill.name] = {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
source: "skill",
|
||||
get template() {
|
||||
return skill.content
|
||||
},
|
||||
hints: [],
|
||||
}
|
||||
}
|
||||
const commands: Record<string, Info> = {}
|
||||
|
||||
return result
|
||||
})
|
||||
const load = Effect.fn("Command.load")(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
const cfg = await Config.get()
|
||||
|
||||
commands[Default.INIT] = {
|
||||
name: Default.INIT,
|
||||
description: "create/update AGENTS.md",
|
||||
source: "command",
|
||||
get template() {
|
||||
return PROMPT_INITIALIZE.replace("${path}", instance.worktree)
|
||||
},
|
||||
hints: hints(PROMPT_INITIALIZE),
|
||||
}
|
||||
commands[Default.REVIEW] = {
|
||||
name: Default.REVIEW,
|
||||
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
||||
source: "command",
|
||||
get template() {
|
||||
return PROMPT_REVIEW.replace("${path}", instance.worktree)
|
||||
},
|
||||
subtask: true,
|
||||
hints: hints(PROMPT_REVIEW),
|
||||
}
|
||||
|
||||
for (const [name, command] of Object.entries(cfg.command ?? {})) {
|
||||
commands[name] = {
|
||||
name,
|
||||
agent: command.agent,
|
||||
model: command.model,
|
||||
description: command.description,
|
||||
source: "command",
|
||||
get template() {
|
||||
return command.template
|
||||
},
|
||||
subtask: command.subtask,
|
||||
hints: hints(command.template),
|
||||
}
|
||||
}
|
||||
for (const [name, prompt] of Object.entries(await MCP.prompts())) {
|
||||
commands[name] = {
|
||||
name,
|
||||
source: "mcp",
|
||||
description: prompt.description,
|
||||
get template() {
|
||||
// since a getter can't be async we need to manually return a promise here
|
||||
return new Promise<string>(async (resolve, reject) => {
|
||||
const template = await MCP.getPrompt(
|
||||
prompt.client,
|
||||
prompt.name,
|
||||
prompt.arguments
|
||||
? // substitute each argument with $1, $2, etc.
|
||||
Object.fromEntries(prompt.arguments?.map((argument, i) => [argument.name, `$${i + 1}`]))
|
||||
: {},
|
||||
).catch(reject)
|
||||
resolve(
|
||||
template?.messages
|
||||
.map((message) => (message.content.type === "text" ? message.content.text : ""))
|
||||
.join("\n") || "",
|
||||
)
|
||||
})
|
||||
},
|
||||
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
// Add skills as invokable commands
|
||||
for (const skill of await Skill.all()) {
|
||||
// Skip if a command with this name already exists
|
||||
if (commands[skill.name]) continue
|
||||
commands[skill.name] = {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
source: "skill",
|
||||
get template() {
|
||||
return skill.content
|
||||
},
|
||||
hints: [],
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const loadFiber = yield* load().pipe(
|
||||
Effect.catchCause((cause) => Effect.sync(() => log.error("init failed", { cause }))),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
const get = Effect.fn("Command.get")(function* (name: string) {
|
||||
yield* Fiber.join(loadFiber)
|
||||
return commands[name]
|
||||
})
|
||||
|
||||
const list = Effect.fn("Command.list")(function* () {
|
||||
yield* Fiber.join(loadFiber)
|
||||
return Object.values(commands)
|
||||
})
|
||||
|
||||
return Service.of({ get, list })
|
||||
}),
|
||||
)
|
||||
|
||||
export async function get(name: string) {
|
||||
return state().then((x) => x[name])
|
||||
return runPromiseInstance(Service.use((svc) => svc.get(name)))
|
||||
}
|
||||
|
||||
export async function list() {
|
||||
return state().then((x) => Object.values(x))
|
||||
return runPromiseInstance(Service.use((svc) => svc.list()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import z from "zod"
|
||||
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
@@ -108,7 +109,9 @@ export namespace ConfigPaths {
|
||||
}
|
||||
|
||||
let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
|
||||
filePath = Filesystem.expandHome(filePath)
|
||||
if (filePath.startsWith("~/")) {
|
||||
filePath = path.join(os.homedir(), filePath.slice(2))
|
||||
}
|
||||
|
||||
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
|
||||
const fileContent = (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
|
||||
import { Command } from "@/command"
|
||||
import { File } from "@/file"
|
||||
import { FileTime } from "@/file/time"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
@@ -16,6 +17,7 @@ import { registerDisposer } from "./instance-registry"
|
||||
export { InstanceContext } from "./instance-context"
|
||||
|
||||
export type InstanceServices =
|
||||
| Command.Service
|
||||
| Question.Service
|
||||
| PermissionNext.Service
|
||||
| ProviderAuth.Service
|
||||
@@ -36,6 +38,7 @@ export type InstanceServices =
|
||||
function lookup(_key: string) {
|
||||
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
|
||||
return Layer.mergeAll(
|
||||
Layer.fresh(Command.layer),
|
||||
Layer.fresh(Question.layer),
|
||||
Layer.fresh(PermissionNext.layer),
|
||||
Layer.fresh(ProviderAuth.defaultLayer),
|
||||
|
||||
@@ -95,7 +95,9 @@ export namespace InstructionPrompt {
|
||||
if (config.instructions) {
|
||||
for (let instruction of config.instructions) {
|
||||
if (instruction.startsWith("https://") || instruction.startsWith("http://")) continue
|
||||
instruction = Filesystem.expandHome(instruction)
|
||||
if (instruction.startsWith("~/")) {
|
||||
instruction = path.join(os.homedir(), instruction.slice(2))
|
||||
}
|
||||
const matches = path.isAbsolute(instruction)
|
||||
? await Glob.scan(path.basename(instruction), {
|
||||
cwd: path.dirname(instruction),
|
||||
|
||||
@@ -203,7 +203,7 @@ export namespace SessionPrompt {
|
||||
if (seen.has(name)) return
|
||||
seen.add(name)
|
||||
const filepath = name.startsWith("~/")
|
||||
? Filesystem.expandHome(name)
|
||||
? path.join(os.homedir(), name.slice(2))
|
||||
: path.resolve(Instance.worktree, name)
|
||||
|
||||
const stats = await fs.stat(filepath).catch(() => undefined)
|
||||
@@ -1782,6 +1782,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
export async function command(input: CommandInput) {
|
||||
log.info("command", input)
|
||||
const command = await Command.get(input.command)
|
||||
if (!command) {
|
||||
throw new NamedError.Unknown({ message: `Command not found: "${input.command}"` })
|
||||
}
|
||||
const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent())
|
||||
|
||||
const raw = input.arguments.match(argsRegex) ?? []
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import z from "zod"
|
||||
import { Effect, Fiber, Layer, ServiceMap } from "effect"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
import { Bus } from "@/bus"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { InstanceContext } from "@/effect/instance-context"
|
||||
import { runPromiseInstance } from "@/effect/runtime"
|
||||
import { Flag } from "@/flag/flag"
|
||||
@@ -14,6 +14,7 @@ import { PermissionNext } from "@/permission"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Config } from "../config/config"
|
||||
import { ConfigMarkdown } from "../config/markdown"
|
||||
import { Glob } from "../util/glob"
|
||||
import { Log } from "../util/log"
|
||||
import { Discovery } from "./discovery"
|
||||
|
||||
@@ -53,6 +54,11 @@ export namespace Skill {
|
||||
type State = {
|
||||
skills: Record<string, Info>
|
||||
dirs: Set<string>
|
||||
task?: Promise<void>
|
||||
}
|
||||
|
||||
type Cache = State & {
|
||||
ensure: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -62,172 +68,153 @@ export namespace Skill {
|
||||
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
|
||||
const add = async (state: State, match: string) => {
|
||||
const md = await ConfigMarkdown.parse(match).catch(async (err) => {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse skill ${match}`
|
||||
const { Session } = await import("@/session")
|
||||
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
log.error("failed to load skill", { skill: match, err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service | AppFileSystem.Service> =
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
const discovery = yield* Discovery.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
if (!md) return
|
||||
|
||||
const state: State = {
|
||||
skills: {},
|
||||
dirs: new Set<string>(),
|
||||
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 = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
|
||||
return Glob.scan(pattern, {
|
||||
cwd: root,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
symlink: true,
|
||||
dot: opts?.dot,
|
||||
})
|
||||
.then((matches) => Promise.all(matches.map((match) => add(state, match))))
|
||||
.catch((error) => {
|
||||
if (!opts?.scope) throw error
|
||||
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Migrate to Effect
|
||||
const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => {
|
||||
const state: State = {
|
||||
skills: {},
|
||||
dirs: new Set<string>(),
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
||||
for (const dir of EXTERNAL_DIRS) {
|
||||
const root = path.join(Global.Path.home, dir)
|
||||
if (!(await Filesystem.isDir(root))) continue
|
||||
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
||||
}
|
||||
|
||||
const add = Effect.fn("Skill.add")(function* (match: string) {
|
||||
const md = yield* Effect.tryPromise(() => ConfigMarkdown.parse(match)).pipe(
|
||||
Effect.catch((err) =>
|
||||
Effect.gen(function* () {
|
||||
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
||||
? err.data.message
|
||||
: `Failed to parse skill ${match}`
|
||||
const { Session } = yield* Effect.promise(() => import("@/session"))
|
||||
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
log.error("failed to load skill", { skill: match, err })
|
||||
return undefined
|
||||
}),
|
||||
),
|
||||
)
|
||||
for await (const root of Filesystem.up({
|
||||
targets: EXTERNAL_DIRS,
|
||||
start: instance.directory,
|
||||
stop: instance.project.worktree,
|
||||
})) {
|
||||
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
|
||||
}
|
||||
}
|
||||
|
||||
if (!md) return
|
||||
for (const dir of await Config.directories()) {
|
||||
await scan(state, dir, OPENCODE_SKILL_PATTERN)
|
||||
}
|
||||
|
||||
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
|
||||
if (!parsed.success) return
|
||||
const cfg = await 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(instance.directory, expanded)
|
||||
if (!(await Filesystem.isDir(dir))) {
|
||||
log.warn("skill path not found", { path: dir })
|
||||
continue
|
||||
}
|
||||
|
||||
if (state.skills[parsed.data.name]) {
|
||||
log.warn("duplicate skill name", {
|
||||
name: parsed.data.name,
|
||||
existing: state.skills[parsed.data.name].location,
|
||||
duplicate: match,
|
||||
})
|
||||
}
|
||||
await scan(state, dir, SKILL_PATTERN)
|
||||
}
|
||||
|
||||
state.dirs.add(path.dirname(match))
|
||||
state.skills[parsed.data.name] = {
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description,
|
||||
location: match,
|
||||
content: md.content,
|
||||
}
|
||||
})
|
||||
for (const url of cfg.skills?.urls ?? []) {
|
||||
for (const dir of await Effect.runPromise(discovery.pull(url))) {
|
||||
state.dirs.add(dir)
|
||||
await scan(state, dir, SKILL_PATTERN)
|
||||
}
|
||||
}
|
||||
|
||||
const scan = Effect.fn("Skill.scan")(function* (
|
||||
root: string,
|
||||
pattern: string,
|
||||
opts?: { dot?: boolean; scope?: string },
|
||||
) {
|
||||
const matches = yield* fs
|
||||
.glob(pattern, {
|
||||
cwd: root,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
symlink: true,
|
||||
dot: opts?.dot,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!opts?.scope) return Effect.fail(error)
|
||||
return Effect.sync(() => {
|
||||
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
|
||||
return [] as string[]
|
||||
})
|
||||
}),
|
||||
)
|
||||
log.info("init", { count: Object.keys(state.skills).length })
|
||||
}
|
||||
|
||||
yield* Effect.forEach(matches, (match) => add(match), { concurrency: "unbounded" })
|
||||
})
|
||||
const ensure = () => {
|
||||
if (state.task) return state.task
|
||||
state.task = load().catch((err) => {
|
||||
state.task = undefined
|
||||
throw err
|
||||
})
|
||||
return state.task
|
||||
}
|
||||
|
||||
const load = Effect.fn("Skill.load")(function* () {
|
||||
// Phase 1: External dirs (global)
|
||||
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
||||
for (const dir of EXTERNAL_DIRS) {
|
||||
const root = path.join(Global.Path.home, dir)
|
||||
if (!(yield* fs.isDir(root).pipe(Effect.orDie))) continue
|
||||
yield* scan(root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
||||
}
|
||||
return { ...state, ensure }
|
||||
}
|
||||
|
||||
// Phase 2: External dirs (project, walk up)
|
||||
const roots = yield* fs
|
||||
.up({
|
||||
targets: EXTERNAL_DIRS,
|
||||
start: instance.directory,
|
||||
stop: instance.project.worktree,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
|
||||
|
||||
yield* Effect.forEach(
|
||||
roots,
|
||||
(root) => scan(root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" }),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
}
|
||||
export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceContext
|
||||
const discovery = yield* Discovery.Service
|
||||
const state = create(instance, discovery)
|
||||
|
||||
// Phase 3: Config directories
|
||||
const dirs = yield* Effect.promise(() => Config.directories())
|
||||
yield* Effect.forEach(dirs, (dir) => scan(dir, OPENCODE_SKILL_PATTERN), { concurrency: "unbounded" })
|
||||
const get = Effect.fn("Skill.get")(function* (name: string) {
|
||||
yield* Effect.promise(() => state.ensure())
|
||||
return state.skills[name]
|
||||
})
|
||||
|
||||
// Phase 4: Custom paths
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
for (const item of cfg.skills?.paths ?? []) {
|
||||
const expanded = Filesystem.expandHome(item)
|
||||
const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
|
||||
if (!(yield* fs.isDir(dir).pipe(Effect.orDie))) {
|
||||
log.warn("skill path not found", { path: dir })
|
||||
continue
|
||||
}
|
||||
const all = Effect.fn("Skill.all")(function* () {
|
||||
yield* Effect.promise(() => state.ensure())
|
||||
return Object.values(state.skills)
|
||||
})
|
||||
|
||||
yield* scan(dir, SKILL_PATTERN)
|
||||
}
|
||||
const dirs = Effect.fn("Skill.dirs")(function* () {
|
||||
yield* Effect.promise(() => state.ensure())
|
||||
return Array.from(state.dirs)
|
||||
})
|
||||
|
||||
// Phase 5: Remote URLs
|
||||
for (const url of cfg.skills?.urls ?? []) {
|
||||
const pullDirs = yield* discovery.pull(url)
|
||||
for (const dir of pullDirs) {
|
||||
state.dirs.add(dir)
|
||||
yield* scan(dir, SKILL_PATTERN)
|
||||
}
|
||||
}
|
||||
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
|
||||
yield* Effect.promise(() => state.ensure())
|
||||
const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
if (!agent) return list
|
||||
return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
|
||||
})
|
||||
|
||||
log.info("init", { count: Object.keys(state.skills).length })
|
||||
})
|
||||
|
||||
const loadFiber = yield* load().pipe(
|
||||
Effect.catchCause((cause) => Effect.sync(() => log.error("init failed", { cause }))),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
const get = Effect.fn("Skill.get")(function* (name: string) {
|
||||
yield* Fiber.join(loadFiber)
|
||||
return state.skills[name]
|
||||
})
|
||||
|
||||
const all = Effect.fn("Skill.all")(function* () {
|
||||
yield* Fiber.join(loadFiber)
|
||||
return Object.values(state.skills)
|
||||
})
|
||||
|
||||
const dirs = Effect.fn("Skill.dirs")(function* () {
|
||||
yield* Fiber.join(loadFiber)
|
||||
return Array.from(state.dirs)
|
||||
})
|
||||
|
||||
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
|
||||
yield* Fiber.join(loadFiber)
|
||||
const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
if (!agent) return list
|
||||
return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
|
||||
})
|
||||
|
||||
return Service.of({ get, all, dirs, available })
|
||||
}),
|
||||
)
|
||||
return Service.of({ get, all, dirs, available })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = layer.pipe(
|
||||
Layer.provide(Discovery.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
)
|
||||
|
||||
export async function get(name: string) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { chmod, mkdir, readFile, writeFile } from "fs/promises"
|
||||
import { createWriteStream, existsSync, statSync } from "fs"
|
||||
import { lookup } from "mime-types"
|
||||
import { realpathSync } from "fs"
|
||||
import os from "os"
|
||||
import { dirname, join, relative, resolve as pathResolve } from "path"
|
||||
import { Readable } from "stream"
|
||||
import { pipeline } from "stream/promises"
|
||||
@@ -96,10 +95,6 @@ export namespace Filesystem {
|
||||
}
|
||||
}
|
||||
|
||||
export function expandHome(p: string): string {
|
||||
return p.startsWith("~/") ? join(os.homedir(), p.slice(2)) : p
|
||||
}
|
||||
|
||||
export function mimeType(p: string): string {
|
||||
return lookup(p) || "application/octet-stream"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user