diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts
index 057754cd9ef8..41b2acc34fc3 100644
--- a/packages/opencode/src/command/index.ts
+++ b/packages/opencode/src/command/index.ts
@@ -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 [
@@ -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"],
}
}
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index eb116f6b960f..a5050dc34253 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -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}".`,
+ "",
+ ``,
+ templateCommand.trim(),
+ ``,
+ "",
+ "## 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)
diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts
index 491ad06aaf47..a52ae2ffdeef 100644
--- a/packages/opencode/test/session/prompt.test.ts
+++ b/packages/opencode/test/session/prompt.test.ts
@@ -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('')
+ 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",
() =>