From 5be579cddd491cf4d09d598f682bf99497ccef85 Mon Sep 17 00:00:00 2001 From: LifeJiggy Date: Tue, 26 May 2026 09:03:58 +0100 Subject: [PATCH] fix(skill): invoke full skill system for /skill-name commands When /skill-name is used, only the raw SKILL.md text was provided as the command template. Referenced files in the skill directory were never loaded, making relative references unresolvable. Fix by routing skill commands through the same file-loading and wrapping logic that the skill tool uses: - Load referenced files from skill directory via rg.files() - Wrap output in with name, content, base directory, files - Use EffectBridge to bridge the async Effect into the template promise --- packages/opencode/src/command/index.ts | 36 +++++++++++++++++++- packages/opencode/test/command/index.test.ts | 32 +++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/command/index.test.ts diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 96e171733df0..1ae77650c65f 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -1,11 +1,15 @@ +import path from "path" +import { pathToFileURL } from "url" import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" import { EffectBridge } from "@/effect/bridge" import type { InstanceContext } from "@/project/instance-context" import { SessionID, MessageID } from "@/session/schema" import { Effect, Layer, Context, Schema } from "effect" +import * as Stream from "effect/Stream" import { Config } from "@/config/config" import { MCP } from "../mcp" +import { Ripgrep } from "../file/ripgrep" import { Skill } from "../skill" import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_REVIEW from "./template/review.txt" @@ -69,6 +73,8 @@ export const layer = Layer.effect( const mcp = yield* MCP.Service const skill = yield* Skill.Service + const rg = yield* Ripgrep.Service + const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) { const cfg = yield* config.get() const bridge = yield* EffectBridge.make() @@ -140,12 +146,40 @@ export const layer = Layer.effect( for (const item of yield* skill.all()) { if (commands[item.name]) continue + const dir = path.dirname(item.location) + const base = pathToFileURL(dir).href commands[item.name] = { name: item.name, description: item.description, source: "skill", get template() { - return item.content + return bridge.promise( + Effect.gen(function* () { + const limit = 10 + const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe( + Stream.filter((file) => !file.includes("SKILL.md")), + Stream.map((file) => path.resolve(dir, file)), + Stream.take(limit), + Stream.runCollect, + Effect.map((chunk) => [...chunk].map((file) => `${file}`).join("\n")), + ) + return [ + ``, + `# Skill: ${item.name}`, + "", + item.content.trim(), + "", + `Base directory for this skill: ${base}`, + "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", + "Note: file list is sampled.", + "", + "", + files, + "", + "", + ].join("\n") + }), + ) }, hints: [], } diff --git a/packages/opencode/test/command/index.test.ts b/packages/opencode/test/command/index.test.ts new file mode 100644 index 000000000000..a1507c4091b3 --- /dev/null +++ b/packages/opencode/test/command/index.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test" +import { hints } from "@/command/index" + +describe("command", () => { + test("hints extracts numbered placeholders", () => { + expect(hints("do $1 and $2")).toEqual(["$1", "$2"]) + }) + + test("hints extracts $ARGUMENTS placeholder", () => { + expect(hints("do $ARGUMENTS")).toEqual(["$ARGUMENTS"]) + }) + + test("hints extracts mixed placeholders", () => { + expect(hints("$1 $2 $ARGUMENTS")).toEqual(["$1", "$2", "$ARGUMENTS"]) + }) + + test("hints deduplicates repeated placeholders", () => { + expect(hints("$1 and $1 again")).toEqual(["$1"]) + }) + + test("hints sorts numbered placeholders", () => { + expect(hints("$3 $1 $2")).toEqual(["$1", "$2", "$3"]) + }) + + test("hints returns empty array for no placeholders", () => { + expect(hints("no placeholders here")).toEqual([]) + }) + + test("hints handles empty string", () => { + expect(hints("")).toEqual([]) + }) +})