Skip to content

Commit 69269e4

Browse files
authored
feat(core): support in-memory skill:// URIs (#60921)
1 parent f4ec153 commit 69269e4

23 files changed

Lines changed: 356 additions & 41 deletions

File tree

‎packages/app/e2e/regression/session-summary.spec.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -684,7 +684,7 @@ test("Extensions lists open config files and skills in a side tab and start setu
684684
mcp: [{ name: "atlas-search", status: { status: "connected" } }],
685685
plugins: [],
686686
skills: [
687-
{ id: "opencode", name: "OpenCode", path: "/builtin/opencode.md", content: "# Built-in OpenCode guide" },
687+
{ id: "opencode", name: "OpenCode", path: "skill://opencode/SKILL.md", content: "# Built-in OpenCode guide" },
688688
{ id: "release-notes", name: "release-notes", path: skill, content: "Release notes" },
689689
],
690690
configEntries: [
@@ -699,12 +699,12 @@ test("Extensions lists open config files and skills in a side tab and start setu
699699
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
700700
})
701701

702-
await page.route(/\/api\/fs\/read\/opencode\.md(\?|$)/, (route) => {
702+
await page.route(/\/api\/fs\/read\/SKILL\.md(\?|$)/, (route) => {
703703
builtinReads += 1
704704

705705
return route.fulfill({
706706
status: 404,
707-
json: { message: "Location not found: /builtin" },
707+
json: { message: "Location not found: skill://opencode" },
708708
headers: cors,
709709
})
710710
})
@@ -728,7 +728,7 @@ test("Extensions lists open config files and skills in a side tab and start setu
728728
await row("Skills").hover()
729729
await list("Skills").getByRole("button", { name: "OpenCode", exact: true }).click()
730730
await expect(summary).toBeHidden()
731-
await expect(page.getByRole("tab", { name: "opencode.md", exact: true })).toHaveAttribute("aria-selected", "true")
731+
await expect(page.getByRole("tab", { name: "SKILL.md", exact: true })).toHaveAttribute("aria-selected", "true")
732732
await expect(page.getByRole("heading", { name: "Built-in OpenCode guide", exact: true })).toBeVisible()
733733
await expect(page.getByText("Failed to load file", { exact: true })).toHaveCount(0)
734734
expect(builtinReads).toBe(0)
@@ -737,7 +737,8 @@ test("Extensions lists open config files and skills in a side tab and start setu
737737
await row("Skills").hover()
738738
await list("Skills").getByRole("button", { name: "release-notes", exact: true }).click()
739739
await expect(summary).toBeHidden()
740-
await expect(page.getByRole("tab", { name: "SKILL.md", exact: true })).toHaveAttribute("aria-selected", "true")
740+
await expect(page.getByRole("tab", { name: "SKILL.md", selected: true, exact: true })).toBeVisible()
741+
await expect(page.getByText("contents:.opencode/skills/release-notes/SKILL.md", { exact: true })).toBeVisible()
741742

742743
// An empty list offers a draft session in the global configuration's directory, its prompt filled in.
743744
await trigger.click()

‎packages/app/src/workspaces/files/model.tsx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,9 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
180180
)
181181
}
182182

183-
// Built-in skills register virtual `/builtin/*.md` paths whose content is inline in `/api/skill`.
183+
// Built-in and virtual skills register `skill://` (or legacy `/builtin/`) paths whose content is inline in `/api/skill`.
184+
const isVirtualSkill = (file: string) => file.startsWith("skill://") || file.startsWith("/builtin/")
185+
184186
const builtinSkill = async (file: string, directory: string) => {
185187
if (data.location.skill.list({ directory }) === undefined) {
186188
await data.location.skill.sync({ directory })
@@ -225,7 +227,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
225227

226228
// Files outside the workspace are read from their own directory, like markdown images.
227229
// The trailing separator from getDirectory keeps "/" and "C:/" valid, like readLocalImage.
228-
const source = file.startsWith("/builtin/")
230+
const source = isVirtualSkill(file)
229231
? readBuiltin(file, directory)
230232
: read(
231233
file,
@@ -272,7 +274,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
272274

273275
if (!file) return Promise.resolve(false)
274276

275-
if (file.startsWith("/builtin/")) {
277+
if (isVirtualSkill(file)) {
276278
return builtinSkill(file, scope()).then(Boolean, () => false)
277279
}
278280

‎packages/app/src/workspaces/files/path.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,8 @@ export function createPathHelpers(scope: () => string) {
121121
}
122122

123123
/** Whether a normalized path points outside the workspace root. */
124-
const absolute = (path: string) => /^[A-Za-z]:[/\\]/.test(path) || path.startsWith("/") || path.startsWith("\\\\")
124+
const absolute = (path: string) =>
125+
path.startsWith("skill://") || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith("/") || path.startsWith("\\\\")
125126

126127
const normalizeDir = (input: string) => {
127128
const path = normalize(input)

‎packages/core/src/plugin/skill.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export const Plugin = define({
3030
id: Skill.ID.make("opencode"),
3131
name: Skill.Name.make("OpenCode"),
3232
description: OpencodeDescription,
33-
path: AbsolutePath.make("/builtin/opencode.md"),
33+
path: AbsolutePath.make("skill://opencode/SKILL.md"),
3434
content: OpencodeContent,
3535
}),
3636
)
@@ -39,7 +39,7 @@ export const Plugin = define({
3939
id: Skill.ID.make("report"),
4040
name: Skill.Name.make("Report"),
4141
description: REPORT_DESCRIPTION,
42-
path: AbsolutePath.make("/builtin/report.md"),
42+
path: AbsolutePath.make("skill://report/SKILL.md"),
4343
content: reportContent,
4444
}),
4545
)

‎packages/core/src/session/prompt.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,11 @@ export const prepare = Effect.fn("SessionPrompt.prepare")(function* (request: {
6767
const skill = yield* skillService.get(attachment.id)
6868
if (!skill) return yield* new SkillNotFoundError({ skill: attachment.id })
6969
prepared.set(skill.id, skill.name)
70+
const files = yield* skillService.files(skill.id)
7071
return {
7172
id: skill.id,
7273
name: skill.name,
73-
text: (yield* Skill.prepare(fs, skill).pipe(Effect.orDie)).output,
74+
text: (yield* Skill.prepare(fs, skill, files).pipe(Effect.orDie)).output,
7475
mention: attachment.mention,
7576
}
7677
}),

‎packages/core/src/skill.ts‎

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,12 @@ export { Event } from "@opencode/schema/skill"
3333
export const available = (skills: ReadonlyArray<Info>, permissions: Permission.Ruleset) =>
3434
skills.filter((skill) => Permission.evaluate("skill", skill.id, permissions).effect !== "deny")
3535

36+
export const uri = (id: string, file?: string) => (file ? `skill://${id}/${file}` : `skill://${id}`)
37+
38+
export const isUri = (value: string) => value.startsWith("skill://")
39+
3640
export const toModelOutput = (skill: Info, files: ReadonlyArray<string>) => {
37-
const directory = path.dirname(skill.path)
41+
const directory = isUri(skill.path) ? uri(skill.id) : path.dirname(skill.path)
3842
return [
3943
`<skill_content name="${skill.name}">`,
4044
`# Skill: ${skill.name}`,
@@ -52,7 +56,23 @@ export const toModelOutput = (skill: Info, files: ReadonlyArray<string>) => {
5256
].join("\n")
5357
}
5458

55-
export const prepare = Effect.fn("Skill.prepare")(function* (fs: FSUtil.Interface, skill: Info) {
59+
export const prepare = Effect.fn("Skill.prepare")(function* (
60+
fs: FSUtil.Interface,
61+
skill: Info,
62+
virtualFiles?: ReadonlyMap<string, string>,
63+
) {
64+
if (isUri(skill.path)) {
65+
const directory = uri(skill.id)
66+
const files = Array.from(virtualFiles?.keys() ?? [])
67+
.filter((file) => file !== "SKILL.md")
68+
.toSorted()
69+
.slice(0, 10)
70+
.map((file) => uri(skill.id, file))
71+
return {
72+
directory,
73+
output: toModelOutput(skill, files),
74+
}
75+
}
5676
const directory = path.dirname(skill.path)
5777
const files =
5878
path.basename(skill.path) === "SKILL.md"
@@ -75,18 +95,20 @@ export const prepare = Effect.fn("Skill.prepare")(function* (fs: FSUtil.Interfac
7595

7696
export type Data = {
7797
skills: Map<ID, Types.DeepMutable<Info>>
98+
files: Map<ID, ReadonlyMap<string, string>>
7899
}
79100

80101
export type Editor = {
81102
list: () => readonly Types.DeepMutable<Info>[]
82103
get: (id: string) => Types.DeepMutable<Info> | undefined
83-
add: (skill: Info) => void
104+
add: (skill: Info, files?: Readonly<Record<string, string>>) => void
84105
update: (id: string, update: (skill: Types.DeepMutable<Info>) => void) => void
85106
remove: (id: string) => void
86107
}
87108

88109
export interface Interface extends State.Transformable<Editor> {
89110
readonly get: (id: ID) => Effect.Effect<Info | undefined>
111+
readonly files: (id: ID) => Effect.Effect<ReadonlyMap<string, string> | undefined>
90112
readonly list: () => Effect.Effect<Info[]>
91113
}
92114

@@ -99,12 +121,22 @@ const layer = Layer.effect(
99121

100122
const state = State.create<Data, Editor>({
101123
name: "skill",
102-
initial: () => ({ skills: new Map() }),
124+
initial: () => ({ skills: new Map(), files: new Map() }),
103125
editor: (editor) => ({
104126
list: () => Array.from(editor.skills.values()),
105127
get: (id) => editor.skills.get(ID.make(id)),
106-
add: (skill) => {
128+
add: (skill, files) => {
107129
editor.skills.set(skill.id, { ...skill })
130+
const entries = files
131+
? Object.entries(files)
132+
.filter(([file]) => file !== "SKILL.md")
133+
.toSorted(([a], [b]) => a.localeCompare(b))
134+
: []
135+
if (entries.length === 0) {
136+
editor.files.delete(skill.id)
137+
return
138+
}
139+
editor.files.set(skill.id, new Map(entries))
108140
},
109141
update: (id, update) => {
110142
const key = ID.make(id)
@@ -114,11 +146,16 @@ const layer = Layer.effect(
114146
current.id = key
115147
},
116148
remove: (id) => {
117-
editor.skills.delete(ID.make(id))
149+
const key = ID.make(id)
150+
editor.skills.delete(key)
151+
editor.files.delete(key)
118152
},
119153
}),
120154
notify: State.changes(
121-
(data) => Array.from(data.skills.values()),
155+
(data) => [
156+
Array.from(data.skills.values()),
157+
Array.from(data.files, ([id, entries]) => [id, Array.from(entries)]),
158+
],
122159
() => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
123160
),
124161
})
@@ -129,6 +166,10 @@ const layer = Layer.effect(
129166
get: Effect.fn("Skill.get")(function* (id) {
130167
return state.get().skills.get(id)
131168
}),
169+
files: Effect.fn("Skill.files")(function* (id) {
170+
const current = state.get()
171+
return current.skills.has(id) ? current.files.get(id) : undefined
172+
}),
132173
list: Effect.fn("Skill.list")(function* () {
133174
return Array.from(state.get().skills.values())
134175
}),

‎packages/core/src/skill/discovery.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { Hash } from "@opencode/util/hash"
1313
const skillConcurrency = 4
1414
const fileConcurrency = 8
1515

16-
function isSafeSegment(value: string) {
16+
export function isSafeSegment(value: string) {
1717
return (
1818
value.length > 0 &&
1919
value !== "." &&
@@ -24,7 +24,7 @@ function isSafeSegment(value: string) {
2424
)
2525
}
2626

27-
function isSafeRelativePath(value: string) {
27+
export function isSafeRelativePath(value: string) {
2828
const segments = value.split("/")
2929
return (
3030
value.length > 0 &&

‎packages/core/src/tool/plugin/read.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@ import { Effect, Schema } from "effect"
77
import { FSUtil } from "@opencode/util/fs-util"
88
import { Location } from "../../location.js"
99
import { FileAccess } from "../../file-access.js"
10+
import { Permission } from "../../permission.js"
1011
import { SessionInstructions } from "../../session/instructions.js"
1112
import { AbsolutePath } from "../../schema.js"
13+
import { Skill } from "../../skill.js"
14+
import { SkillDiscovery } from "../../skill/discovery.js"
1215
import { ReadToolFileSystem } from "../read-filesystem.js"
1316
import { Environment } from "../../environment/index.js"
1417

@@ -31,6 +34,8 @@ export const Plugin = {
3134
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: Context) {
3235
const reader = yield* ReadToolFileSystem.Service
3336
const access = yield* FileAccess.Service
37+
const permission = yield* Permission.Service
38+
const skills = yield* Skill.Service
3439
const sessionInstructions = yield* SessionInstructions.Service
3540
const fs = yield* FSUtil.Service
3641
const location = yield* Location.Service
@@ -46,6 +51,51 @@ export const Plugin = {
4651
output: Output,
4752
execute: (input, context) => {
4853
return Effect.gen(function* () {
54+
if (Skill.isUri(input.path)) {
55+
const parsed = parseSkillUri(input.path)
56+
if (!parsed) return yield* new ToolFailure({ message: `File not found: ${input.path}` })
57+
const id = Skill.ID.make(parsed.id)
58+
const skill = yield* skills.get(id)
59+
if (!skill || !Skill.isUri(skill.path))
60+
return yield* new ToolFailure({ message: `File not found: ${input.path}` })
61+
const extra = yield* skills.files(id)
62+
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
63+
yield* permission.assert({
64+
action: "skill",
65+
resources: [skill.id],
66+
save: [skill.id],
67+
sessionID: context.sessionID,
68+
agent: context.agent,
69+
source,
70+
})
71+
yield* permission.assert({
72+
action: "read",
73+
resources: [parsed.uri],
74+
save: ["*"],
75+
sessionID: context.sessionID,
76+
agent: context.agent,
77+
source,
78+
})
79+
const virtualFs = Environment.makeFiles(Environment.makeMemoryDriver())
80+
const encoder = new TextEncoder()
81+
yield* virtualFs.write(AbsolutePath.make("/SKILL.md"), encoder.encode(skill.content))
82+
yield* Effect.forEach(
83+
extra ? Array.from(extra) : [],
84+
([rel, text]) => virtualFs.write(AbsolutePath.make(`/${rel}`), encoder.encode(text)),
85+
{ discard: true },
86+
)
87+
const output = yield* ReadToolFileSystem.read(virtualFs, AbsolutePath.make(parsed.target), input.path, {
88+
offset: input.offset,
89+
limit: input.limit,
90+
}).pipe(
91+
Effect.catchIf(
92+
(error) => error instanceof Environment.NotFound,
93+
() => Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
94+
),
95+
)
96+
return { output, path: input.path }
97+
}
98+
4999
const read = (target: FileAccess.Target) =>
50100
reader.read(target.absolute, target.resource, {
51101
offset: input.offset,
@@ -199,6 +249,17 @@ export const Plugin = {
199249
}),
200250
}
201251

252+
function parseSkillUri(value: string) {
253+
const trimmed = value.slice("skill://".length).replace(/\/+$/, "")
254+
const slash = trimmed.indexOf("/")
255+
const id = slash === -1 ? trimmed : trimmed.slice(0, slash)
256+
const rest = slash === -1 ? "" : trimmed.slice(slash + 1)
257+
if (!SkillDiscovery.isSafeSegment(id)) return undefined
258+
if (rest !== "" && !rest.split("/").every(SkillDiscovery.isSafeSegment)) return undefined
259+
// Permission checks use the canonical URI, so `skill://id/file/` can't slip past a rule for `skill://id/file`.
260+
return { id, target: rest === "" ? "/" : `/${rest}`, uri: Skill.uri(id, rest || undefined) }
261+
}
262+
202263
export const toModelContent = (path: string, offset: number | undefined, output: typeof Output.Type) => {
203264
if (output.type === "file" && output.encoding === "base64")
204265
return [

‎packages/core/src/tool/plugin/skill.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ export const Plugin = {
5656
agent: context.agent,
5757
source: { type: "tool", messageID: context.messageID, id: context.id },
5858
})
59-
return { name: skill.name, ...(yield* Skill.prepare(fs, skill)) }
59+
const files = yield* skills.files(skill.id)
60+
return { name: skill.name, ...(yield* Skill.prepare(fs, skill, files)) }
6061
}).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
6162
}).pipe(
6263
Effect.map((output) => ({

0 commit comments

Comments
 (0)