Merge pull request #12825 from Kilo-Org/rune-polo

feat(cli): add command file management parity with skills
This commit is contained in:
Kirill Kalishev
2026-08-06 11:52:27 -04:00
committed by GitHub
21 changed files with 1052 additions and 12 deletions
@@ -0,0 +1,130 @@
import { readFile, unlink } from "node:fs/promises"
import path from "node:path"
import { Global } from "@opencode-ai/core/global"
import { Glob } from "@opencode-ai/core/util/glob"
import { Schema } from "effect"
import { Command } from "@/command"
import { configEntryNameFromPath } from "@/config/entry-name"
import { WorkflowsMigrator } from "@/kilocode/workflows-migrator"
export const Info = Schema.Struct({
name: Schema.String,
description: Schema.optional(Schema.String),
agent: Schema.optional(Schema.String),
model: Schema.optional(Schema.String),
variant: Schema.optional(Schema.String),
source: Schema.optional(Schema.String),
builtin: Schema.Boolean,
location: Schema.String,
editable: Schema.Boolean,
content: Schema.optional(Schema.String),
subtask: Schema.optional(Schema.Boolean),
hints: Schema.Array(Schema.String),
}).annotate({ identifier: "CommandFile" })
export type Info = Schema.Schema.Type<typeof Info>
type File = {
name: string
location: string
content: string
}
const COMMAND_PREFIXES = ["command/", "commands/"]
async function files(dir: string) {
const result: File[] = []
for (const file of await Glob.scan("{command,commands}/**/*.md", { cwd: dir, absolute: true, dot: true, symlink: true })) {
result.push(await command(dir, file))
}
return result
}
async function command(dir: string, file: string): Promise<File> {
const content = await readFile(file, "utf8")
return {
name: configEntryNameFromPath(path.relative(dir, file), COMMAND_PREFIXES),
location: file,
content,
}
}
function precedence(files: File[]) {
const result = new Map<string, File>()
for (const file of files) result.set(file.name, file)
return result
}
function description(cmd: Command.Info, file?: File) {
if (cmd.description) return cmd.description
if (file) return WorkflowsMigrator.extractDescription(file.content)
return undefined
}
function literal(cmd: Command.Info) {
return typeof cmd.template === "string" ? cmd.template : undefined
}
export async function discover(input: { commands: readonly Command.Info[]; directories: readonly string[]; directory: string }) {
const all = []
for (const item of await WorkflowsMigrator.discoverWorkflows(input.directory)) {
all.push({ name: item.name, location: item.path, content: item.content })
}
for (const dir of input.directories) all.push(...(await files(dir)))
const by = precedence(all)
return input.commands
.filter((cmd) => cmd.source !== "skill")
.map((cmd): Info => {
const file = by.get(cmd.name)
if (file) {
return {
name: cmd.name,
description: description(cmd, file),
agent: cmd.agent,
model: cmd.model,
variant: cmd.variant,
source: cmd.source,
builtin: false,
location: file.location,
editable: true,
content: file.content,
subtask: cmd.subtask,
hints: cmd.hints,
}
}
return {
name: cmd.name,
description: description(cmd),
agent: cmd.agent,
model: cmd.model,
variant: cmd.variant,
source: cmd.source,
builtin: true,
location: "builtin",
editable: false,
content: literal(cmd),
subtask: cmd.subtask,
hints: cmd.hints,
}
})
}
export function target(location: string, commands: readonly Info[]) {
if (!path.isAbsolute(location)) throw new Error("command location must be absolute")
const file = path.resolve(location)
const command = commands.find((item) => item.editable && path.resolve(item.location) === file)
if (!command) throw new Error("command not found in registry")
if (!file.endsWith(".md")) throw new Error("command location must reference a markdown file")
const cache = path.join(Global.Path.cache, "commands")
const relative = path.relative(cache, file)
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) {
throw new Error("remove cache-backed commands from configuration")
}
return file
}
export async function remove(location: string, commands: readonly Info[]) {
await unlink(target(location, commands))
}
export * as CommandFiles from "./command-files"
@@ -24,6 +24,7 @@ import {
} from "@/kilocode/notebook/protocol"
import { ModelUsage } from "@/kilocode/session/model-usage"
import { SessionID } from "@/session/schema"
import { CommandFiles } from "@/kilocode/command-files"
const root = "/kilocode"
@@ -31,6 +32,10 @@ export const RemoveSkillPayload = Schema.Struct({
location: Schema.String,
})
export const RemoveCommandPayload = Schema.Struct({
location: Schema.String,
})
export const RemoveAgentPayload = Schema.Struct({
name: Schema.String,
})
@@ -47,6 +52,8 @@ export const AgentManagerRejectPayload = Schema.Struct({ error: AgentManagerFail
export const KilocodePaths = {
heapSnapshot: `${root}/heap/snapshot`,
agentRequirements: `${root}/agent/requirements`,
commandFiles: `${root}/command/files`,
removeCommand: `${root}/command/remove`,
removeSkill: `${root}/skill/remove`,
removeAgent: `${root}/agent/remove`,
notebookList: `${root}/notebook`,
@@ -83,6 +90,28 @@ export const KilocodeApi = HttpApi.make("kilocode")
description: "Check whether the selected agent's requirements are available in the request directory.",
}),
),
HttpApiEndpoint.get("commandFiles", KilocodePaths.commandFiles, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(CommandFiles.Info), "Command files"),
}).annotateMerge(
OpenApi.annotations({
identifier: "kilocode.commandFiles",
summary: "List command files",
description: "List commands with editable file locations for settings clients.",
}),
),
HttpApiEndpoint.post("removeCommand", KilocodePaths.removeCommand, {
query: WorkspaceRoutingQuery,
payload: RemoveCommandPayload,
success: described(Schema.Boolean, "Command removed"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "kilocode.removeCommand",
summary: "Remove a command",
description: "Remove a command by deleting its markdown file from disk and clearing it from cache.",
}),
),
HttpApiEndpoint.post("removeSkill", KilocodePaths.removeSkill, {
query: WorkspaceRoutingQuery,
payload: RemoveSkillPayload,
@@ -1,8 +1,10 @@
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import * as KiloAgent from "@/kilocode/agent"
import { CommandFiles } from "@/kilocode/command-files"
import * as KiloSkill from "@/kilocode/skill-remove"
import { Agent } from "@/agent/agent"
import { Command } from "@/command"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { HeapSnapshot } from "@/kilocode/cli/heap-snapshot"
@@ -21,12 +23,14 @@ import {
NotebookRejectPayload,
NotebookReplyPayload,
RemoveAgentPayload,
RemoveCommandPayload,
RemoveSkillPayload,
} from "../groups/kilocode"
export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode", (handlers) =>
Effect.gen(function* () {
const agents = yield* Agent.Service
const commands = yield* Command.Service
const skills = yield* Skill.Service
const config = yield* Config.Service
const store = yield* InstanceStore.Service
@@ -43,6 +47,34 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode"
return yield* agents.requirementStatus(ctx.query.agent)
})
const commandFiles = Effect.fn("KilocodeHttpApi.commandFiles")(function* () {
const instance = yield* InstanceState.context
const dirs = yield* config.directories()
const items = yield* commands.list()
return yield* Effect.tryPromise({
try: () => CommandFiles.discover({ commands: items, directories: dirs, directory: instance.directory }),
catch: (err) => err,
}).pipe(Effect.catch((err) => Effect.die(err)))
})
const removeCommand = Effect.fn("KilocodeHttpApi.removeCommand")(function* (ctx: {
payload: typeof RemoveCommandPayload.Type
}) {
const instance = yield* InstanceState.context
const dirs = yield* config.directories()
const items = yield* commands.list()
const entries = yield* Effect.tryPromise({
try: () => CommandFiles.discover({ commands: items, directories: dirs, directory: instance.directory }),
catch: (err) => err,
}).pipe(Effect.catch((err) => Effect.die(err)))
yield* Effect.tryPromise({
try: () => CommandFiles.remove(ctx.payload.location, entries),
catch: () => new HttpApiError.BadRequest({}),
})
yield* store.dispose(instance)
return true
})
const removeSkill = Effect.fn("KilocodeHttpApi.removeSkill")(function* (ctx: {
payload: typeof RemoveSkillPayload.Type
}) {
@@ -136,6 +168,8 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode"
return handlers
.handle("heapSnapshot", heapSnapshot)
.handle("agentRequirements", agentRequirements)
.handle("commandFiles", commandFiles)
.handle("removeCommand", removeCommand)
.handle("removeSkill", removeSkill)
.handle("removeAgent", removeAgent)
.handle("notebookList", notebookList)
@@ -0,0 +1,144 @@
import { afterEach, describe, expect, test } from "bun:test"
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { CommandFiles } from "../../src/kilocode/command-files"
import type { Command } from "../../src/command"
const roots: string[] = []
afterEach(async () => {
await Promise.all(roots.map((dir) => rm(dir, { recursive: true, force: true })))
roots.length = 0
})
async function temp() {
const dir = await mkdtemp(path.join(os.tmpdir(), "kilo-command-files-"))
roots.push(dir)
return dir
}
function cmd(input: Partial<Command.Info> & Pick<Command.Info, "name">): Command.Info {
return {
name: input.name,
description: input.description,
agent: input.agent,
model: input.model,
variant: input.variant,
source: input.source,
template: input.template ?? "body",
subtask: input.subtask,
hints: input.hints ?? [],
}
}
describe("CommandFiles", () => {
test("discovers editable command files and read-only builtins", async () => {
const dir = await temp()
const file = path.join(dir, ".kilo", "command", "review.md")
await mkdir(path.dirname(file), { recursive: true })
await writeFile(file, "---\ndescription: Review code\n---\n\nReview $ARGUMENTS")
const items = await CommandFiles.discover({
directory: dir,
directories: [path.join(dir, ".kilo")],
commands: [
cmd({
name: "review",
source: "command",
agent: "reviewer",
model: "anthropic/claude-sonnet-4-6",
variant: "high",
subtask: true,
hints: ["$ARGUMENTS"],
}),
cmd({ name: "init", source: "command" }),
],
})
expect(items.map((item) => item.name)).toEqual(["review", "init"])
expect(items[0]).toMatchObject({
name: "review",
editable: true,
builtin: false,
location: file,
agent: "reviewer",
model: "anthropic/claude-sonnet-4-6",
variant: "high",
subtask: true,
})
expect(items[0].content).toContain("Review $ARGUMENTS")
expect(items[1]).toMatchObject({ name: "init", editable: false, builtin: true, location: "builtin" })
})
test("maps legacy workflows to editable commands", async () => {
const dir = await temp()
const file = path.join(dir, ".kilo", "workflows", "ship.md")
await mkdir(path.dirname(file), { recursive: true })
await writeFile(file, "# Ship\n\nRun release checks")
const items = await CommandFiles.discover({
directory: dir,
directories: [path.join(dir, ".kilo")],
commands: [cmd({ name: "ship", source: "command", description: "Workflow: ship" })],
})
expect(items).toHaveLength(1)
expect(items[0]).toMatchObject({ name: "ship", editable: true, builtin: false, location: file })
expect(items[0].content).toBe("# Ship\n\nRun release checks")
})
test("prefers command file attribution over same-named legacy workflow", async () => {
const dir = await temp()
const workflow = path.join(dir, ".kilo", "workflows", "ship.md")
const file = path.join(dir, ".kilo", "command", "ship.md")
await mkdir(path.dirname(workflow), { recursive: true })
await mkdir(path.dirname(file), { recursive: true })
await writeFile(workflow, "# Legacy Ship")
await writeFile(file, "# Command Ship")
const items = await CommandFiles.discover({
directory: dir,
directories: [path.join(dir, ".kilo")],
commands: [cmd({ name: "ship", source: "command" })],
})
expect(items[0]).toMatchObject({ name: "ship", editable: true, builtin: false, location: file })
expect(items[0].content).toBe("# Command Ship")
})
test("discovers symlinked command files", async () => {
const dir = await temp()
const real = path.join(dir, "linked", "review.md")
const link = path.join(dir, ".kilo", "command", "review.md")
await mkdir(path.dirname(real), { recursive: true })
await mkdir(path.dirname(link), { recursive: true })
await writeFile(real, "Review from symlink")
await symlink(real, link)
const items = await CommandFiles.discover({
directory: dir,
directories: [path.join(dir, ".kilo")],
commands: [cmd({ name: "review", source: "command" })],
})
expect(items[0]).toMatchObject({ name: "review", editable: true, builtin: false, location: link })
expect(items[0].content).toBe("Review from symlink")
})
test("remove only accepts known editable markdown files", async () => {
const dir = await temp()
const file = path.join(dir, ".kilo", "command", "ok.md")
await mkdir(path.dirname(file), { recursive: true })
await writeFile(file, "OK")
const entries = [
{ name: "ok", location: file, editable: true, builtin: false, hints: [] },
{ name: "init", location: "builtin", editable: false, builtin: true, hints: [] },
]
await expect(CommandFiles.remove("builtin", entries)).rejects.toThrow("absolute")
await expect(CommandFiles.remove(path.join(dir, "other.md"), entries)).rejects.toThrow("not found")
await CommandFiles.remove(file, entries)
await expect(CommandFiles.remove(file, entries)).rejects.toThrow()
})
})
@@ -3,7 +3,7 @@ import { mkdir, rm } from "fs/promises"
import path from "path"
import { KiloMemory } from "@kilocode/kilo-memory/effect"
import { MemoryPaths } from "@kilocode/kilo-memory/effect/paths"
import { array, check, object } from "../../server/httpapi-exercise/assertions"
import { array, check, isRecord, object } from "../../server/httpapi-exercise/assertions"
import { http, route } from "../../server/httpapi-exercise/dsl"
import type { Scenario, ScenarioContext } from "../../server/httpapi-exercise/types"
import { anacondaDesktopScenarios } from "../anaconda-desktop/httpapi-exercise-scenarios"
@@ -37,6 +37,13 @@ const agent = async (dir: string) => {
)
}
const command = async (dir: string) => {
await Bun.write(
path.join(dir, ".kilo/command/httpapi-remove.md"),
"---\ndescription: HTTP API command remove\nmodel: anthropic/claude-sonnet-4-6\nvariant: high\n---\nRun command.\n",
)
}
function memory(ctx: ScenarioContext) {
const dir = directory(ctx)
return MemoryPaths.root({ ctx: { directory: dir, worktree: dir } })
@@ -544,6 +551,44 @@ export const kiloScenarios: Scenario[] = [
array(body.mcps)
array(body.vscode_extensions)
}),
http.protected
.get("/kilocode/command/files", "kilocode.commandFiles")
.inProject({ git: true, init: command })
.json(200, (body, ctx) => {
array(body)
const item = body.find((item) => isRecord(item) && item.name === "httpapi-remove")
object(item)
check(item.description === "HTTP API command remove", "command file should include description")
check(
item.location === path.join(directory(ctx), ".kilo/command/httpapi-remove.md"),
"command file should include location",
)
check(item.editable === true, "command file should be editable")
check(item.builtin === false, "command file should not be builtin")
check(item.model === "anthropic/claude-sonnet-4-6", "command file should include model metadata")
check(item.variant === "high", "command file should include variant metadata")
check(typeof item.content === "string" && item.content.includes("Run command."), "command file should include content")
}),
http.protected
.post("/kilocode/command/remove", "kilocode.removeCommand")
.inProject({ git: true, init: command })
.mutating()
.preserveDatabase()
.at((ctx) => ({
path: "/kilocode/command/remove",
headers: ctx.headers(),
body: { location: path.join(directory(ctx), ".kilo/command/httpapi-remove.md") },
}))
.jsonEffect(200, (body, ctx) =>
Effect.gen(function* () {
check(body === true, "command removal should return true")
const location = path.join(directory(ctx), ".kilo/command/httpapi-remove.md")
check(
!(yield* Effect.promise(() => Bun.file(location).exists())),
"removed command should not remain on disk",
)
}),
),
http.protected
.post("/kilocode/skill/remove", "kilocode.removeSkill")
.inProject({ git: true, init: skill })
@@ -144,7 +144,8 @@ describe("test runner cleanup", () => {
const stderr = new Response(proc.stderr).text()
try {
const code = await deadline(proc.exited, 15_000)
const limit = process.platform === "win32" ? 30_000 : 15_000
const code = await deadline(proc.exited, limit)
const output = await Promise.all([stdout, stderr])
expect(code, output[1] || output[0]).not.toBe(0)
expect(output[0]).toContain("TIME")
@@ -167,7 +168,7 @@ describe("test runner cleanup", () => {
await proc.exited
await fs.rm(file, { force: true })
}
}, 30_000)
}, 45_000)
test.skipIf(process.platform === "win32")(
"bounds inherited output after the test process exits",