diff --git a/.changeset/commands-settings-jetbrains.md b/.changeset/commands-settings-jetbrains.md new file mode 100644 index 00000000000..0c451ea9f32 --- /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, inspect model and reasoning variant metadata, 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..919dc4b74bc 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 @@ -630,8 +631,12 @@ object KiloCliDataParser { CommandInfo( name = obj.str("name") ?: "", description = obj.str("description"), + agent = obj.str("agent"), + model = obj.str("model"), + variant = obj.str("variant"), source = obj.str("source"), hints = obj["hints"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(), + subtask = obj.flagOrNull("subtask"), ) } @@ -662,9 +667,34 @@ object KiloCliDataParser { CommandDto( name = name, description = obj.str("description"), + agent = obj.str("agent"), + model = obj.str("model"), + variant = obj.str("variant"), source = obj.str("source"), hints = obj["hints"].arr()?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(), template = obj.str("template"), + subtask = obj.flagOrNull("subtask"), + ) + } + + 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"), + agent = obj.str("agent"), + model = obj.str("model"), + variant = obj.str("variant"), + source = obj.str("source"), + builtin = obj.bool("builtin"), + location = location, + editable = obj.bool("editable"), + content = obj.str("content"), + subtask = obj.flagOrNull("subtask"), + hints = obj["hints"].arr()?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(), ) } @@ -713,6 +743,16 @@ object KiloCliDataParser { return if (prim.isString) prim.content else null } + fun parsePathConfig(raw: String): String? { + val prim = runCatching { tryParseObject(raw)?.get("config")?.jsonPrimitive }.getOrNull() ?: return null + return if (prim.isString) prim.content else null + } + + fun parsePathHome(raw: String): String? { + val prim = runCatching { tryParseObject(raw)?.get("home")?.jsonPrimitive }.getOrNull() ?: return null + return if (prim.isString) prim.content else null + } + fun parseModelState(raw: String): ModelStateDto { val obj = tryParseObject(raw) ?: return ModelStateDto() return ModelStateDto( 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..244e5b2f2da 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 @@ -75,7 +76,7 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = override suspend fun reloadSkills(directory: String): Boolean { LOG.info("Skills reload requested dir=$directory") - if (hasActiveSession(directory)) { + if (hasActiveSession(directory, "Skills")) { LOG.warn("Skills reload blocked by active session dir=$directory") return false } @@ -136,6 +137,45 @@ 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, "Commands")) { + 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 roots = commandRoots(directory) + val paths = edits.map { (location, content) -> + val path = writableCommandPath(directory, location, known, roots) ?: 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}") } @@ -193,24 +233,24 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = return true } - private fun hasActiveSession(directory: String): Boolean { + private fun hasActiveSession(directory: String, label: String): Boolean { val active = app.sessions.statuses.value.filterValues { it.type != "idle" } if (active.isNotEmpty()) { - LOG.info("Skills reload active statuses dir=$directory count=${active.size} types=${active.values.map { it.type }.distinct()}") + LOG.info("$label reload active statuses dir=$directory count=${active.size} types=${active.values.map { it.type }.distinct()}") return true } val permissions = runCatching { app.chat.pendingPermissions(directory) }.onFailure { err -> - LOG.warn("Skills reload pending permission check failed dir=$directory", err) + LOG.warn("$label reload pending permission check failed dir=$directory", err) }.getOrDefault(emptyList()) if (permissions.isNotEmpty()) { - LOG.info("Skills reload pending permissions dir=$directory count=${permissions.size}") + LOG.info("$label reload pending permissions dir=$directory count=${permissions.size}") return true } val questions = runCatching { app.chat.pendingQuestions(directory) }.onFailure { err -> - LOG.warn("Skills reload pending question check failed dir=$directory", err) + LOG.warn("$label reload pending question check failed dir=$directory", err) }.getOrDefault(emptyList()) if (questions.isNotEmpty()) { - LOG.info("Skills reload pending questions dir=$directory count=${questions.size}") + LOG.info("$label reload pending questions dir=$directory count=${questions.size}") return true } return false @@ -238,6 +278,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 +296,28 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? = return path } + private fun writableCommandPath(directory: String, location: String, known: Set, roots: 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(path, roots)) 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 +329,59 @@ 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 suspend fun commandRoots(directory: String): Set = buildSet { + addProjectCommandRoots(this, directory) + val paths = runCatching { request(directory, "/path", null) }.getOrNull() + val config = paths?.let(KiloCliDataParser::parsePathConfig) + if (config != null) addConfigCommandRoots(this, config) + val home = paths?.let(KiloCliDataParser::parsePathHome) + if (home != null) addHomeCommandRoots(this, home) + } + + private fun addProjectCommandRoots(roots: MutableSet, dir: String) { + val base = try { + Path.of(dir).normalize() + } catch (_: InvalidPathException) { + return + } + for (cfg in listOf(".kilo", ".kilocode")) { + for (name in listOf("command", "commands")) roots.add(base.resolve(cfg).resolve(name).normalize()) + } + } + + private fun addConfigCommandRoots(roots: MutableSet, dir: String) { + val base = try { + Path.of(dir).normalize() + } catch (_: InvalidPathException) { + return + } + for (name in listOf("command", "commands")) roots.add(base.resolve(name).normalize()) + } + + private fun addHomeCommandRoots(roots: MutableSet, home: String) { + val base = try { + Path.of(home).normalize() + } catch (_: InvalidPathException) { + return + } + for (cfg in listOf(".kilo", ".kilocode")) addConfigCommandRoots(roots, base.resolve(cfg).toString()) + } + + private fun newCommandPath(path: Path, roots: Set): Boolean { + return roots.any { root -> 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/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceDtoMapper.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceDtoMapper.kt index 981ea1ad28b..ad89fb9be0f 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceDtoMapper.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceDtoMapper.kt @@ -56,8 +56,12 @@ internal object KiloWorkspaceDtoMapper { fun command(c: CommandInfo) = CommandDto( name = c.name, description = c.description, + agent = c.agent, + model = c.model, + variant = c.variant, source = c.source, hints = c.hints, + subtask = c.subtask, ) fun skill(s: SkillInfo) = SkillDto( diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt index 37c58d853d6..3d926aa84da 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt @@ -135,8 +135,12 @@ data class AgentInfo( data class CommandInfo( val name: String, val description: String?, + val agent: String?, + val model: String?, + val variant: String?, val source: String?, val hints: List, + val subtask: Boolean?, ) data class SkillInfo( diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 93514a7a750..f6b7be9cbc3 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -1817,7 +1817,7 @@ class KiloCliDataParserTest { @Test fun `parseCommands - maps name, description, source, and hints`() { val raw = """[ - {"name":"init","description":"guided AGENTS.md setup","template":"static body","hints":["${'$'}ARGUMENTS"],"source":"command"}, + {"name":"init","description":"guided AGENTS.md setup","agent":"reviewer","model":"anthropic/claude-sonnet-4-6","variant":"high","template":"static body","hints":["${'$'}ARGUMENTS"],"source":"command","subtask":true}, {"name":"mcp-tool","template":"","hints":["${'$'}1","${'$'}2"],"source":"mcp"} ]""" @@ -1826,8 +1826,12 @@ class KiloCliDataParserTest { assertEquals(2, result.size) assertEquals("init", result[0].name) assertEquals("guided AGENTS.md setup", result[0].description) + assertEquals("reviewer", result[0].agent) + assertEquals("anthropic/claude-sonnet-4-6", result[0].model) + assertEquals("high", result[0].variant) assertEquals("command", result[0].source) assertEquals(listOf("\$ARGUMENTS"), result[0].hints) + assertEquals(true, result[0].subtask) assertEquals("mcp", result[1].source) assertEquals(listOf("\$1", "\$2"), result[1].hints) } @@ -1878,6 +1882,8 @@ class KiloCliDataParserTest { fun `parsePathState - extracts state from valid path response`() { val raw = """{"home":"/home/user","state":"/home/user/.local/state/kilo","config":"/home/user/.config/kilo","worktree":"/project","directory":"/project"}""" assertEquals("/home/user/.local/state/kilo", KiloCliDataParser.parsePathState(raw)) + assertEquals("/home/user/.config/kilo", KiloCliDataParser.parsePathConfig(raw)) + assertEquals("/home/user", KiloCliDataParser.parsePathHome(raw)) } @Test 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..6daa8b0096f 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,76 @@ 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","agent":"reviewer","model":"anthropic/claude-sonnet-4-6","variant":"high","source":"command","builtin":false,"location":"$file","editable":true,"content":"Review","subtask":true}, + {"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("reviewer", commands.single { it.name == "review" }.agent) + assertEquals("anthropic/claude-sonnet-4-6", commands.single { it.name == "review" }.model) + assertEquals("high", commands.single { it.name == "review" }.variant) + assertEquals(true, commands.single { it.name == "review" }.subtask) + 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/commands/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 `save commands validates new global command paths`() = runBlocking { + val project = Files.createTempDirectory("kilo-command-project") + val config = Files.createTempDirectory("kilo-command-config") + val added = config.resolve("commands/global.md") + mock.path = """{"home":"/tmp","state":"/tmp","config":"$config","worktree":"$project","directory":"$project"}""" + val rpc = rpc() + + assertTrue(rpc.saveCommands(project.toString(), mapOf(added.toString() to "global command"))) + + assertEquals("global command", Files.readString(added)) + assertEquals(1, mock.requestCount("/path")) + } + @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..cf60691cfc5 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 @@ -368,7 +372,7 @@ class MockCliServer : AutoCloseable { respond(output, organizationSetStatus, "true") } path == "/global/event" -> handleSse(output, latch) - path == "/path" -> respond(output, 200, this.path) + bare == "/path" -> respond(output, 200, this.path) bare == "/provider" -> respond(output, providersStatus, providers) bare == "/provider/auth" -> respond(output, providerAuthStatus, providerAuth) bare == "/agent" -> respond(output, agentsStatus, agents) @@ -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/CommandDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandDto.kt index 4d0e6540aac..f14eab4aaa5 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandDto.kt @@ -6,7 +6,11 @@ import kotlinx.serialization.Serializable data class CommandDto( val name: String, val description: String? = null, + val agent: String? = null, + val model: String? = null, + val variant: String? = null, val source: String? = null, val hints: List = emptyList(), val template: String? = null, + val subtask: Boolean? = null, ) 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..9c4267dcf03 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/CommandFileDto.kt @@ -0,0 +1,19 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class CommandFileDto( + val name: String, + val description: String? = null, + val agent: String? = null, + val model: String? = null, + val variant: String? = null, + val source: String? = null, + val builtin: Boolean = false, + val location: String, + val editable: Boolean = false, + val content: String? = null, + val subtask: Boolean? = 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..35496d2b472 --- /dev/null +++ b/packages/opencode/src/kilocode/command-files.ts @@ -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 + +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 { + 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 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" 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..1a183f23fe4 --- /dev/null +++ b/packages/opencode/test/kilocode/command-files.test.ts @@ -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 & Pick): 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() + }) +}) diff --git a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts index 2e3e6c4f9ce..e3b682befa0 100644 --- a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts +++ b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts @@ -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 } }) @@ -543,6 +550,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 }) diff --git a/packages/opencode/test/kilocode/test-runner-cleanup.test.ts b/packages/opencode/test/kilocode/test-runner-cleanup.test.ts index 23f1432fc3b..7c5dbf18025 100644 --- a/packages/opencode/test/kilocode/test-runner-cleanup.test.ts +++ b/packages/opencode/test/kilocode/test-runner-cleanup.test.ts @@ -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", 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 f234bbed584..459fa4d8dee 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3234,6 +3234,21 @@ export type AgentRequirementResult = { } } +export type CommandFile = { + name: string + description?: string + agent?: string + model?: string + variant?: string + source?: string + builtin: boolean + location: string + editable: boolean + content?: string + subtask?: boolean + hints: Array +} + export type NotebookOutput = { mime: string text?: string @@ -12542,6 +12557,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 71ff553fff6..11853b6aac6 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"], @@ -34352,6 +34488,52 @@ "required": ["agent", "directory", "enabled", "state", "skills", "mcps", "vscode_extensions"], "additionalProperties": false }, + "CommandFile": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "source": { + "type": "string" + }, + "builtin": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "editable": { + "type": "boolean" + }, + "content": { + "type": "string" + }, + "subtask": { + "type": "boolean" + }, + "hints": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["name", "builtin", "location", "editable", "hints"], + "additionalProperties": false + }, "NotebookOutput": { "type": "object", "properties": {