Skip to content
Merged
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/skills-paths-leading-slash-and-picker-groups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---

Load skills from `skills.paths` entries written with a leading slash, such as `/.github/skills`, by falling back to the project root when no such directory exists at the filesystem root. Show skills in the VS Code slash menu under their own Skills group and list a skill that shares a name with a command as `/name:skill`.
8 changes: 8 additions & 0 deletions packages/kilo-docs/pages/customize/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ You can configure extra skill locations and remote skill URLs in your `kilo.json

The `skills.paths` key accepts absolute paths, `~/` home-relative paths, or paths relative to the project root. The `skills.urls` key accepts URLs to remote skill directories that serve an `index.json` manifest.

A path that starts with `/` or `\` but has no drive letter, such as `/.github/skills`, is tried as an absolute path first. If that directory does not exist, Kilo resolves it relative to the project root instead, so `/.github/skills` and `.github/skills` load the same repository skills. Skills loaded through this fallback are treated as project skills.

The remote server must serve an `index.json` file at the URL path with the following structure:

```json
Expand Down Expand Up @@ -172,6 +174,8 @@ You can configure extra skill locations and remote skill URLs in your `kilo.json

The `skills.paths` key accepts absolute paths, `~/` home-relative paths, or paths relative to the project root. The `skills.urls` key accepts URLs to remote skill directories that serve an `index.json` manifest.

A path that starts with `/` or `\` but has no drive letter, such as `/.github/skills`, is tried as an absolute path first. If that directory does not exist, Kilo resolves it relative to the project root instead, so `/.github/skills` and `.github/skills` load the same repository skills. Skills loaded through this fallback are treated as project skills.

The remote server must serve an `index.json` file at the URL path with the following structure:

```json
Expand Down Expand Up @@ -220,11 +224,15 @@ If you need a skill to only apply in certain situations, write a clear and speci

When multiple skills share the same name, project-level skills (`.kilo/skills/`) take precedence over global skills (`~/.kilo/skills/`). Skills from compatibility directories (`.claude/skills/`, `.agents/skills/`) and additional configured paths are loaded alongside project and global skills.

Every loaded skill is also available as a slash command. The `/` menu lists skills in a separate **Skills** group. When a skill shares its name with a custom command or MCP prompt, the command keeps `/name` and the skill is listed as `/name:skill`, so both stay reachable.

{% /tab %}
{% tab label="CLI" %}

When multiple skills share the same name, project-level skills (`.kilo/skills/`) take precedence over global skills (`~/.kilo/skills/`). Skills from compatibility directories (`.claude/skills/`, `.agents/skills/`) and additional configured paths are loaded alongside project and global skills.

Every loaded skill is also available as a slash command. When a skill shares its name with a custom command or MCP prompt, the command keeps `/name` and the skill is offered as `/name:skill` in autocomplete, so both stay reachable.

{% /tab %}
{% /tabs %}

Expand Down
65 changes: 65 additions & 0 deletions packages/kilo-vscode/tests/unit/use-slash-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,71 @@ describe("useSlashCommand sandbox action", () => {
})
})

// Issue #14096: skills were listed as indistinguishable "Commands" rows.
describe("skill entries in the slash menu", () => {
const loaded = (ctx: ReturnType<typeof setup>) =>
ctx.fire({
type: "commandsLoaded",
commands: [
{ name: "foo", description: "custom command", source: "command", hints: [] },
{ name: "foo", description: "skill with the same name", source: "skill", hints: [] },
{ name: "grill", description: "a plain skill", source: "skill", hints: [] },
{ name: "grill", description: "duplicate skill row", source: "skill", hints: [] },
{ name: "notes", description: "mcp prompt", source: "mcp", hints: [] },
],
})

it("orders skills after commands so the dropdown can render a Skills group", () => {
const ctx = setup(() => {})
loaded(ctx)
ctx.slash.onInput("/", 1)
const server = ctx.slash.results().filter((cmd) => !cmd.action)
const sources = server.map((cmd) => cmd.source ?? "command")
const first = sources.indexOf("skill")
expect(first).toBeGreaterThan(0)
expect(sources.slice(first).every((source) => source === "skill")).toBe(true)
expect(sources.slice(0, first).every((source) => source !== "skill")).toBe(true)
ctx.dispose()
})

it("suffixes a skill that clashes with a command and drops exact duplicate rows", () => {
const ctx = setup(() => {})
loaded(ctx)
ctx.slash.onInput("/", 1)
const rows = ctx.slash
.results()
.filter((cmd) => !cmd.action)
.map((cmd) => `${cmd.source ?? "command"}:${cmd.name}`)
expect(rows.filter((row) => row.endsWith(":foo"))).toEqual(["command:foo"])
expect(rows.filter((row) => row.startsWith("skill:"))).toEqual(["skill:foo:skill", "skill:grill"])
ctx.dispose()
})

it("inserts /name:skill when the clashing skill row is selected", () => {
const ctx = setup(() => {})
loaded(ctx)
let text = ""
const textarea = { value: "/foo", setSelectionRange: () => {}, focus: () => {} } as unknown as HTMLTextAreaElement
ctx.slash.onInput("/foo", 4)
const entry = ctx.slash.results().find((cmd) => cmd.source === "skill" && cmd.name.startsWith("foo"))!
ctx.slash.select(entry, textarea, (value) => (text = value))
expect(text.startsWith("/foo:skill")).toBe(true)
ctx.dispose()
})

it("still matches a clashing skill by its plain name", () => {
const ctx = setup(() => {})
loaded(ctx)
ctx.slash.onInput("/foo", 4)
const names = ctx.slash
.results()
.filter((cmd) => !cmd.action)
.map((cmd) => cmd.name)
expect(names).toEqual(["foo", "foo:skill"])
ctx.dispose()
})
})

