From b692f1ded1969165587a184e359d0848dc3e9bea Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 3 Aug 2026 13:25:40 -0400 Subject: [PATCH] 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. --- .changeset/commands-settings-jetbrains.md | 5 + .../kilocode/backend/cli/KiloCliDataParser.kt | 18 ++ .../rpc/KiloAgentBehaviorRpcApiImpl.kt | 80 +++++++++ .../rpc/KiloAgentBehaviorRpcApiImplTest.kt | 52 ++++++ .../kilocode/backend/testing/MockCliServer.kt | 9 + .../client/testing/FakeAgentBehaviorRpcApi.kt | 39 ++++ .../kilocode/rpc/KiloAgentBehaviorRpcApi.kt | 9 + .../ai/kilocode/rpc/dto/CommandFileDto.kt | 15 ++ .../opencode/src/kilocode/command-files.ts | 127 +++++++++++++ .../server/httpapi/groups/kilocode.ts | 29 +++ .../server/httpapi/handlers/kilocode.ts | 34 ++++ .../test/kilocode/command-files.test.ts | 82 +++++++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 79 ++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 69 +++++++ packages/sdk/openapi.json | 170 ++++++++++++++++++ 15 files changed, 817 insertions(+) create mode 100644 .changeset/commands-settings-jetbrains.md create mode 100644 packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandFileDto.kt create mode 100644 packages/opencode/src/kilocode/command-files.ts create mode 100644 packages/opencode/test/kilocode/command-files.test.ts diff --git a/.changeset/commands-settings-jetbrains.md b/.changeset/commands-settings-jetbrains.md new file mode 100644 index 00000000000..fc7b8272360 --- /dev/null +++ b/.changeset/commands-settings-jetbrains.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Add kilocode command-file endpoints so clients can list editable command/workflow files and remove them. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index d18a8c0dee4..eec74b0c1cb 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -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 = + 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 { val root = runCatching { json.parseToJsonElement(raw) }.getOrNull() ?: return emptyList() return when (root) { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt index 95185f2f204..fa006abd004 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImpl.kt @@ -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 = + 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): 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 { + val items = commandFiles(directory) + return items.mapNotNull { item -> resolveEditableCommandPath(item) }.toSet() + } + private fun writablePath(directory: String, location: String, known: Set): 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? { + 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()) } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt index 880e6cba4cd..514fa0d7e28 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt @@ -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 { + 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") diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index d016709a685..573ad67a46e 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -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"}""") diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt index 87f13a939c4..c74b6132c04 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt @@ -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() var skills = emptyList() + var commandFiles = emptyList() var mcps = emptyList() var mcpConfigs = emptyMap() val agentCalls = mutableListOf() @@ -19,6 +21,10 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { val skillRemovals = mutableListOf>() val skillReloads = mutableListOf() val skillSaves = mutableListOf>() + val commandCalls = mutableListOf() + val commandRemovals = mutableListOf>() + val commandReloads = mutableListOf() + val commandSaves = mutableListOf>() val mcpCalls = mutableListOf() val mcpConfigCalls = mutableListOf() val mcpSaves = mutableListOf>() @@ -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 { + 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): 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 { assertNotEdt("agentBehavior.mcpStatus") mcpStatusError?.let { throw it } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAgentBehaviorRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAgentBehaviorRpcApi.kt index b13c03d526a..ac82b9de313 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAgentBehaviorRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAgentBehaviorRpcApi.kt @@ -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 { suspend fun commands(directory: String): List + suspend fun commandFiles(directory: String): List + + suspend fun removeCommand(directory: String, location: String): Boolean + + suspend fun reloadCommands(directory: String): Boolean + + suspend fun saveCommands(directory: String, edits: Map): Boolean + suspend fun mcpStatus(directory: String): List suspend fun mcpConfig(directory: String): Map diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandFileDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandFileDto.kt new file mode 100644 index 00000000000..77c4a678117 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandFileDto.kt @@ -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 = emptyList(), +) diff --git a/packages/opencode/src/kilocode/command-files.ts b/packages/opencode/src/kilocode/command-files.ts new file mode 100644 index 00000000000..b1748018057 --- /dev/null +++ b/packages/opencode/src/kilocode/command-files.ts @@ -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 + +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 { + 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 { + 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() + 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" diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts index ad9a8db7783..fa48f84e9c7 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts @@ -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, diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts index f421f5d9335..229dadf9602 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts @@ -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) diff --git a/packages/opencode/test/kilocode/command-files.test.ts b/packages/opencode/test/kilocode/command-files.test.ts new file mode 100644 index 00000000000..354bf749625 --- /dev/null +++ b/packages/opencode/test/kilocode/command-files.test.ts @@ -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 & Pick): 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() + }) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 11c459be779..7a689c520e1 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -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( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + 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( + parameters?: { + directory?: string + workspace?: string + location?: string + }, + options?: Options, + ) { + 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 * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b95780c020a..02f843df6bb 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -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 +} + 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 +} + +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 diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 6e46bad83b1..fe6d6064a00 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -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": {