466644eb25
* fix: use .kilo instead of .kilocode for config directories * fix: keep global ~/.kilocode untouched for legacy CLI compat Only rename project-level .kilocode/ to .kilo/. The global ~/.kilocode directory must stay because legacy CLI instances and legacy-migration.ts in kilo-gateway still read from it. * test: update VS Code extension tests to use .kilo directory paths * fix: correct paths.ts comment and use specific git exclude entries - Fix comment referencing ~/.kilo when globalDir() returns ~/.kilocode - Revert to specific .kilo/ git exclude entries instead of blanket .kilo/ to avoid hiding user-authored rules/workflows/config from git status * fix: handle legacy per-worktree metadata and stale paths in state - readMetadata() falls back to .kilocode/ inside worktrees since the per-worktree metadata dirs aren't renamed by the top-level migration - Rewrite stale .kilocode/ paths in agent-manager.json on load * fix: run .kilocode migration at extension activation, not just Agent Manager Move migration call to the top of activate() so it runs for all users on every extension startup, before kilo serve is spawned or any code reads from the .kilo directory. * fix: update git worktree refs after .kilocode → .kilo rename After renaming the directory, git's internal .git/worktrees/*/gitdir files still reference the old .kilocode path. This causes git to lose track of worktrees, leading to crashes. Both the CLI and extension migration now rewrite these gitdir files after a successful rename. * fix: read from both .kilo and .kilocode, write to .kilo Replace the one-time directory rename migration with a dual-read strategy: - CLI config (rules, workflows, skills, MCP, project-id): read from both .kilo/ and .kilocode/ directories, with .kilo taking precedence - Agent Manager data (worktrees, state, setup scripts): migrate from .kilocode/ to .kilo/ at startup since the extension exclusively owns these - Config discovery (paths.ts, config.ts): include .kilocode in directory and agent/command pattern matching Key decisions: - .kilo/ is the new canonical write location for all new data - .kilocode/ is read as a legacy fallback (no data loss for existing users) - No directory rename: both dirs can coexist safely - Agent Manager migration is item-level (moves individual files), not a full directory rename, so it handles both-dirs-exist gracefully - Windows path rewrite in WorktreeStateManager handles both / and \ separators - Workflow/MCP load order: .kilocode first, .kilo second (last wins) - Rules dedup via seen-set with .kilo checked first (first wins) - Delete migrate-kilo-dir.ts (no longer needed) * fix: resolve .git file when fixing worktree refs during migration When the project root is itself a worktree, .git is a file pointing at the shared git dir, not a directory. Follow the gitdir pointer to find the actual .git/worktrees/ location. * fix: recover partial .kilo migrations and global dirs Always repair stale git worktree refs when .kilo worktrees already exist so partially migrated repos recover on startup. Also dual-read global skills, rules, and workflows from both legacy and new home directories. * fix: keep .kilo ahead of legacy config dirs * fix: narrow legacy agent manager excludes * chore: link migration cleanup follow-up
266 lines
7.9 KiB
TypeScript
266 lines
7.9 KiB
TypeScript
import { test, expect, describe } from "bun:test"
|
|
import { KilocodePaths } from "../../src/kilocode/paths"
|
|
import { tmpdir } from "../fixture/fixture"
|
|
import path from "path"
|
|
import fs from "fs/promises"
|
|
|
|
async function withHome<T>(home: string, fn: () => Promise<T>): Promise<T> {
|
|
const prev = process.env.HOME
|
|
process.env.HOME = home
|
|
try {
|
|
return await fn()
|
|
} finally {
|
|
if (prev) process.env.HOME = prev
|
|
else delete process.env.HOME
|
|
}
|
|
}
|
|
|
|
describe("KilocodePaths", () => {
|
|
describe("skillDirectories", () => {
|
|
test("discovers skills from .kilo/skills/", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const skillDir = path.join(dir, ".kilo", "skills", "test-skill")
|
|
await fs.mkdir(skillDir, { recursive: true })
|
|
await Bun.write(
|
|
path.join(skillDir, "SKILL.md"),
|
|
`---
|
|
name: test-skill
|
|
description: A test skill
|
|
---
|
|
# Test instructions`,
|
|
)
|
|
},
|
|
})
|
|
|
|
const result = await KilocodePaths.skillDirectories({
|
|
projectDir: tmp.path,
|
|
worktreeRoot: tmp.path,
|
|
skipGlobalPaths: true,
|
|
})
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0]).toEndWith(".kilo")
|
|
})
|
|
|
|
test("returns empty array when no .kilo/skills/ exists", async () => {
|
|
await using tmp = await tmpdir()
|
|
|
|
const result = await KilocodePaths.skillDirectories({
|
|
projectDir: tmp.path,
|
|
worktreeRoot: tmp.path,
|
|
skipGlobalPaths: true,
|
|
})
|
|
|
|
expect(result).toHaveLength(0)
|
|
})
|
|
|
|
test("discovers skills from nested .kilo directories", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
// Root level skill
|
|
const rootSkillDir = path.join(dir, ".kilo", "skills", "root-skill")
|
|
await fs.mkdir(rootSkillDir, { recursive: true })
|
|
await Bun.write(
|
|
path.join(rootSkillDir, "SKILL.md"),
|
|
`---
|
|
name: root-skill
|
|
description: Root level skill
|
|
---
|
|
# Root instructions`,
|
|
)
|
|
|
|
// Nested project skill
|
|
const nestedDir = path.join(dir, "packages", "nested")
|
|
const nestedSkillDir = path.join(nestedDir, ".kilo", "skills", "nested-skill")
|
|
await fs.mkdir(nestedSkillDir, { recursive: true })
|
|
await Bun.write(
|
|
path.join(nestedSkillDir, "SKILL.md"),
|
|
`---
|
|
name: nested-skill
|
|
description: Nested skill
|
|
---
|
|
# Nested instructions`,
|
|
)
|
|
},
|
|
})
|
|
|
|
// Run from nested directory, should find both
|
|
const nestedPath = path.join(tmp.path, "packages", "nested")
|
|
const result = await KilocodePaths.skillDirectories({
|
|
projectDir: nestedPath,
|
|
worktreeRoot: tmp.path,
|
|
skipGlobalPaths: true,
|
|
})
|
|
|
|
expect(result).toHaveLength(2)
|
|
expect(result.some((d) => d.includes("packages/nested"))).toBe(true)
|
|
expect(result.some((d) => !d.includes("packages/nested"))).toBe(true)
|
|
})
|
|
|
|
test("handles .kilo directory without skills subdirectory", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
// Create .kilo but not skills/
|
|
await fs.mkdir(path.join(dir, ".kilo"), { recursive: true })
|
|
await Bun.write(path.join(dir, ".kilo", "config.json"), "{}")
|
|
},
|
|
})
|
|
|
|
const result = await KilocodePaths.skillDirectories({
|
|
projectDir: tmp.path,
|
|
worktreeRoot: tmp.path,
|
|
skipGlobalPaths: true,
|
|
})
|
|
|
|
expect(result).toHaveLength(0)
|
|
})
|
|
|
|
test("handles symlinked skill directories", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
// Create actual skill in a different location
|
|
const actualDir = path.join(dir, "shared-skills", "my-skill")
|
|
await fs.mkdir(actualDir, { recursive: true })
|
|
await Bun.write(
|
|
path.join(actualDir, "SKILL.md"),
|
|
`---
|
|
name: my-skill
|
|
description: Symlinked skill
|
|
---
|
|
# Instructions`,
|
|
)
|
|
|
|
// Create .kilo/skills/ and symlink the skill
|
|
const skillsDir = path.join(dir, ".kilo", "skills")
|
|
await fs.mkdir(skillsDir, { recursive: true })
|
|
await fs.symlink(actualDir, path.join(skillsDir, "my-skill"))
|
|
},
|
|
})
|
|
|
|
const result = await KilocodePaths.skillDirectories({
|
|
projectDir: tmp.path,
|
|
worktreeRoot: tmp.path,
|
|
skipGlobalPaths: true,
|
|
})
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0]).toEndWith(".kilo")
|
|
})
|
|
|
|
test("discovers skills from legacy .kilocode/skills/", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const skillDir = path.join(dir, ".kilocode", "skills", "legacy-skill")
|
|
await fs.mkdir(skillDir, { recursive: true })
|
|
await Bun.write(
|
|
path.join(skillDir, "SKILL.md"),
|
|
`---
|
|
name: legacy-skill
|
|
description: A legacy skill
|
|
---
|
|
# Legacy instructions`,
|
|
)
|
|
},
|
|
})
|
|
|
|
const result = await KilocodePaths.skillDirectories({
|
|
projectDir: tmp.path,
|
|
worktreeRoot: tmp.path,
|
|
skipGlobalPaths: true,
|
|
})
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0]).toEndWith(".kilocode")
|
|
})
|
|
|
|
test("returns legacy skill dirs before .kilo so .kilo skills win", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
// .kilo skill
|
|
const kiloSkillDir = path.join(dir, ".kilo", "skills", "new-skill")
|
|
await fs.mkdir(kiloSkillDir, { recursive: true })
|
|
await Bun.write(path.join(kiloSkillDir, "SKILL.md"), "# New skill")
|
|
|
|
// .kilocode skill
|
|
const legacySkillDir = path.join(dir, ".kilocode", "skills", "old-skill")
|
|
await fs.mkdir(legacySkillDir, { recursive: true })
|
|
await Bun.write(path.join(legacySkillDir, "SKILL.md"), "# Old skill")
|
|
},
|
|
})
|
|
|
|
const result = await KilocodePaths.skillDirectories({
|
|
projectDir: tmp.path,
|
|
worktreeRoot: tmp.path,
|
|
skipGlobalPaths: true,
|
|
})
|
|
|
|
expect(result).toHaveLength(2)
|
|
expect(result[0]).toEndWith(".kilocode")
|
|
expect(result[1]).toEndWith(".kilo")
|
|
})
|
|
|
|
test("discovers global skills from ~/.kilo/skills/", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const skillDir = path.join(dir, ".kilo", "skills", "global-skill")
|
|
await fs.mkdir(skillDir, { recursive: true })
|
|
await Bun.write(path.join(skillDir, "SKILL.md"), "# Global skill")
|
|
await fs.mkdir(path.join(dir, "repo"), { recursive: true })
|
|
},
|
|
})
|
|
|
|
const result = await withHome(tmp.path, () =>
|
|
KilocodePaths.skillDirectories({
|
|
projectDir: path.join(tmp.path, "repo"),
|
|
worktreeRoot: path.join(tmp.path, "repo"),
|
|
}),
|
|
)
|
|
|
|
expect(result.some((d) => d.endsWith(".kilo"))).toBe(true)
|
|
})
|
|
|
|
test("discovers multiple skills in same directory", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const skillsDir = path.join(dir, ".kilo", "skills")
|
|
|
|
// First skill
|
|
const skill1 = path.join(skillsDir, "skill-one")
|
|
await fs.mkdir(skill1, { recursive: true })
|
|
await Bun.write(
|
|
path.join(skill1, "SKILL.md"),
|
|
`---
|
|
name: skill-one
|
|
description: First skill
|
|
---
|
|
# First`,
|
|
)
|
|
|
|
// Second skill
|
|
const skill2 = path.join(skillsDir, "skill-two")
|
|
await fs.mkdir(skill2, { recursive: true })
|
|
await Bun.write(
|
|
path.join(skill2, "SKILL.md"),
|
|
`---
|
|
name: skill-two
|
|
description: Second skill
|
|
---
|
|
# Second`,
|
|
)
|
|
},
|
|
})
|
|
|
|
const result = await KilocodePaths.skillDirectories({
|
|
projectDir: tmp.path,
|
|
worktreeRoot: tmp.path,
|
|
skipGlobalPaths: true,
|
|
})
|
|
|
|
// Should return the .kilo directory (not skills/ subdirectory)
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0]).toEndWith(".kilo")
|
|
})
|
|
})
|
|
})
|