Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/commands-settings-jetbrains.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"),
)
}

Expand Down Expand Up @@ -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<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"),
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(),
)
}

Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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<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, "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<String, String>): 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}")
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -238,6 +278,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) {
Expand All @@ -251,12 +296,28 @@ class KiloAgentBehaviorRpcApiImpl(private val backend: KiloBackendAppService? =
return path
}

private fun writableCommandPath(directory: String, location: String, known: Set<Path>, roots: 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(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 {
Expand All @@ -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<Path> = 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<Path>, 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<Path>, 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<Path>, 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<Path>): 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()) }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
val subtask: Boolean?,
)

data class SkillInfo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
]"""

Expand All @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading