feat(cli): add kilocode command-file endpoints for slash command management

Add GET /kilocode/command/files and POST /kilocode/command/remove, backed by a
Kilo-owned discovery module that lists commands with editable file locations
(and marks non-file commands as read-only built-ins). Wire the matching shared
RPC contract, backend RPC implementation, DTO, and parser in the JetBrains
plugin so clients can consume the endpoints. Regenerate the SDK.
This commit is contained in:
kirillk
2026-08-03 13:25:40 -04:00
parent dfc1607693
commit b692f1ded1
15 changed files with 817 additions and 0 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/cli": minor
---
Add kilocode command-file endpoints so clients can list editable command/workflow files and remove them.
@@ -17,6 +17,7 @@ import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.CloudSessionDto
import ai.kilocode.rpc.dto.CloudSessionListDto
import ai.kilocode.rpc.dto.CommandDto
import ai.kilocode.rpc.dto.CommandFileDto
import ai.kilocode.rpc.dto.ConfigDto
import ai.kilocode.rpc.dto.ConfigPatchDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
@@ -668,6 +669,23 @@ object KiloCliDataParser {
)
}
fun parseAgentBehaviorCommandFiles(raw: String): List<CommandFileDto> =
raw.array().mapNotNull { item ->
val obj = item.obj() ?: return@mapNotNull null
val name = obj.str("name") ?: return@mapNotNull null
val location = obj.str("location") ?: return@mapNotNull null
CommandFileDto(
name = name,
description = obj.str("description"),
source = obj.str("source"),
builtin = obj.bool("builtin"),
location = location,
editable = obj.bool("editable"),
content = obj.str("content"),
hints = obj["hints"].arr()?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(),
)
}
fun parseMcpStatus(raw: String): List<McpStatusDto> {
val root = runCatching { json.parseToJsonElement(raw) }.getOrNull() ?: return emptyList()
return when (root) {
@@ -10,6 +10,7 @@ import ai.kilocode.rpc.KiloAgentBehaviorRpcApi
import ai.kilocode.rpc.dto.AgentCreateDto
import ai.kilocode.rpc.dto.AgentDetailDto
import ai.kilocode.jetbrains.api.model.AgentBuilderSaveRequest
import ai.kilocode.rpc.dto.CommandFileDto
import ai.kilocode.rpc.dto.ConfigPatchDto
import ai.kilocode.rpc.dto.McpConfigDto
import ai.kilocode.rpc.dto.McpServerConfigDto
@@ -136,6 +137,44 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? =
override suspend fun commands(directory: String) = KiloCliDataParser.parseAgentBehaviorCommands(request(directory, "/command", null))
override suspend fun commandFiles(directory: String): List<CommandFileDto> =
KiloCliDataParser.parseAgentBehaviorCommandFiles(request(directory, "/kilocode/command/files", null))
override suspend fun removeCommand(directory: String, location: String): Boolean =
post(directory, "/kilocode/command/remove", JsonObject(mapOf("location" to JsonPrimitive(location))))
override suspend fun reloadCommands(directory: String): Boolean {
LOG.info("Commands reload requested dir=$directory")
if (hasActiveSession(directory)) {
LOG.warn("Commands reload blocked by active session dir=$directory")
return false
}
runCatching { post(directory, "/instance/reload") }.onFailure { err ->
LOG.warn("Commands reload failed dir=$directory", err)
}.getOrThrow()
LOG.info("Commands reload succeeded dir=$directory")
return true
}
override suspend fun saveCommands(directory: String, edits: Map<String, String>): Boolean {
LOG.info("Commands save requested dir=$directory count=${edits.size}")
app.requireReady()
val known = knownCommands(directory)
val paths = edits.map { (location, content) ->
val path = writableCommandPath(directory, location, known) ?: return false
path to content
}
withContext(Dispatchers.IO) {
for ((path, content) in paths) {
Files.createDirectories(path.parent)
Files.writeString(path, content, StandardCharsets.UTF_8)
}
}
LOG.info("Command files saved dir=$directory count=${paths.size}")
LOG.info("Commands save reload deferred dir=$directory count=${paths.size}")
return true
}
override suspend fun mcpStatus(directory: String) = KiloCliDataParser.parseMcpStatus(request(directory, "/mcp", null)).also { items ->
LOG.info("MCP status returned dir=$directory count=${items.size}")
}
@@ -238,6 +277,11 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? =
return items.mapNotNull { item -> resolveEditablePath(item) }.toSet()
}
private suspend fun knownCommands(directory: String): Set<Path> {
val items = commandFiles(directory)
return items.mapNotNull { item -> resolveEditableCommandPath(item) }.toSet()
}
private fun writablePath(directory: String, location: String, known: Set<Path>): Path? {
val path = resolveSkillPath(location)
if (path == null) {
@@ -251,12 +295,28 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? =
return path
}
private fun writableCommandPath(directory: String, location: String, known: Set<Path>): Path? {
val path = resolveCommandPath(location)
if (path == null) {
LOG.warn("Command save rejected: invalid location dir=$directory location=$location")
return null
}
if (path in known || newCommandPath(directory, path)) return path
LOG.warn("Command save rejected: unknown command dir=$directory path=$path")
return null
}
private fun resolveEditablePath(skill: SkillDto): Path? {
val path = resolveSkillPath(skill.location) ?: return null
if (urlCached(path)) return null
return path
}
private fun resolveEditableCommandPath(command: CommandFileDto): Path? {
if (!command.editable) return null
return resolveCommandPath(command.location)
}
private fun resolveSkillPath(location: String): Path? {
val raw = normalizeWorkspacePath(location) ?: return null
val path = try {
@@ -268,6 +328,26 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? =
return path
}
private fun resolveCommandPath(location: String): Path? {
val raw = normalizeWorkspacePath(location) ?: return null
val path = try {
Path.of(raw).normalize()
} catch (_: InvalidPathException) {
return null
}
if (!path.isAbsolute || path.fileName?.toString()?.endsWith(".md") != true) return null
return path
}
private fun newCommandPath(directory: String, path: Path): Boolean {
val root = try {
Path.of(directory).normalize().resolve(".kilo").resolve("command").normalize()
} catch (_: InvalidPathException) {
return false
}
return path.startsWith(root)
}
private fun urlCached(path: Path): Boolean {
return cacheRoots().any { root -> path.startsWith(root.resolve("kilo").resolve("skills").normalize()) }
}
@@ -119,6 +119,58 @@ class KiloAgentBehaviorRpcApiImplTest {
assertEquals(1, mock.requestCount("/instance/reload"))
}
@Test
fun `command files and remove command call CLI endpoints`() = runBlocking {
val dir = Files.createTempDirectory("kilo-command-test")
val file = Files.createDirectories(dir.resolve("command")).resolve("review.md")
Files.writeString(file, "---\ndescription: Review code\n---\n\nReview $" + "ARGUMENTS")
mock.commandFiles = """[
{"name":"review","description":"Review code","source":"command","builtin":false,"location":"$file","editable":true,"content":"Review"},
{"name":"init","source":"command","builtin":true,"location":"builtin","editable":false,"content":"Init"}
]""".trimIndent()
val rpc = rpc()
val commands = rpc.commandFiles("/test project")
assertEquals(listOf("review", "init"), commands.map { it.name })
assertEquals(true, commands.single { it.name == "review" }.editable)
assertEquals(false, commands.single { it.name == "init" }.editable)
assertTrue(rpc.removeCommand("/test project", file.toString()))
assertEquals("{\"location\":\"$file\"}", mock.lastCommandRemoveBody)
assertEquals(1, mock.requestCount("/kilocode/command/remove"))
mock.commandRemoveStatus = 400
val err = assertFailsWith<RuntimeException> {
rpc.removeCommand("/test", "/tmp/missing.md")
}
assertContains(err.message.orEmpty(), "HTTP 400")
assertTrue(rpc.reloadCommands("/test project"))
assertEquals(1, mock.requestCount("/instance/reload"))
}
@Test
fun `save commands validates known and new project command paths`() = runBlocking {
val project = Files.createTempDirectory("kilo-command-project")
val known = Files.createDirectories(project.resolve(".kilo/command")).resolve("known.md")
val added = project.resolve(".kilo/command/new.md")
val other = Files.createTempFile("kilo-command-other", ".md")
Files.writeString(known, "old")
Files.writeString(other, "old")
mock.commandFiles = """[
{"name":"known","source":"command","builtin":false,"location":"$known","editable":true,"content":"old"}
]""".trimIndent()
val rpc = rpc()
assertTrue(rpc.saveCommands(project.toString(), mapOf(known.toString() to "new", added.toString() to "created")))
assertEquals("new", Files.readString(known))
assertEquals("created", Files.readString(added))
assertEquals(1, mock.requestCount("/kilocode/command/files"))
assertFalse(rpc.saveCommands(project.toString(), mapOf(other.toString() to "nope")))
assertEquals("old", Files.readString(other))
}
@Test
fun `url cached skills are read only`() = runBlocking {
val cache = Path.of(System.getProperty("user.home"), ".cache", "kilo", "skills", "remote")
@@ -68,10 +68,12 @@ class MockCliServer : AutoCloseable {
@Volatile var mcpStatus = 200
@Volatile var mcpActionStatus = 200
@Volatile var agentRemoveStatus = 200
@Volatile var commandRemoveStatus = 200
@Volatile var skillRemoveStatus = 200
@Volatile var agentBuilderStatus = 200
@Volatile var lastMcpActionPath: String? = null
@Volatile var lastAgentRemoveBody: String? = null
@Volatile var lastCommandRemoveBody: String? = null
@Volatile var lastSkillRemoveBody: String? = null
@Volatile var lastAgentBuilderPath: String? = null
@Volatile var lastAgentBuilderBody: String? = null
@@ -83,11 +85,13 @@ class MockCliServer : AutoCloseable {
@Volatile var providersAfterAuthPut: String? = null
@Volatile var agents = "[]"
@Volatile var commands = "[]"
@Volatile var commandFiles = "[]"
@Volatile var skills = "[]"
@Volatile var providersStatus = 200
@Volatile var providerAuthStatus = 200
@Volatile var agentsStatus = 200
@Volatile var commandsStatus = 200
@Volatile var commandFilesStatus = 200
@Volatile var skillsStatus = 200
// File search responses
@@ -382,6 +386,11 @@ class MockCliServer : AutoCloseable {
lastAgentRemoveBody = body
respond(output, agentRemoveStatus, if (agentRemoveStatus == 200) "true" else """{"error":"Agent not found"}""")
}
bare == "/kilocode/command/files" -> respond(output, commandFilesStatus, commandFiles)
bare == "/kilocode/command/remove" && method == "POST" -> {
lastCommandRemoveBody = body
respond(output, commandRemoveStatus, if (commandRemoveStatus == 200) "true" else """{"error":"Command not found"}""")
}
bare == "/kilocode/skill/remove" && method == "POST" -> {
lastSkillRemoveBody = body
respond(output, skillRemoveStatus, if (skillRemoveStatus == 200) "true" else """{"error":"Skill not found"}""")
@@ -3,6 +3,7 @@ package ai.kilocode.client.testing
import ai.kilocode.rpc.KiloAgentBehaviorRpcApi
import ai.kilocode.rpc.dto.AgentCreateDto
import ai.kilocode.rpc.dto.AgentDetailDto
import ai.kilocode.rpc.dto.CommandFileDto
import ai.kilocode.rpc.dto.CommandDto
import ai.kilocode.rpc.dto.McpConfigDto
import ai.kilocode.rpc.dto.McpServerConfigDto
@@ -12,6 +13,7 @@ import ai.kilocode.rpc.dto.SkillDto
class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
var agents = emptyList<AgentDetailDto>()
var skills = emptyList<SkillDto>()
var commandFiles = emptyList<CommandFileDto>()
var mcps = emptyList<McpStatusDto>()
var mcpConfigs = emptyMap<String, McpServerConfigDto>()
val agentCalls = mutableListOf<String>()
@@ -19,6 +21,10 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
val skillRemovals = mutableListOf<Pair<String, String>>()
val skillReloads = mutableListOf<String>()
val skillSaves = mutableListOf<Triple<String, String, String>>()
val commandCalls = mutableListOf<String>()
val commandRemovals = mutableListOf<Pair<String, String>>()
val commandReloads = mutableListOf<String>()
val commandSaves = mutableListOf<Triple<String, String, String>>()
val mcpCalls = mutableListOf<String>()
val mcpConfigCalls = mutableListOf<String>()
val mcpSaves = mutableListOf<Triple<String, String, McpConfigDto?>>()
@@ -33,6 +39,7 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
var afterMcpConnect: (suspend (String, String) -> Unit)? = null
var createError: Exception? = null
var skillsError: Exception? = null
var commandFilesError: Exception? = null
var removeError: Exception? = null
var removeSkillError: Exception? = null
var saveSkillError: Exception? = null
@@ -42,6 +49,9 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
var removeSkillResult = true
var reloadSkillResult = true
var saveSkillResult = true
var removeCommandResult = true
var reloadCommandResult = true
var saveCommandResult = true
var mcpConnectResult = true
var mcpDisconnectResult = true
var mcpAuthenticateResult = true
@@ -123,6 +133,35 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
return emptyList()
}
override suspend fun commandFiles(directory: String): List<CommandFileDto> {
assertNotEdt("agentBehavior.commandFiles")
commandFilesError?.let { throw it }
commandCalls.add(directory)
return commandFiles
}
override suspend fun removeCommand(directory: String, location: String): Boolean {
assertNotEdt("agentBehavior.removeCommand")
commandRemovals.add(directory to location)
if (removeCommandResult) commandFiles = commandFiles.filterNot { it.location == location }
return removeCommandResult
}
override suspend fun reloadCommands(directory: String): Boolean {
assertNotEdt("agentBehavior.reloadCommands")
commandReloads.add(directory)
return reloadCommandResult
}
override suspend fun saveCommands(directory: String, edits: Map<String, String>): Boolean {
assertNotEdt("agentBehavior.saveCommands")
for ((location, content) in edits) commandSaves.add(Triple(directory, location, content))
if (saveCommandResult) commandFiles = commandFiles.map { command ->
edits[command.location]?.let { command.copy(content = it) } ?: command
}
return saveCommandResult
}
override suspend fun mcpStatus(directory: String): List<McpStatusDto> {
assertNotEdt("agentBehavior.mcpStatus")
mcpStatusError?.let { throw it }
@@ -2,6 +2,7 @@ package ai.kilocode.rpc
import ai.kilocode.rpc.dto.AgentDetailDto
import ai.kilocode.rpc.dto.AgentCreateDto
import ai.kilocode.rpc.dto.CommandFileDto
import ai.kilocode.rpc.dto.CommandDto
import ai.kilocode.rpc.dto.McpConfigDto
import ai.kilocode.rpc.dto.McpServerConfigDto
@@ -38,6 +39,14 @@ interface KiloAgentBehaviorRpcApi : RemoteApi<Unit> {
suspend fun commands(directory: String): List<CommandDto>
suspend fun commandFiles(directory: String): List<CommandFileDto>
suspend fun removeCommand(directory: String, location: String): Boolean
suspend fun reloadCommands(directory: String): Boolean
suspend fun saveCommands(directory: String, edits: Map<String, String>): Boolean
suspend fun mcpStatus(directory: String): List<McpStatusDto>
suspend fun mcpConfig(directory: String): Map<String, McpServerConfigDto>
@@ -0,0 +1,15 @@
package ai.kilocode.rpc.dto
import kotlinx.serialization.Serializable
@Serializable
data class CommandFileDto(
val name: String,
val description: String? = null,
val source: String? = null,
val builtin: Boolean = false,
val location: String,
val editable: Boolean = false,
val content: String? = null,
val hints: List<String> = emptyList(),
)
@@ -0,0 +1,127 @@
import { readdir, readFile, unlink } from "node:fs/promises"
import path from "node:path"
import { Global } from "@opencode-ai/core/global"
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),
source: Schema.optional(Schema.String),
builtin: Schema.Boolean,
location: Schema.String,
editable: Schema.Boolean,
content: Schema.optional(Schema.String),
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 scan(path.join(dir, "command"))) result.push(await command(dir, file))
for (const file of await scan(path.join(dir, "commands"))) result.push(await command(dir, file))
return result
}
async function scan(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
const result: string[] = []
for (const entry of entries) {
const next = path.join(dir, entry.name)
if (entry.isDirectory()) result.push(...(await scan(next)))
if (entry.isFile() && entry.name.endsWith(".md")) result.push(next)
}
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 dir of input.directories) all.push(...(await files(dir)))
for (const item of await WorkflowsMigrator.discoverWorkflows(input.directory)) {
all.push({ name: item.name, location: item.path, content: item.content })
}
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),
source: cmd.source,
builtin: false,
location: file.location,
editable: true,
content: file.content,
hints: cmd.hints,
}
}
return {
name: cmd.name,
description: description(cmd),
source: cmd.source,
builtin: true,
location: "builtin",
editable: false,
content: literal(cmd),
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,82 @@
import { afterEach, describe, expect, test } from "bun:test"
import { mkdir, mkdtemp, rm, 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,
source: input.source,
template: input.template ?? "body",
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", 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 })
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("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()
})
})
+79
View File
@@ -183,6 +183,8 @@ import type {
KilocodeAgentManagerReplyResponses,
KilocodeAgentRequirementsErrors,
KilocodeAgentRequirementsResponses,
KilocodeCommandFilesErrors,
KilocodeCommandFilesResponses,
KilocodeHeapSnapshotErrors,
KilocodeHeapSnapshotResponses,
KilocodeNotebookListErrors,
@@ -193,6 +195,8 @@ import type {
KilocodeNotebookReplyResponses,
KilocodeRemoveAgentErrors,
KilocodeRemoveAgentResponses,
KilocodeRemoveCommandErrors,
KilocodeRemoveCommandResponses,
KilocodeRemoveSkillErrors,
KilocodeRemoveSkillResponses,
KilocodeSessionImportMessageErrors,
@@ -8038,6 +8042,81 @@ export class Kilocode extends HeyApiClient {
})
}
/**
* List command files
*
* List commands with editable file locations for settings clients.
*/
public commandFiles<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<
KilocodeCommandFilesResponses,
KilocodeCommandFilesErrors,
ThrowOnError
>({
url: "/kilocode/command/files",
...options,
...params,
})
}
/**
* Remove a command
*
* Remove a command by deleting its markdown file from disk and clearing it from cache.
*/
public removeCommand<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
location?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "body", key: "location" },
],
},
],
)
return (options?.client ?? this.client).post<
KilocodeRemoveCommandResponses,
KilocodeRemoveCommandErrors,
ThrowOnError
>({
url: "/kilocode/command/remove",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* Remove a skill
*
+69
View File
@@ -3231,6 +3231,17 @@ export type AgentRequirementResult = {
}
}
export type CommandFile = {
name: string
description?: string
source?: string
builtin: boolean
location: string
editable: boolean
content?: string
hints: Array<string>
}
export type NotebookOutput = {
mime: string
text?: string
@@ -12538,6 +12549,64 @@ export type KilocodeAgentRequirementsResponses = {
export type KilocodeAgentRequirementsResponse =
KilocodeAgentRequirementsResponses[keyof KilocodeAgentRequirementsResponses]
export type KilocodeCommandFilesData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/kilocode/command/files"
}
export type KilocodeCommandFilesErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type KilocodeCommandFilesError = KilocodeCommandFilesErrors[keyof KilocodeCommandFilesErrors]
export type KilocodeCommandFilesResponses = {
/**
* Command files
*/
200: Array<CommandFile>
}
export type KilocodeCommandFilesResponse = KilocodeCommandFilesResponses[keyof KilocodeCommandFilesResponses]
export type KilocodeRemoveCommandData = {
body?: {
location: string
}
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/kilocode/command/remove"
}
export type KilocodeRemoveCommandErrors = {
/**
* BadRequest | InvalidRequestError
*/
400: EffectHttpApiErrorBadRequest | InvalidRequestError
}
export type KilocodeRemoveCommandError = KilocodeRemoveCommandErrors[keyof KilocodeRemoveCommandErrors]
export type KilocodeRemoveCommandResponses = {
/**
* Command removed
*/
200: boolean
}
export type KilocodeRemoveCommandResponse = KilocodeRemoveCommandResponses[keyof KilocodeRemoveCommandResponses]
export type KilocodeRemoveSkillData = {
body?: {
location: string
+170
View File
@@ -14909,6 +14909,142 @@
]
}
},
"/kilocode/command/files": {
"get": {
"tags": ["kilocode"],
"operationId": "kilocode.commandFiles",
"parameters": [
{
"name": "directory",
"in": "query",
"schema": {
"type": "string"
},
"required": false
},
{
"name": "workspace",
"in": "query",
"schema": {
"type": "string"
},
"required": false
}
],
"responses": {
"200": {
"description": "Command files",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CommandFile"
},
"description": "Command files"
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BadRequestError"
}
}
}
}
},
"description": "List commands with editable file locations for settings clients.",
"summary": "List command files",
"x-codeSamples": [
{
"lang": "js",
"source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.kilocode.commandFiles({\n ...\n})"
}
]
}
},
"/kilocode/command/remove": {
"post": {
"tags": ["kilocode"],
"operationId": "kilocode.removeCommand",
"parameters": [
{
"name": "directory",
"in": "query",
"schema": {
"type": "string"
},
"required": false
},
{
"name": "workspace",
"in": "query",
"schema": {
"type": "string"
},
"required": false
}
],
"responses": {
"200": {
"description": "Command removed",
"content": {
"application/json": {
"schema": {
"type": "boolean",
"description": "Command removed"
}
}
}
},
"400": {
"description": "BadRequest | InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/effect_HttpApiError_BadRequest"
},
{
"$ref": "#/components/schemas/InvalidRequestError"
}
]
}
}
}
}
},
"description": "Remove a command by deleting its markdown file from disk and clearing it from cache.",
"summary": "Remove a command",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"type": "string"
}
},
"required": ["location"],
"additionalProperties": false
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.kilocode.removeCommand({\n ...\n})"
}
]
}
},
"/kilocode/skill/remove": {
"post": {
"tags": ["kilocode"],
@@ -34344,6 +34480,40 @@
"required": ["agent", "directory", "enabled", "state", "skills", "mcps", "vscode_extensions"],
"additionalProperties": false
},
"CommandFile": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"source": {
"type": "string"
},
"builtin": {
"type": "boolean"
},
"location": {
"type": "string"
},
"editable": {
"type": "boolean"
},
"content": {
"type": "string"
},
"hints": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["name", "builtin", "location", "editable", "hints"],
"additionalProperties": false
},
"NotebookOutput": {
"type": "object",
"properties": {