mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
feat(cli): add kilocode command-file endpoints for slash command management
Add GET /kilocode/command/files and POST /kilocode/command/remove, backed by a Kilo-owned discovery module that lists commands with editable file locations (and marks non-file commands as read-only built-ins). Wire the matching shared RPC contract, backend RPC implementation, DTO, and parser in the JetBrains plugin so clients can consume the endpoints. Regenerate the SDK.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
---
|
||||
|
||||
Add kilocode command-file endpoints so clients can list editable command/workflow files and remove them.
|
||||
+18
@@ -17,6 +17,7 @@ import ai.kilocode.rpc.dto.ChatEventDto
|
||||
import ai.kilocode.rpc.dto.CloudSessionDto
|
||||
import ai.kilocode.rpc.dto.CloudSessionListDto
|
||||
import ai.kilocode.rpc.dto.CommandDto
|
||||
import ai.kilocode.rpc.dto.CommandFileDto
|
||||
import ai.kilocode.rpc.dto.ConfigDto
|
||||
import ai.kilocode.rpc.dto.ConfigPatchDto
|
||||
import ai.kilocode.rpc.dto.ConfigUpdateDto
|
||||
@@ -668,6 +669,23 @@ object KiloCliDataParser {
|
||||
)
|
||||
}
|
||||
|
||||
fun parseAgentBehaviorCommandFiles(raw: String): List<CommandFileDto> =
|
||||
raw.array().mapNotNull { item ->
|
||||
val obj = item.obj() ?: return@mapNotNull null
|
||||
val name = obj.str("name") ?: return@mapNotNull null
|
||||
val location = obj.str("location") ?: return@mapNotNull null
|
||||
CommandFileDto(
|
||||
name = name,
|
||||
description = obj.str("description"),
|
||||
source = obj.str("source"),
|
||||
builtin = obj.bool("builtin"),
|
||||
location = location,
|
||||
editable = obj.bool("editable"),
|
||||
content = obj.str("content"),
|
||||
hints = obj["hints"].arr()?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
fun parseMcpStatus(raw: String): List<McpStatusDto> {
|
||||
val root = runCatching { json.parseToJsonElement(raw) }.getOrNull() ?: return emptyList()
|
||||
return when (root) {
|
||||
|
||||
+80
@@ -10,6 +10,7 @@ import ai.kilocode.rpc.KiloAgentBehaviorRpcApi
|
||||
import ai.kilocode.rpc.dto.AgentCreateDto
|
||||
import ai.kilocode.rpc.dto.AgentDetailDto
|
||||
import ai.kilocode.jetbrains.api.model.AgentBuilderSaveRequest
|
||||
import ai.kilocode.rpc.dto.CommandFileDto
|
||||
import ai.kilocode.rpc.dto.ConfigPatchDto
|
||||
import ai.kilocode.rpc.dto.McpConfigDto
|
||||
import ai.kilocode.rpc.dto.McpServerConfigDto
|
||||
@@ -136,6 +137,44 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? =
|
||||
|
||||
override suspend fun commands(directory: String) = KiloCliDataParser.parseAgentBehaviorCommands(request(directory, "/command", null))
|
||||
|
||||
override suspend fun commandFiles(directory: String): List<CommandFileDto> =
|
||||
KiloCliDataParser.parseAgentBehaviorCommandFiles(request(directory, "/kilocode/command/files", null))
|
||||
|
||||
override suspend fun removeCommand(directory: String, location: String): Boolean =
|
||||
post(directory, "/kilocode/command/remove", JsonObject(mapOf("location" to JsonPrimitive(location))))
|
||||
|
||||
override suspend fun reloadCommands(directory: String): Boolean {
|
||||
LOG.info("Commands reload requested dir=$directory")
|
||||
if (hasActiveSession(directory)) {
|
||||
LOG.warn("Commands reload blocked by active session dir=$directory")
|
||||
return false
|
||||
}
|
||||
runCatching { post(directory, "/instance/reload") }.onFailure { err ->
|
||||
LOG.warn("Commands reload failed dir=$directory", err)
|
||||
}.getOrThrow()
|
||||
LOG.info("Commands reload succeeded dir=$directory")
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun saveCommands(directory: String, edits: Map<String, String>): Boolean {
|
||||
LOG.info("Commands save requested dir=$directory count=${edits.size}")
|
||||
app.requireReady()
|
||||
val known = knownCommands(directory)
|
||||
val paths = edits.map { (location, content) ->
|
||||
val path = writableCommandPath(directory, location, known) ?: return false
|
||||
path to content
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
for ((path, content) in paths) {
|
||||
Files.createDirectories(path.parent)
|
||||
Files.writeString(path, content, StandardCharsets.UTF_8)
|
||||
}
|
||||
}
|
||||
LOG.info("Command files saved dir=$directory count=${paths.size}")
|
||||
LOG.info("Commands save reload deferred dir=$directory count=${paths.size}")
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun mcpStatus(directory: String) = KiloCliDataParser.parseMcpStatus(request(directory, "/mcp", null)).also { items ->
|
||||
LOG.info("MCP status returned dir=$directory count=${items.size}")
|
||||
}
|
||||
@@ -238,6 +277,11 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? =
|
||||
return items.mapNotNull { item -> resolveEditablePath(item) }.toSet()
|
||||
}
|
||||
|
||||
private suspend fun knownCommands(directory: String): Set<Path> {
|
||||
val items = commandFiles(directory)
|
||||
return items.mapNotNull { item -> resolveEditableCommandPath(item) }.toSet()
|
||||
}
|
||||
|
||||
private fun writablePath(directory: String, location: String, known: Set<Path>): Path? {
|
||||
val path = resolveSkillPath(location)
|
||||
if (path == null) {
|
||||
@@ -251,12 +295,28 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? =
|
||||
return path
|
||||
}
|
||||
|
||||
private fun writableCommandPath(directory: String, location: String, known: Set<Path>): Path? {
|
||||
val path = resolveCommandPath(location)
|
||||
if (path == null) {
|
||||
LOG.warn("Command save rejected: invalid location dir=$directory location=$location")
|
||||
return null
|
||||
}
|
||||
if (path in known || newCommandPath(directory, path)) return path
|
||||
LOG.warn("Command save rejected: unknown command dir=$directory path=$path")
|
||||
return null
|
||||
}
|
||||
|
||||
private fun resolveEditablePath(skill: SkillDto): Path? {
|
||||
val path = resolveSkillPath(skill.location) ?: return null
|
||||
if (urlCached(path)) return null
|
||||
return path
|
||||
}
|
||||
|
||||
private fun resolveEditableCommandPath(command: CommandFileDto): Path? {
|
||||
if (!command.editable) return null
|
||||
return resolveCommandPath(command.location)
|
||||
}
|
||||
|
||||
private fun resolveSkillPath(location: String): Path? {
|
||||
val raw = normalizeWorkspacePath(location) ?: return null
|
||||
val path = try {
|
||||
@@ -268,6 +328,26 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? =
|
||||
return path
|
||||
}
|
||||
|
||||
private fun resolveCommandPath(location: String): Path? {
|
||||
val raw = normalizeWorkspacePath(location) ?: return null
|
||||
val path = try {
|
||||
Path.of(raw).normalize()
|
||||
} catch (_: InvalidPathException) {
|
||||
return null
|
||||
}
|
||||
if (!path.isAbsolute || path.fileName?.toString()?.endsWith(".md") != true) return null
|
||||
return path
|
||||
}
|
||||
|
||||
private fun newCommandPath(directory: String, path: Path): Boolean {
|
||||
val root = try {
|
||||
Path.of(directory).normalize().resolve(".kilo").resolve("command").normalize()
|
||||
} catch (_: InvalidPathException) {
|
||||
return false
|
||||
}
|
||||
return path.startsWith(root)
|
||||
}
|
||||
|
||||
private fun urlCached(path: Path): Boolean {
|
||||
return cacheRoots().any { root -> path.startsWith(root.resolve("kilo").resolve("skills").normalize()) }
|
||||
}
|
||||
|
||||
+52
@@ -119,6 +119,58 @@ class KiloAgentBehaviorRpcApiImplTest {
|
||||
assertEquals(1, mock.requestCount("/instance/reload"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `command files and remove command call CLI endpoints`() = runBlocking {
|
||||
val dir = Files.createTempDirectory("kilo-command-test")
|
||||
val file = Files.createDirectories(dir.resolve("command")).resolve("review.md")
|
||||
Files.writeString(file, "---\ndescription: Review code\n---\n\nReview $" + "ARGUMENTS")
|
||||
mock.commandFiles = """[
|
||||
{"name":"review","description":"Review code","source":"command","builtin":false,"location":"$file","editable":true,"content":"Review"},
|
||||
{"name":"init","source":"command","builtin":true,"location":"builtin","editable":false,"content":"Init"}
|
||||
]""".trimIndent()
|
||||
val rpc = rpc()
|
||||
|
||||
val commands = rpc.commandFiles("/test project")
|
||||
assertEquals(listOf("review", "init"), commands.map { it.name })
|
||||
assertEquals(true, commands.single { it.name == "review" }.editable)
|
||||
assertEquals(false, commands.single { it.name == "init" }.editable)
|
||||
|
||||
assertTrue(rpc.removeCommand("/test project", file.toString()))
|
||||
assertEquals("{\"location\":\"$file\"}", mock.lastCommandRemoveBody)
|
||||
assertEquals(1, mock.requestCount("/kilocode/command/remove"))
|
||||
|
||||
mock.commandRemoveStatus = 400
|
||||
val err = assertFailsWith<RuntimeException> {
|
||||
rpc.removeCommand("/test", "/tmp/missing.md")
|
||||
}
|
||||
assertContains(err.message.orEmpty(), "HTTP 400")
|
||||
|
||||
assertTrue(rpc.reloadCommands("/test project"))
|
||||
assertEquals(1, mock.requestCount("/instance/reload"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save commands validates known and new project command paths`() = runBlocking {
|
||||
val project = Files.createTempDirectory("kilo-command-project")
|
||||
val known = Files.createDirectories(project.resolve(".kilo/command")).resolve("known.md")
|
||||
val added = project.resolve(".kilo/command/new.md")
|
||||
val other = Files.createTempFile("kilo-command-other", ".md")
|
||||
Files.writeString(known, "old")
|
||||
Files.writeString(other, "old")
|
||||
mock.commandFiles = """[
|
||||
{"name":"known","source":"command","builtin":false,"location":"$known","editable":true,"content":"old"}
|
||||
]""".trimIndent()
|
||||
val rpc = rpc()
|
||||
|
||||
assertTrue(rpc.saveCommands(project.toString(), mapOf(known.toString() to "new", added.toString() to "created")))
|
||||
assertEquals("new", Files.readString(known))
|
||||
assertEquals("created", Files.readString(added))
|
||||
assertEquals(1, mock.requestCount("/kilocode/command/files"))
|
||||
|
||||
assertFalse(rpc.saveCommands(project.toString(), mapOf(other.toString() to "nope")))
|
||||
assertEquals("old", Files.readString(other))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `url cached skills are read only`() = runBlocking {
|
||||
val cache = Path.of(System.getProperty("user.home"), ".cache", "kilo", "skills", "remote")
|
||||
|
||||
+9
@@ -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"}""")
|
||||
|
||||
+39
@@ -3,6 +3,7 @@ package ai.kilocode.client.testing
|
||||
import ai.kilocode.rpc.KiloAgentBehaviorRpcApi
|
||||
import ai.kilocode.rpc.dto.AgentCreateDto
|
||||
import ai.kilocode.rpc.dto.AgentDetailDto
|
||||
import ai.kilocode.rpc.dto.CommandFileDto
|
||||
import ai.kilocode.rpc.dto.CommandDto
|
||||
import ai.kilocode.rpc.dto.McpConfigDto
|
||||
import ai.kilocode.rpc.dto.McpServerConfigDto
|
||||
@@ -12,6 +13,7 @@ import ai.kilocode.rpc.dto.SkillDto
|
||||
class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
|
||||
var agents = emptyList<AgentDetailDto>()
|
||||
var skills = emptyList<SkillDto>()
|
||||
var commandFiles = emptyList<CommandFileDto>()
|
||||
var mcps = emptyList<McpStatusDto>()
|
||||
var mcpConfigs = emptyMap<String, McpServerConfigDto>()
|
||||
val agentCalls = mutableListOf<String>()
|
||||
@@ -19,6 +21,10 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
|
||||
val skillRemovals = mutableListOf<Pair<String, String>>()
|
||||
val skillReloads = mutableListOf<String>()
|
||||
val skillSaves = mutableListOf<Triple<String, String, String>>()
|
||||
val commandCalls = mutableListOf<String>()
|
||||
val commandRemovals = mutableListOf<Pair<String, String>>()
|
||||
val commandReloads = mutableListOf<String>()
|
||||
val commandSaves = mutableListOf<Triple<String, String, String>>()
|
||||
val mcpCalls = mutableListOf<String>()
|
||||
val mcpConfigCalls = mutableListOf<String>()
|
||||
val mcpSaves = mutableListOf<Triple<String, String, McpConfigDto?>>()
|
||||
@@ -33,6 +39,7 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
|
||||
var afterMcpConnect: (suspend (String, String) -> Unit)? = null
|
||||
var createError: Exception? = null
|
||||
var skillsError: Exception? = null
|
||||
var commandFilesError: Exception? = null
|
||||
var removeError: Exception? = null
|
||||
var removeSkillError: Exception? = null
|
||||
var saveSkillError: Exception? = null
|
||||
@@ -42,6 +49,9 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
|
||||
var removeSkillResult = true
|
||||
var reloadSkillResult = true
|
||||
var saveSkillResult = true
|
||||
var removeCommandResult = true
|
||||
var reloadCommandResult = true
|
||||
var saveCommandResult = true
|
||||
var mcpConnectResult = true
|
||||
var mcpDisconnectResult = true
|
||||
var mcpAuthenticateResult = true
|
||||
@@ -123,6 +133,35 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
override suspend fun commandFiles(directory: String): List<CommandFileDto> {
|
||||
assertNotEdt("agentBehavior.commandFiles")
|
||||
commandFilesError?.let { throw it }
|
||||
commandCalls.add(directory)
|
||||
return commandFiles
|
||||
}
|
||||
|
||||
override suspend fun removeCommand(directory: String, location: String): Boolean {
|
||||
assertNotEdt("agentBehavior.removeCommand")
|
||||
commandRemovals.add(directory to location)
|
||||
if (removeCommandResult) commandFiles = commandFiles.filterNot { it.location == location }
|
||||
return removeCommandResult
|
||||
}
|
||||
|
||||
override suspend fun reloadCommands(directory: String): Boolean {
|
||||
assertNotEdt("agentBehavior.reloadCommands")
|
||||
commandReloads.add(directory)
|
||||
return reloadCommandResult
|
||||
}
|
||||
|
||||
override suspend fun saveCommands(directory: String, edits: Map<String, String>): Boolean {
|
||||
assertNotEdt("agentBehavior.saveCommands")
|
||||
for ((location, content) in edits) commandSaves.add(Triple(directory, location, content))
|
||||
if (saveCommandResult) commandFiles = commandFiles.map { command ->
|
||||
edits[command.location]?.let { command.copy(content = it) } ?: command
|
||||
}
|
||||
return saveCommandResult
|
||||
}
|
||||
|
||||
override suspend fun mcpStatus(directory: String): List<McpStatusDto> {
|
||||
assertNotEdt("agentBehavior.mcpStatus")
|
||||
mcpStatusError?.let { throw it }
|
||||
|
||||
+9
@@ -2,6 +2,7 @@ package ai.kilocode.rpc
|
||||
|
||||
import ai.kilocode.rpc.dto.AgentDetailDto
|
||||
import ai.kilocode.rpc.dto.AgentCreateDto
|
||||
import ai.kilocode.rpc.dto.CommandFileDto
|
||||
import ai.kilocode.rpc.dto.CommandDto
|
||||
import ai.kilocode.rpc.dto.McpConfigDto
|
||||
import ai.kilocode.rpc.dto.McpServerConfigDto
|
||||
@@ -38,6 +39,14 @@ interface KiloAgentBehaviorRpcApi : RemoteApi<Unit> {
|
||||
|
||||
suspend fun commands(directory: String): List<CommandDto>
|
||||
|
||||
suspend fun commandFiles(directory: String): List<CommandFileDto>
|
||||
|
||||
suspend fun removeCommand(directory: String, location: String): Boolean
|
||||
|
||||
suspend fun reloadCommands(directory: String): Boolean
|
||||
|
||||
suspend fun saveCommands(directory: String, edits: Map<String, String>): Boolean
|
||||
|
||||
suspend fun mcpStatus(directory: String): List<McpStatusDto>
|
||||
|
||||
suspend fun mcpConfig(directory: String): Map<String, McpServerConfigDto>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package ai.kilocode.rpc.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class CommandFileDto(
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val source: String? = null,
|
||||
val builtin: Boolean = false,
|
||||
val location: String,
|
||||
val editable: Boolean = false,
|
||||
val content: String? = null,
|
||||
val hints: List<String> = emptyList(),
|
||||
)
|
||||
@@ -0,0 +1,127 @@
|
||||
import { readdir, readFile, unlink } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Schema } from "effect"
|
||||
import { Command } from "@/command"
|
||||
import { configEntryNameFromPath } from "@/config/entry-name"
|
||||
import { WorkflowsMigrator } from "@/kilocode/workflows-migrator"
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.optional(Schema.String),
|
||||
source: Schema.optional(Schema.String),
|
||||
builtin: Schema.Boolean,
|
||||
location: Schema.String,
|
||||
editable: Schema.Boolean,
|
||||
content: Schema.optional(Schema.String),
|
||||
hints: Schema.Array(Schema.String),
|
||||
}).annotate({ identifier: "CommandFile" })
|
||||
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
type File = {
|
||||
name: string
|
||||
location: string
|
||||
content: string
|
||||
}
|
||||
|
||||
const COMMAND_PREFIXES = ["command/", "commands/"]
|
||||
|
||||
async function files(dir: string) {
|
||||
const result: File[] = []
|
||||
for (const file of await scan(path.join(dir, "command"))) result.push(await command(dir, file))
|
||||
for (const file of await scan(path.join(dir, "commands"))) result.push(await command(dir, file))
|
||||
return result
|
||||
}
|
||||
|
||||
async function scan(dir: string): Promise<string[]> {
|
||||
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
||||
const result: string[] = []
|
||||
for (const entry of entries) {
|
||||
const next = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) result.push(...(await scan(next)))
|
||||
if (entry.isFile() && entry.name.endsWith(".md")) result.push(next)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function command(dir: string, file: string): Promise<File> {
|
||||
const content = await readFile(file, "utf8")
|
||||
return {
|
||||
name: configEntryNameFromPath(path.relative(dir, file), COMMAND_PREFIXES),
|
||||
location: file,
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
function precedence(files: File[]) {
|
||||
const result = new Map<string, File>()
|
||||
for (const file of files) result.set(file.name, file)
|
||||
return result
|
||||
}
|
||||
|
||||
function description(cmd: Command.Info, file?: File) {
|
||||
if (cmd.description) return cmd.description
|
||||
if (file) return WorkflowsMigrator.extractDescription(file.content)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function literal(cmd: Command.Info) {
|
||||
return typeof cmd.template === "string" ? cmd.template : undefined
|
||||
}
|
||||
|
||||
export async function discover(input: { commands: readonly Command.Info[]; directories: readonly string[]; directory: string }) {
|
||||
const all = []
|
||||
for (const dir of input.directories) all.push(...(await files(dir)))
|
||||
for (const item of await WorkflowsMigrator.discoverWorkflows(input.directory)) {
|
||||
all.push({ name: item.name, location: item.path, content: item.content })
|
||||
}
|
||||
const by = precedence(all)
|
||||
return input.commands
|
||||
.filter((cmd) => cmd.source !== "skill")
|
||||
.map((cmd): Info => {
|
||||
const file = by.get(cmd.name)
|
||||
if (file) {
|
||||
return {
|
||||
name: cmd.name,
|
||||
description: description(cmd, file),
|
||||
source: cmd.source,
|
||||
builtin: false,
|
||||
location: file.location,
|
||||
editable: true,
|
||||
content: file.content,
|
||||
hints: cmd.hints,
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: cmd.name,
|
||||
description: description(cmd),
|
||||
source: cmd.source,
|
||||
builtin: true,
|
||||
location: "builtin",
|
||||
editable: false,
|
||||
content: literal(cmd),
|
||||
hints: cmd.hints,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function target(location: string, commands: readonly Info[]) {
|
||||
if (!path.isAbsolute(location)) throw new Error("command location must be absolute")
|
||||
const file = path.resolve(location)
|
||||
const command = commands.find((item) => item.editable && path.resolve(item.location) === file)
|
||||
if (!command) throw new Error("command not found in registry")
|
||||
if (!file.endsWith(".md")) throw new Error("command location must reference a markdown file")
|
||||
const cache = path.join(Global.Path.cache, "commands")
|
||||
const relative = path.relative(cache, file)
|
||||
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) {
|
||||
throw new Error("remove cache-backed commands from configuration")
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
export async function remove(location: string, commands: readonly Info[]) {
|
||||
await unlink(target(location, commands))
|
||||
}
|
||||
|
||||
export * as CommandFiles from "./command-files"
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "@/kilocode/notebook/protocol"
|
||||
import { ModelUsage } from "@/kilocode/session/model-usage"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { CommandFiles } from "@/kilocode/command-files"
|
||||
|
||||
const root = "/kilocode"
|
||||
|
||||
@@ -31,6 +32,10 @@ export const RemoveSkillPayload = Schema.Struct({
|
||||
location: Schema.String,
|
||||
})
|
||||
|
||||
export const RemoveCommandPayload = Schema.Struct({
|
||||
location: Schema.String,
|
||||
})
|
||||
|
||||
export const RemoveAgentPayload = Schema.Struct({
|
||||
name: Schema.String,
|
||||
})
|
||||
@@ -47,6 +52,8 @@ export const AgentManagerRejectPayload = Schema.Struct({ error: AgentManagerFail
|
||||
export const KilocodePaths = {
|
||||
heapSnapshot: `${root}/heap/snapshot`,
|
||||
agentRequirements: `${root}/agent/requirements`,
|
||||
commandFiles: `${root}/command/files`,
|
||||
removeCommand: `${root}/command/remove`,
|
||||
removeSkill: `${root}/skill/remove`,
|
||||
removeAgent: `${root}/agent/remove`,
|
||||
notebookList: `${root}/notebook`,
|
||||
@@ -83,6 +90,28 @@ export const KilocodeApi = HttpApi.make("kilocode")
|
||||
description: "Check whether the selected agent's requirements are available in the request directory.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("commandFiles", KilocodePaths.commandFiles, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(CommandFiles.Info), "Command files"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "kilocode.commandFiles",
|
||||
summary: "List command files",
|
||||
description: "List commands with editable file locations for settings clients.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("removeCommand", KilocodePaths.removeCommand, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: RemoveCommandPayload,
|
||||
success: described(Schema.Boolean, "Command removed"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "kilocode.removeCommand",
|
||||
summary: "Remove a command",
|
||||
description: "Remove a command by deleting its markdown file from disk and clearing it from cache.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("removeSkill", KilocodePaths.removeSkill, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: RemoveSkillPayload,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
||||
import * as KiloAgent from "@/kilocode/agent"
|
||||
import { CommandFiles } from "@/kilocode/command-files"
|
||||
import * as KiloSkill from "@/kilocode/skill-remove"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Command } from "@/command"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { HeapSnapshot } from "@/kilocode/cli/heap-snapshot"
|
||||
@@ -21,12 +23,14 @@ import {
|
||||
NotebookRejectPayload,
|
||||
NotebookReplyPayload,
|
||||
RemoveAgentPayload,
|
||||
RemoveCommandPayload,
|
||||
RemoveSkillPayload,
|
||||
} from "../groups/kilocode"
|
||||
|
||||
export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
const commands = yield* Command.Service
|
||||
const skills = yield* Skill.Service
|
||||
const config = yield* Config.Service
|
||||
const store = yield* InstanceStore.Service
|
||||
@@ -43,6 +47,34 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode"
|
||||
return yield* agents.requirementStatus(ctx.query.agent)
|
||||
})
|
||||
|
||||
const commandFiles = Effect.fn("KilocodeHttpApi.commandFiles")(function* () {
|
||||
const instance = yield* InstanceState.context
|
||||
const dirs = yield* config.directories()
|
||||
const items = yield* commands.list()
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => CommandFiles.discover({ commands: items, directories: dirs, directory: instance.directory }),
|
||||
catch: (err) => err,
|
||||
}).pipe(Effect.catch((err) => Effect.die(err)))
|
||||
})
|
||||
|
||||
const removeCommand = Effect.fn("KilocodeHttpApi.removeCommand")(function* (ctx: {
|
||||
payload: typeof RemoveCommandPayload.Type
|
||||
}) {
|
||||
const instance = yield* InstanceState.context
|
||||
const dirs = yield* config.directories()
|
||||
const items = yield* commands.list()
|
||||
const entries = yield* Effect.tryPromise({
|
||||
try: () => CommandFiles.discover({ commands: items, directories: dirs, directory: instance.directory }),
|
||||
catch: (err) => err,
|
||||
}).pipe(Effect.catch((err) => Effect.die(err)))
|
||||
yield* Effect.tryPromise({
|
||||
try: () => CommandFiles.remove(ctx.payload.location, entries),
|
||||
catch: () => new HttpApiError.BadRequest({}),
|
||||
})
|
||||
yield* store.dispose(instance)
|
||||
return true
|
||||
})
|
||||
|
||||
const removeSkill = Effect.fn("KilocodeHttpApi.removeSkill")(function* (ctx: {
|
||||
payload: typeof RemoveSkillPayload.Type
|
||||
}) {
|
||||
@@ -136,6 +168,8 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode"
|
||||
return handlers
|
||||
.handle("heapSnapshot", heapSnapshot)
|
||||
.handle("agentRequirements", agentRequirements)
|
||||
.handle("commandFiles", commandFiles)
|
||||
.handle("removeCommand", removeCommand)
|
||||
.handle("removeSkill", removeSkill)
|
||||
.handle("removeAgent", removeAgent)
|
||||
.handle("notebookList", notebookList)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { CommandFiles } from "../../src/kilocode/command-files"
|
||||
import type { Command } from "../../src/command"
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.map((dir) => rm(dir, { recursive: true, force: true })))
|
||||
roots.length = 0
|
||||
})
|
||||
|
||||
async function temp() {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "kilo-command-files-"))
|
||||
roots.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
function cmd(input: Partial<Command.Info> & Pick<Command.Info, "name">): Command.Info {
|
||||
return {
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
source: input.source,
|
||||
template: input.template ?? "body",
|
||||
hints: input.hints ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
describe("CommandFiles", () => {
|
||||
test("discovers editable command files and read-only builtins", async () => {
|
||||
const dir = await temp()
|
||||
const file = path.join(dir, ".kilo", "command", "review.md")
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
await writeFile(file, "---\ndescription: Review code\n---\n\nReview $ARGUMENTS")
|
||||
|
||||
const items = await CommandFiles.discover({
|
||||
directory: dir,
|
||||
directories: [path.join(dir, ".kilo")],
|
||||
commands: [cmd({ name: "review", source: "command", hints: ["$ARGUMENTS"] }), cmd({ name: "init", source: "command" })],
|
||||
})
|
||||
|
||||
expect(items.map((item) => item.name)).toEqual(["review", "init"])
|
||||
expect(items[0]).toMatchObject({ name: "review", editable: true, builtin: false, location: file })
|
||||
expect(items[0].content).toContain("Review $ARGUMENTS")
|
||||
expect(items[1]).toMatchObject({ name: "init", editable: false, builtin: true, location: "builtin" })
|
||||
})
|
||||
|
||||
test("maps legacy workflows to editable commands", async () => {
|
||||
const dir = await temp()
|
||||
const file = path.join(dir, ".kilo", "workflows", "ship.md")
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
await writeFile(file, "# Ship\n\nRun release checks")
|
||||
|
||||
const items = await CommandFiles.discover({
|
||||
directory: dir,
|
||||
directories: [path.join(dir, ".kilo")],
|
||||
commands: [cmd({ name: "ship", source: "command", description: "Workflow: ship" })],
|
||||
})
|
||||
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toMatchObject({ name: "ship", editable: true, builtin: false, location: file })
|
||||
expect(items[0].content).toBe("# Ship\n\nRun release checks")
|
||||
})
|
||||
|
||||
test("remove only accepts known editable markdown files", async () => {
|
||||
const dir = await temp()
|
||||
const file = path.join(dir, ".kilo", "command", "ok.md")
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
await writeFile(file, "OK")
|
||||
const entries = [
|
||||
{ name: "ok", location: file, editable: true, builtin: false, hints: [] },
|
||||
{ name: "init", location: "builtin", editable: false, builtin: true, hints: [] },
|
||||
]
|
||||
|
||||
await expect(CommandFiles.remove("builtin", entries)).rejects.toThrow("absolute")
|
||||
await expect(CommandFiles.remove(path.join(dir, "other.md"), entries)).rejects.toThrow("not found")
|
||||
await CommandFiles.remove(file, entries)
|
||||
await expect(CommandFiles.remove(file, entries)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -183,6 +183,8 @@ import type {
|
||||
KilocodeAgentManagerReplyResponses,
|
||||
KilocodeAgentRequirementsErrors,
|
||||
KilocodeAgentRequirementsResponses,
|
||||
KilocodeCommandFilesErrors,
|
||||
KilocodeCommandFilesResponses,
|
||||
KilocodeHeapSnapshotErrors,
|
||||
KilocodeHeapSnapshotResponses,
|
||||
KilocodeNotebookListErrors,
|
||||
@@ -193,6 +195,8 @@ import type {
|
||||
KilocodeNotebookReplyResponses,
|
||||
KilocodeRemoveAgentErrors,
|
||||
KilocodeRemoveAgentResponses,
|
||||
KilocodeRemoveCommandErrors,
|
||||
KilocodeRemoveCommandResponses,
|
||||
KilocodeRemoveSkillErrors,
|
||||
KilocodeRemoveSkillResponses,
|
||||
KilocodeSessionImportMessageErrors,
|
||||
@@ -8038,6 +8042,81 @@ export class Kilocode extends HeyApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* List command files
|
||||
*
|
||||
* List commands with editable file locations for settings clients.
|
||||
*/
|
||||
public commandFiles<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<
|
||||
KilocodeCommandFilesResponses,
|
||||
KilocodeCommandFilesErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/kilocode/command/files",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a command
|
||||
*
|
||||
* Remove a command by deleting its markdown file from disk and clearing it from cache.
|
||||
*/
|
||||
public removeCommand<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
location?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
{ in: "body", key: "location" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<
|
||||
KilocodeRemoveCommandResponses,
|
||||
KilocodeRemoveCommandErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/kilocode/command/remove",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a skill
|
||||
*
|
||||
|
||||
@@ -3231,6 +3231,17 @@ export type AgentRequirementResult = {
|
||||
}
|
||||
}
|
||||
|
||||
export type CommandFile = {
|
||||
name: string
|
||||
description?: string
|
||||
source?: string
|
||||
builtin: boolean
|
||||
location: string
|
||||
editable: boolean
|
||||
content?: string
|
||||
hints: Array<string>
|
||||
}
|
||||
|
||||
export type NotebookOutput = {
|
||||
mime: string
|
||||
text?: string
|
||||
@@ -12538,6 +12549,64 @@ export type KilocodeAgentRequirementsResponses = {
|
||||
export type KilocodeAgentRequirementsResponse =
|
||||
KilocodeAgentRequirementsResponses[keyof KilocodeAgentRequirementsResponses]
|
||||
|
||||
export type KilocodeCommandFilesData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/kilocode/command/files"
|
||||
}
|
||||
|
||||
export type KilocodeCommandFilesErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: BadRequestError
|
||||
}
|
||||
|
||||
export type KilocodeCommandFilesError = KilocodeCommandFilesErrors[keyof KilocodeCommandFilesErrors]
|
||||
|
||||
export type KilocodeCommandFilesResponses = {
|
||||
/**
|
||||
* Command files
|
||||
*/
|
||||
200: Array<CommandFile>
|
||||
}
|
||||
|
||||
export type KilocodeCommandFilesResponse = KilocodeCommandFilesResponses[keyof KilocodeCommandFilesResponses]
|
||||
|
||||
export type KilocodeRemoveCommandData = {
|
||||
body?: {
|
||||
location: string
|
||||
}
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/kilocode/command/remove"
|
||||
}
|
||||
|
||||
export type KilocodeRemoveCommandErrors = {
|
||||
/**
|
||||
* BadRequest | InvalidRequestError
|
||||
*/
|
||||
400: EffectHttpApiErrorBadRequest | InvalidRequestError
|
||||
}
|
||||
|
||||
export type KilocodeRemoveCommandError = KilocodeRemoveCommandErrors[keyof KilocodeRemoveCommandErrors]
|
||||
|
||||
export type KilocodeRemoveCommandResponses = {
|
||||
/**
|
||||
* Command removed
|
||||
*/
|
||||
200: boolean
|
||||
}
|
||||
|
||||
export type KilocodeRemoveCommandResponse = KilocodeRemoveCommandResponses[keyof KilocodeRemoveCommandResponses]
|
||||
|
||||
export type KilocodeRemoveSkillData = {
|
||||
body?: {
|
||||
location: string
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user