Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ const layer = Layer.effect(
name: item.name,
description: item.description,
source: "skill",
// Skill bodies are free-form markdown docs, not command templates.
// Placeholder substitution ($ARGUMENTS / $N) is applied by a fixed
// wrapper in SessionPrompt.command so docs cannot swallow user args.
get template() {
if (!dir) return item.content
return [
Expand All @@ -147,7 +150,7 @@ const layer = Layer.effect(
"Relative paths in this skill (e.g., scripts/, references/) are relative to this base directory.",
].join("\n")
},
hints: [],
hints: ["$ARGUMENTS"],
}
}

Expand Down
54 changes: 37 additions & 17 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1373,25 +1373,45 @@ const layer = Layer.effect(
const args = raw.map((arg) => arg.replace(quoteTrimRegex, ""))
const templateCommand = yield* Effect.promise(async () => cmd.template)

const placeholders = templateCommand.match(placeholderRegex) ?? []
let last = 0
for (const item of placeholders) {
const value = Number(item.slice(1))
if (value > last) last = value
}
let template: string
if (cmd.source === "skill") {
// Skills are free-form markdown, not command templates. Never run
// $ARGUMENTS / $N substitution against the skill body — docs often
// mention those tokens as examples and would swallow the real request.
// Keep the user request in a dedicated trailing section instead.
const request = input.arguments.trim()
template = [
`Follow the skill instructions below for skill "${cmd.name}".`,
"",
`<skill_content name="${cmd.name}">`,
templateCommand.trim(),
`</skill_content>`,
"",
"## User request",
request ||
"(No additional user request was provided. Follow the skill instructions and ask if anything is unclear.)",
].join("\n")
} else {
const placeholders = templateCommand.match(placeholderRegex) ?? []
let last = 0
for (const item of placeholders) {
const value = Number(item.slice(1))
if (value > last) last = value
}

const withArgs = templateCommand.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
if (argIndex >= args.length) return ""
if (position === last) return args.slice(argIndex).join(" ")
return args[argIndex]
})
const usesArgumentsPlaceholder = templateCommand.includes("$ARGUMENTS")
let template = withArgs.replaceAll("$ARGUMENTS", input.arguments)
const withArgs = templateCommand.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
if (argIndex >= args.length) return ""
if (position === last) return args.slice(argIndex).join(" ")
return args[argIndex]
})
const usesArgumentsPlaceholder = templateCommand.includes("$ARGUMENTS")
template = withArgs.replaceAll("$ARGUMENTS", input.arguments)

if (placeholders.length === 0 && !usesArgumentsPlaceholder && input.arguments.trim()) {
template = template + "\n\n" + input.arguments
if (placeholders.length === 0 && !usesArgumentsPlaceholder && input.arguments.trim()) {
template = template + "\n\n" + input.arguments
}
}

const shellMatches = ConfigMarkdown.shell(template)
Expand Down
50 changes: 50 additions & 0 deletions packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1776,6 +1776,56 @@ unix(
30_000,
)

it.instance(
"skill slash command preserves user request and does not substitute skill body placeholders",
() =>
Effect.gen(function* () {
const { dir, llm } = yield* useServerConfig(providerCfg)
const skillDir = path.join(dir, ".opencode", "skill", "arg-skill")
yield* writeText(
path.join(skillDir, "SKILL.md"),
`---
name: arg-skill
description: Skill that documents $ARGUMENTS without consuming them.
---

# Arg Skill

Example docs mention Input: $ARGUMENTS and also $1.

Do the real work from the user request section only.
`,
)

const { prompt, chat } = yield* boot()
yield* llm.text("done")

const result = yield* prompt.command({
sessionID: chat.id,
command: "arg-skill",
arguments: "please fix the login bug in src/auth.ts",
})

expect(result.info.role).toBe("assistant")
const sessions = yield* Session.Service
const messages = yield* sessions.messages({ sessionID: chat.id })
const user = messages.find((message) => message.info.role === "user")
const text = user?.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")

expect(text).toContain('<skill_content name="arg-skill">')
expect(text).toContain("Example docs mention Input: $ARGUMENTS and also $1.")
expect(text).toContain("## User request")
expect(text).toContain("please fix the login bug in src/auth.ts")
// Placeholder tokens inside the skill body must remain literal.
expect(text).not.toMatch(/Example docs mention Input: please fix the login bug/)
expect(text).not.toMatch(/Example docs mention Input: please/)
}),
30_000,
)

unixNoLLMServer(
"cancel interrupts shell and resolves cleanly",
() =>
Expand Down
Loading