Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions .changeset/skill-slash-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"kilo-code": minor
"@kilocode/cli": minor
---

Invoke skills as slash commands in the CLI TUI and VS Code sidebar. Each skill is available as `/<name>` when the name is free and always as `/skill:<name>`, so you can run a skill directly without going through the skill picker dialog.
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onMouseEnter={() => slash.setIndex(idx() + offset)}
>
<span class="slash-command-name">/{cmd.name}</span>
<Show when={cmd.source === "skill" || cmd.source === "mcp"}>
<span class="slash-command-badge" data-source={cmd.source}>
{cmd.source === "skill"
? language.t("prompt.slash.badge.skill")
: language.t("prompt.slash.badge.mcp")}
</span>
</Show>
<Show when={cmd.description}>
<span class="slash-command-desc">{cmd.description}</span>
</Show>
Expand Down
16 changes: 15 additions & 1 deletion packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,21 @@ export function useSlashCommand(vscode: VSCodeContext, exclude?: Set<string>): S

const commands = (): SlashCommandEntry[] => {
const names = new Set(client.map((c) => c.name))
const filtered = server().filter((c) => !names.has(c.name))
// Hide the `/skill:<name>` alias when the bare `/<name>` is already shown as a skill.
// Skills always register both forms; showing both would duplicate entries in the dropdown.
const bareSkills = new Set(
server()
.filter((c) => c.source === "skill" && !c.name.startsWith("skill:"))
.map((c) => c.name),
)
const filtered = server().filter((c) => {
if (names.has(c.name)) return false
if (c.source === "skill" && c.name.startsWith("skill:")) {
const bare = c.name.slice("skill:".length)
if (bareSkills.has(bare)) return false
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Client-command collisions hide the skill alias

bareSkills is built before client commands are filtered out, so if a skill name matches a client-side slash command like settings or help, the bare skill is removed by names.has(c.name) and this branch also removes /skill:<name>. That makes the skill unreachable in VS Code despite the alias guarantee. Consider only hiding the alias when the bare skill will actually be returned.

Suggested change
if (bareSkills.has(bare)) return false
if (bareSkills.has(bare) && !names.has(bare)) return false

}
return true
})
return [...client, ...filtered]
}

Expand Down
23 changes: 23 additions & 0 deletions packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,29 @@
font-size: 11px;
}

.slash-command-badge {
flex-shrink: 0;
padding: 1px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
background: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
opacity: 0.8;
}

.slash-command-badge[data-source="skill"] {
background: var(--vscode-charts-purple, var(--vscode-badge-background));
color: var(--vscode-badge-foreground);
}

.slash-command-badge[data-source="mcp"] {
background: var(--vscode-charts-blue, var(--vscode-badge-background));
color: var(--vscode-badge-foreground);
}

.slash-command-empty {
padding: 8px 10px;
font-size: 12px;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -363,9 +363,22 @@ export function Autocomplete(props: {
const commands = createMemo((): AutocompleteOption[] => {
const results: AutocompleteOption[] = [...command.slashes()]

// kilocode_change start - track skill names already shown under a short alias
const skillBareNames = new Set<string>()
for (const c of sync.data.command) {
if (c.source === "skill" && !c.name.startsWith("skill:")) skillBareNames.add(c.name)
}
// kilocode_change end
for (const serverCommand of sync.data.command) {
if (serverCommand.source === "skill") continue
const label = serverCommand.source === "mcp" ? ":mcp" : ""
// kilocode_change start - expose skills; hide the /skill:<name> alias when the bare /<name> is already shown
if (
serverCommand.source === "skill" &&
serverCommand.name.startsWith("skill:") &&
skillBareNames.has(serverCommand.name.slice("skill:".length))
)
continue
const label = serverCommand.source === "mcp" ? ":mcp" : serverCommand.source === "skill" ? ":skill" : ""
// kilocode_change end
results.push({
display: "/" + serverCommand.name + label,
description: serverCommand.description,
Expand Down
21 changes: 17 additions & 4 deletions packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,18 +156,31 @@ export const layer = Layer.effect(
}
}

// kilocode_change start - expose skills as slash commands with /skill:<name> alias
for (const item of yield* skill.all()) {
if (commands[item.name]) continue
commands[item.name] = {
name: item.name,
const aliased = `skill:${item.name}`
commands[aliased] = {
name: aliased,
description: item.description,
source: "skill",
get template() {
return item.content
},
hints: [],
hints: [item.name],
}
if (!commands[item.name]) {
commands[item.name] = {
name: item.name,
description: item.description,
source: "skill",
get template() {
return item.content
},
hints: [aliased],
}
}
}
// kilocode_change end

return {
commands,
Expand Down
78 changes: 78 additions & 0 deletions packages/opencode/test/kilocode/skill-slash-command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Command } from "../../src/command"
import { Instance } from "../../src/project/instance"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"

const it = testEffect(Layer.mergeAll(Command.defaultLayer, CrossSpawnSpawner.defaultLayer))

afterEach(async () => {
await Instance.disposeAll()
})

describe("skills as slash commands", () => {
it.live("registers the built-in kilo-config skill under both /kilo-config and /skill:kilo-config", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const svc = yield* Command.Service
const commands = yield* svc.list()

const bare = commands.find((c) => c.name === "kilo-config")
const aliased = commands.find((c) => c.name === "skill:kilo-config")

expect(bare).toBeDefined()
expect(bare!.source).toBe("skill")
expect(aliased).toBeDefined()
expect(aliased!.source).toBe("skill")
expect(aliased!.description).toBe(bare!.description)

// Both should resolve to the same skill content.
const bareTemplate = yield* Effect.promise(async () => await bare!.template)
const aliasedTemplate = yield* Effect.promise(async () => await aliased!.template)
expect(bareTemplate).toBe(aliasedTemplate)
expect(bareTemplate.length).toBeGreaterThan(0)
}),
{ git: true },
),
)

it.live("keeps /skill:<name> reachable even when the bare name is shadowed by a user command", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
// Write a user command that collides with the built-in kilo-config skill name.
yield* Effect.promise(() =>
Bun.write(
`${dir}/.kilo/command/kilo-config.md`,
`---
description: user override command
---

user template body
`,
),
)

const svc = yield* Command.Service
const commands = yield* svc.list()

const bare = commands.find((c) => c.name === "kilo-config")
const aliased = commands.find((c) => c.name === "skill:kilo-config")

expect(bare).toBeDefined()
expect(bare!.source).toBe("command")
expect(aliased).toBeDefined()
expect(aliased!.source).toBe("skill")

const bareTemplate = yield* Effect.promise(async () => await bare!.template)
const aliasedTemplate = yield* Effect.promise(async () => await aliased!.template)
expect(bareTemplate).toContain("user template body")
expect(aliasedTemplate).not.toBe(bareTemplate)
}),
{ git: true },
),
)
})
Loading