mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
Merge pull request #12825 from Kilo-Org/rune-polo
feat(cli): add command file management parity with skills
This commit is contained in:
@@ -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<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 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<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 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"
|
||||
@@ -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,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<Command.Info> & Pick<Command.Info, "name">): 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()
|
||||
})
|
||||
})
|
||||
@@ -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 } })
|
||||
@@ -544,6 +551,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 })
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user