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
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
18 changes: 17 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,23 @@ 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 actually shown as a skill.
// Skills register both forms server-side; showing both would duplicate entries. But if
// a client action shadows the bare name, we must keep the alias so the skill stays
// reachable.
const bareSkillsShown = new Set(
server()
.filter((c) => c.source === "skill" && !c.name.startsWith("skill:") && !names.has(c.name))
.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 (bareSkillsShown.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