describe("slash command keyboard selection", () => {
it.each(["sandbox", "verify"])("leaves Shift+Tab unhandled with /%s selected", (name) => {
const draft = `/${name} keep this draft`
Expand Down
87 changes: 37 additions & 50 deletions packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { useTerminalContext } from "../../hooks/useTerminalContext"
import { useGitChangesContext } from "../../hooks/useGitChangesContext"
import { hasTerminalMention } from "../../hooks/terminal-context-utils"
import { hasGitChangesMention } from "../../hooks/git-changes-context-utils"
import { useSlashCommand } from "../../hooks/useSlashCommand"
import { useSlashCommand, skill as isSkill } from "../../hooks/useSlashCommand"
import { useGoalComposer } from "./goal/useGoalComposer"
import { GoalHeader } from "./goal/GoalHeader"
import { useGhostText } from "../../hooks/useGhostText"
Expand Down Expand Up @@ -1861,56 +1861,43 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
{(() => {
const all = slash.results()
const actions = all.filter((c) => c.action)
const server = all.filter((c) => !c.action)
const offset = actions.length
const commands = all.filter((c) => !c.action && !isSkill(c))
const skills = all.filter(isSkill)
// results() is ordered actions, commands, skills; keyboard indexes follow that order.
const groups = [
{ label: "Actions", items: actions, offset: 0 },
{ label: "Commands", items: commands, offset: actions.length },
{ label: "Skills", items: skills, offset: actions.length + commands.length },
].filter((group) => group.items.length > 0)
return (
<>
<Show when={actions.length > 0}>
<div class="slash-command-group-label">Actions</div>
<For each={actions}>
{(cmd, idx) => (
<div
class="slash-command-item"
classList={{ "slash-command-item--active": idx() === slash.index() }}
onMouseDown={(e) => {
e.preventDefault()
if (textareaRef) slash.select(cmd, textareaRef, setText, adjustHeight)
}}
onMouseEnter={() => slash.setIndex(idx())}
>
<span class="slash-command-name">/{cmd.name}</span>
<Show when={cmd.description}>
<span class="slash-command-desc">{cmd.description}</span>
</Show>
</div>
)}
</For>
</Show>
<Show when={server.length > 0}>
<Show when={actions.length > 0}>
<div class="slash-command-separator" />
</Show>
<div class="slash-command-group-label">Commands</div>
<For each={server}>
{(cmd, idx) => (
<div
class="slash-command-item"
classList={{ "slash-command-item--active": idx() + offset === slash.index() }}
onMouseDown={(e) => {
e.preventDefault()
if (textareaRef) slash.select(cmd, textareaRef, setText, adjustHeight)
}}
onMouseEnter={() => slash.setIndex(idx() + offset)}
>
<span class="slash-command-name">/{cmd.name}</span>
<Show when={cmd.description}>
<span class="slash-command-desc">{cmd.description}</span>
</Show>
</div>
)}
</For>
</Show>
</>
<For each={groups}>
{(group, g) => (
<>
<Show when={g() > 0}>
<div class="slash-command-separator" />
</Show>
<div class="slash-command-group-label">{group.label}</div>
<For each={group.items}>
{(cmd, idx) => (
<div
class="slash-command-item"
classList={{ "slash-command-item--active": idx() + group.offset === slash.index() }}
onMouseDown={(e) => {
e.preventDefault()
if (textareaRef) slash.select(cmd, textareaRef, setText, adjustHeight)
}}
onMouseEnter={() => slash.setIndex(idx() + group.offset)}
>
<span class="slash-command-name">/{cmd.name}</span>
<Show when={cmd.description}>
<span class="slash-command-desc">{cmd.description}</span>
</Show>
</div>
)}
</For>
</>
)}
</For>
)
})()}
</Show>
Expand Down
29 changes: 26 additions & 3 deletions packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,24 @@ export function sortByScore(matches: SlashCommandEntry[], query: string): SlashC
return [...matches].sort((a, b) => getMatchScore(b, lower) - getMatchScore(a, lower))
}

export const skill = (cmd: SlashCommandEntry) => !cmd.action && cmd.source === "skill"

/**
* The CLI lists a skill next to a command of the same name so the TUI can offer both as
* `/name` and `/name:skill`. Apply the same suffix here so the two rows are distinguishable
* and selecting the skill row inserts the text the CLI resolves to that skill.
*/
export function disambiguate(list: SlashCommandEntry[], taken: Set<string>): SlashCommandEntry[] {
const seen = new Set<string>()
return list.flatMap((cmd) => {
const name = skill(cmd) && taken.has(cmd.name) ? `${cmd.name}:skill` : cmd.name
const key = `${cmd.source ?? "command"}:${name}`
if (seen.has(key)) return []
seen.add(key)
return [name === cmd.name ? cmd : { ...cmd, name }]
})
}

interface VSCodeContext {
postMessage: (message: WebviewMessage) => void
onMessage: (handler: (message: ExtensionMessage) => void) => () => void
Expand Down Expand Up @@ -238,7 +256,8 @@ export function useSlashCommand(
const set = excluded()
const only = included()
const filtered = server().filter((c) => !names.has(c.name) && !set?.has(c.name) && (!only || only.has(c.name)))
return [...list, ...filtered]
const taken = new Set(filtered.filter((c) => !skill(c)).map((c) => c.name))
Comment thread
marius-kilocode marked this conversation as resolved.
return [...list, ...disambiguate(filtered, taken)]
}

const show = () => query() !== null
Expand Down Expand Up @@ -285,8 +304,12 @@ export function useSlashCommand(

const results = () => {
const list = matched()
// PromptInput renders contiguous Actions and Commands groups, so keyboard indexes must use the same order.
return [...list.filter((cmd) => cmd.action), ...list.filter((cmd) => !cmd.action)]
// PromptInput renders contiguous Actions, Commands, and Skills groups, so keyboard indexes must use the same order.
return [
...list.filter((cmd) => cmd.action),
...list.filter((cmd) => !cmd.action && !skill(cmd)),
...list.filter(skill),
]
}

const unsubscribe = vscode.onMessage((message) => {
Expand Down
25 changes: 25 additions & 0 deletions packages/opencode/src/kilocode/skill/paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import path from "path"
import { Effect } from "effect"

// `skills.paths` entries like "/.github/skills" are almost always meant as "relative to the
// project root", but `path.isAbsolute` reads them as the filesystem root (or the current drive
// root on Windows), so discovery silently finds nothing. Keep the upstream absolute-first
// semantics, and only when that directory does not exist, fall back to the project-relative
// reading. Drive-qualified ("C:\...") and UNC ("\\server\...") paths never fall back.
export function rooted(value: string): boolean {
return /^[\\/](?![\\/])/.test(value)
}

export const resolve = Effect.fn("KilocodeSkillPaths.resolve")(function* (
expanded: string,
directory: string,
isDir: (dir: string) => Effect.Effect<boolean>,
) {
const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
if (!rooted(expanded)) return dir
if (yield* isDir(dir)) return dir
const alt = path.join(directory, expanded)
if (!(yield* isDir(alt))) return dir
yield* Effect.logInfo("skill path resolved relative to the project", { path: expanded, dir: alt })
return alt
})
6 changes: 4 additions & 2 deletions packages/opencode/src/skill/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { isRecord } from "@/util/record"
import { Flag } from "@opencode-ai/core/flag/flag" // kilocode_change
import { escapeHtml } from "@/util/html"
import { trustedInProject } from "../kilocode/skill/trust" // kilocode_change
import * as SkillPaths from "../kilocode/skill/paths" // kilocode_change

const CLAUDE_EXTERNAL_DIR = ".claude"
const AGENTS_EXTERNAL_DIR = ".agents"
Expand Down Expand Up @@ -273,15 +274,16 @@ const discoverSkills = Effect.fnUntraced(function* (
const cfg = yield* config.get()
for (const item of cfg.skills?.paths ?? []) {
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
const dir = yield* SkillPaths.resolve(expanded, directory, fsys.isDir) // kilocode_change - "/x" falls back to the project root
if (!(yield* fsys.isDir(dir))) {
yield* Effect.logWarning("skill path not found", { path: dir })
continue
}

// kilocode_change start - trust follows the config source that declared the path, never the selected path.
// A "/x" entry that fell back to the project root is project content and stays untrusted.
const origin = cfg.skill_path_origins?.[item]
const trusted = origin?.trusted === true && path.isAbsolute(expanded)
const trusted = origin?.trusted === true && path.isAbsolute(expanded) && dir === expanded
yield* scan(state, dir, SKILL_PATTERN, {
trusted,
root: trusted ? undefined : (origin?.root ?? projectRoot),
Expand Down
Loading
Loading