kimi-code/packages/agent-core/test/skill/scanner.test.ts
liruifengv b51e13538d
ci: run unit tests on windows (#1037)
* ci: run unit tests on windows

* fix(migration-legacy): align workdir bucket key with agent-core

computeWorkdirBucket used a local node:path-based resolve that yields backslash-separated paths on Windows, while agent-core's encodeWorkDirKey uses pathe (forward slashes on every platform). The SHA-256 inputs diverged, so migrated sessions were written to a bucket that the session picker never reads, making them invisible on Windows.

Alias computeWorkdirBucket to encodeWorkDirKey so both sides stay byte-identical, drop the local slugify copy, and update the workdir-bucket test reference accordingly.

* test(acp-adapter): expect platform-native separators in e2e-fs path

The e2e-fs test asserted the fs/readTextFile wire path as the raw POSIX targetPath, but AcpKaos.toClientPath converts '/' to '\' when the inner LocalKaos reports pathClass 'win32' (Windows). On Windows the wire path became '\Users\test\x.ts' and the assertion failed.

Mirror toClientPath in the test: expect backslash separators on win32 and the raw path otherwise. Implementation is unchanged.

* test(sdk): normalize workDir and skillDir paths in session tests

SessionStore.create/list and the skill loader normalize paths through pathe (forward slashes). The SDK tests compared the resulting workDir and skill loaded-dir against raw mkdtemp / node:path strings, which use backslashes on Windows (and node:fs realpath also returns backslashes for the skill dir), failing three toMatchObject assertions.

Build the expected paths with agent-core's normalizeWorkDir so they match the internal pathe representation on every platform. The skill dir keeps its realpath() (the loader realpaths the root) and only normalizes separators.

* test(skill): normalize realpath to forward slashes in scanner tests

resolveSkillRoots normalizes every root.path through fs.realpath followed by replacing backslashes with forward slashes (scanner.ts). The scanner tests compared root.path against node:fs realpath directly, which returns backslashes on Windows, so twenty assertions failed (toEqual / toContain / toHaveLength) even though the resolved paths were identical.

Wrap realpath at the top of the test file to mirror the implementation's normalization, so every comparison uses the same forward-slash form on every platform.

* test: skip Unix-only permission tests on Windows

The Unix file-permission assertions (mode bits like 0o600 / 0o700 and chmod 000 making a path unreadable) have no equivalent on Windows, which uses ACLs; fs.chmod there can only toggle the read-only bit. These six tests failed on Windows with mismatched mode values or a missing 40411.

Skip them on win32 via it.skipIf(process.platform === 'win32'): oauth FileTokenStorage (0600 file, 0700 dir), agent-core BackgroundTaskPersistence (0700 tasks dir), agent-core createPerIdJsonStore (0700 subdir), migration-legacy atomicWrite (0600 file), and server fs:browse (chmod 000 -> 40411).

* test(tui): make platform-sensitive assertions cross-platform

The TUI implementations are already platform-aware (pathe-style paths, pathToFileURL, quoteShellArg cmd/POSIX quoting, Alt+V on Windows for paste expansion), but the tests hard-coded POSIX expectations and failed on Windows.

Align the assertions with the implementation's platform behavior: footer-goal-badge matches the '[goal' badge prefix instead of /goal/ (toolbar tips contain '/goal'); tool-call expects backslash relative paths on win32; plan-box builds the file:// URL via pathToFileURL; custom-editor sends Alt+V on win32 for paste expansion; file-mention-provider normalizes the expected description to forward slashes; kimi-tui-startup builds the resume command with quoteShellArg; kimi-tui-message-flow builds the expected install path with resolve().

* test: align path assertions with pathe on Windows

Several test suites asserted paths produced by node:path/node:os/node:fs against values that agent-core, node-sdk and kaos normalize through pathe (forward slashes). On Windows the two forms diverge (backslashes vs forward slashes), failing about 19 assertions.

Mirror the implementation's normalization in the assertions via a local toPosix helper (or agent-core's normalizeWorkDir), so expected paths use forward slashes on every platform: kaos LocalKaos, node-sdk export/list/resume/config/transport sessions, cli FileMentionProvider, and agent-core skill-session.

* test(native): build path expectations with node:path.resolve

paths.mjs builds every path with node:path.resolve, which yields backslash-separated absolute paths on Windows. The path-helpers tests asserted against template strings that mixed the backslash appRoot with forward-slash segments, so Object.is failed on Windows even though the strings looked identical.

Build the expectations with the same resolve(appRoot, ...) helper so the separators match on every platform.

* fix: make Windows CI tests pass across all packages

Fix the remaining Windows CI failures so the Windows test job can go green. The changes fall into a few categories:

- Path separators: agent-core/node-sdk/kaos normalize paths via pathe (forward slashes); align test expectations and a couple of implementations (native cache base, workspace registry) with that.

- Platform-only services: skip launchd/systemd manager suites on win32 (Windows uses schtasks).

- Process/signal lifecycle: skip or relax tests that rely on POSIX signals / SIGTERM semantics that Windows does not support.

- Hook shell syntax: rewrite hook test commands from POSIX shell (single quotes, semicolons, stderr redirects, if/then/fi) to node -e / .cjs files that run under cmd.exe.

- CRLF: make Bash tool description stripping tolerate CRLF line endings.

- Misc: realpath short-name divergence, port-retry timing, telemetry spawn, fs-watch timing, snapshot path normalization, etc.

* fix: remove unused basename import in workspaceRegistryService

Fix lint error (no-unused-vars): basename from node:path is no longer used after switching to posixBasename from pathe.

* fix: align resume harness pathClass and wait for banner state on Windows

Two more Windows CI fixes:

- createResumeNoSideEffectKaos now reports pathClass 'win32' on Windows so tool descriptions (e.g. Glob's Windows note) match the live agent in expectResumeMatches, fixing usage/description deep-equal drift.

- kimi-tui-startup once-banner test now waits for writeBannerDisplayState to land before asserting, since the atomic write can lag behind the render on Windows.

* fix: resolve remaining Windows unit test failures

Make the new Windows CI job green across agent-core, kaos, node-sdk and server:

- Align the resume harness kaos pathClass with the live agent so platform-conditional tool descriptions (Glob's Windows note) match in expectResumeMatches instead of drifting on win32.
- Rewrite hook commands in agent-core tests as cross-platform node one-liners; single-quote echo, >&2 and ';' do not work under cmd.exe.
- Add .gitattributes enforcing LF so raw-imported templates (e.g. the compaction instruction) produce byte-identical token counts on Windows and POSIX.
- Terminate the full process tree on Windows in both the hook runner and kaos (taskkill /T /F) so grandchildren cannot outlive their parent and keep the cwd locked.
- Normalize workDir path separators in two kimi-sdk session tests to match the stored canonical form.
- Avoid cmd.exe arg-quoting pitfalls in the kaos cmd.exe test, and run the Windows process-tree kill test from a script file with the pid path passed via argv.
- Give the first fs-git e2e test more time on Windows and retry the temp-dir cleanup; skip the fs-watch overflow-burst assertion on Windows where fs-event coalescing prevents the single-window spike.

* ci: retrigger checks

* fix: resolve remaining Windows failures after merging main

- Terminate the spawned git/gh process tree on Windows in FsGitService (taskkill /T /F on timeout) so a timed-out 'gh pr view' cannot leave a grandchild holding the workspace cwd, which made the fs-git e2e cleanup fail with EPERM.

- Give the fs:git_status e2e suite a longer timeout on Windows and retry the temp-dir cleanup longer to ride out the slower child-process teardown.

- Make the third-party plugin install trust test assert the resolved install path via node:path so it matches the Windows-resolved path (D:\tmp\...) as well as the POSIX one.

* fix: align workspace registry roots and harden fs-git cleanup on Windows

- workspace-registry test: compare normalized (forward-slash) roots, since the registry and session index both store workDir via pathe.resolve (forward slashes on every platform). realpath() yields backslashes on Windows and diverged from the stored root.

- fs-git e2e: bump the temp-dir cleanup retries and the afterEach timeout, since Windows child-process teardown after server.close() is asynchronous and can keep the workspace cwd locked for several seconds.

* test: stub openUrl in kimi-tui-message-flow feedback tests

The /feedback command falls back to openUrl(FEEDBACK_ISSUE_URL) when submission fails, which spawned a real browser window on every test run. Mock #/utils/open-url (matching the existing login/message-replay/server test convention) so the suite never opens a browser.

* test: harden fs-git e2e cleanup against Windows cwd locks

On Windows, git/gh child processes and the session core process can outlive server.close() and keep the temp workspace as their cwd, so rmSync fails with EPERM even after a long retry. Add rmSyncRobust that retries and, if the cwd is still locked, swallows EPERM/EBUSY on Windows — the OS reclaims the temp dir and a cleanup hiccup must not fail an otherwise-passing test.

* test: harden server e2e cleanup against async teardown races

server.close() does not fully await the server's asynchronous teardown, so on a loaded CI runner the temp home/workspace dirs can still be held or written to when the afterEach rmSync runs, failing with EPERM (Windows) or ENOTEMPTY (Linux). Use a rmSyncRobust helper (retry + swallow EPERM/EBUSY/ENOTEMPTY) in the fs-git and question e2e cleanup. Also fix a leftover `throw err` (renamed to `throw error`) that broke the typecheck.
2026-06-26 11:56:41 +08:00

1391 lines
48 KiB
TypeScript

import { mkdtemp, mkdir, realpath as fsRealpath, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'pathe';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { discoverSkills, resolveSkillRoots, SessionSkillRegistry, type SkillRoot } from '../../src/skill';
// Mirror `resolveSkillRoots`' internal realpath (fs.realpath + forward-slash
// normalization, see scanner.ts) so `root.path` comparisons hold on Windows,
// where `node:fs.realpath` returns backslashes. Every test in this file
// compares against `root.path`, so the helper must match that exact form.
async function realpath(p: string): Promise<string> {
return (await fsRealpath(p)).replaceAll('\\', '/');
}
const tempDirs: string[] = [];
afterEach(async () => {
vi.unstubAllEnvs();
for (const dir of tempDirs.splice(0)) {
await rm(dir, { recursive: true, force: true });
}
});
describe('skill discovery', () => {
it('resolves documented roots in precedence order with brand merging enabled by default', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
await mkdir(path.join(repoDir, '.kimi-code', 'skills'), { recursive: true });
await mkdir(path.join(repoDir, '.agents', 'skills'), { recursive: true });
await mkdir(path.join(homeDir, '.kimi-code', 'skills'), { recursive: true });
await mkdir(path.join(homeDir, '.agents', 'skills'), { recursive: true });
await mkdir(path.join(repoDir, 'team-skills'), { recursive: true });
const realRepoDir = await realpath(repoDir);
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
extraDirs: ['team-skills'],
});
expect(roots.map((root) => path.relative(realRepoDir, root.path))).toEqual([
'.kimi-code/skills',
'.agents/skills',
path.relative(realRepoDir, await realpath(path.join(homeDir, '.kimi-code', 'skills'))),
path.relative(realRepoDir, await realpath(path.join(homeDir, '.agents', 'skills'))),
'team-skills',
]);
expect(roots.map((root) => root.source)).toEqual([
'project',
'project',
'user',
'user',
'extra',
]);
});
it('uses only the first brand directory when brand merging is disabled', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
await mkdir(path.join(repoDir, '.kimi-code', 'skills'), { recursive: true });
await mkdir(path.join(homeDir, '.kimi-code', 'skills'), { recursive: true });
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
mergeAllAvailableSkills: false,
});
expect(roots.map((root) => root.path)).toEqual([
await realpath(path.join(repoDir, '.kimi-code', 'skills')),
await realpath(path.join(homeDir, '.kimi-code', 'skills')),
]);
});
it('lets explicit skill dirs replace automatic project and user discovery', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
await mkdir(path.join(repoDir, '.kimi-code', 'skills'), { recursive: true });
await mkdir(path.join(homeDir, '.kimi-code', 'skills'), { recursive: true });
await mkdir(path.join(repoDir, 'explicit-skills'), { recursive: true });
await mkdir(path.join(repoDir, 'extra-skills'), { recursive: true });
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
explicitDirs: ['explicit-skills'],
extraDirs: ['extra-skills'],
});
const realRepoDir = await realpath(repoDir);
expect(roots.map((root) => [path.relative(realRepoDir, root.path), root.source])).toEqual([
['explicit-skills', 'user'],
['extra-skills', 'extra'],
]);
});
it('discovers flat markdown skills, keeps directory skills over same-name flat files, and preserves source precedence', async () => {
const { homeDir, repoDir } = await makeWorkspace();
const projectRoot = path.join(repoDir, '.kimi-code', 'skills');
const userRoot = path.join(homeDir, '.kimi-code', 'skills');
await writeSkill(projectRoot, 'review.md', ['Project review body first line.', '', 'Details.']);
await writeSkill(userRoot, path.join('review', 'SKILL.md'), [
'---',
'name: review',
'description: User review',
'---',
'',
'User review body.',
]);
await writeSkill(projectRoot, path.join('deploy', 'SKILL.md'), [
'---',
'name: deploy',
'description: Directory deploy',
'---',
'',
'Deploy body.',
]);
await writeSkill(projectRoot, 'deploy.md', ['Flat deploy should be ignored.']);
const warnings: string[] = [];
const roots: SkillRoot[] = [
{ path: projectRoot, source: 'project' },
{ path: userRoot, source: 'user' },
];
const skills = await discoverSkills({
roots,
onWarning: (message) => warnings.push(message),
});
expect(skills.map((skill) => [skill.name, skill.description, skill.source])).toEqual([
['deploy', 'Directory deploy', 'project'],
['review', 'Project review body first line.', 'project'],
]);
expect(warnings.some((message) => message.includes('Ignoring flat skill'))).toBe(true);
});
it('keeps flow skills user-visible while excluding them from model invocation', async () => {
const { repoDir } = await makeWorkspace();
const projectRoot = path.join(repoDir, '.kimi-code', 'skills');
await writeSkill(projectRoot, path.join('review-flow', 'SKILL.md'), [
'---',
'name: review-flow',
'description: Review flow',
'type: flow',
'---',
'',
'```mermaid',
'flowchart TD',
'BEGIN --> END',
'```',
]);
const registry = new SessionSkillRegistry();
await registry.loadRoots([{ path: projectRoot, source: 'project' }]);
expect(registry.listSkills().map((skill) => skill.name)).toEqual(['review-flow']);
expect(registry.listInvocableSkills()).toEqual([]);
});
it('skips directory skills with missing frontmatter metadata', async () => {
const { repoDir } = await makeWorkspace();
const projectRoot = path.join(repoDir, '.kimi-code', 'skills');
await writeSkill(projectRoot, path.join('valid', 'SKILL.md'), [
'---',
'name: valid',
'description: Valid skill',
'---',
'',
'Valid body.',
]);
await writeSkill(projectRoot, path.join('no-frontmatter', 'SKILL.md'), [
'# Heading should not become a description',
'',
'Body.',
]);
await writeSkill(projectRoot, path.join('missing-name', 'SKILL.md'), [
'---',
'description: Missing name',
'---',
'',
'Body.',
]);
await writeSkill(projectRoot, path.join('missing-description', 'SKILL.md'), [
'---',
'name: missing-description',
'---',
'',
'# Heading should not become a description',
]);
const warnings: string[] = [];
const skills = await discoverSkills({
roots: [{ path: projectRoot, source: 'project' }],
onWarning: (message) => warnings.push(message),
});
expect(skills.map((skill) => skill.name)).toEqual(['valid']);
expect(warnings).toHaveLength(3);
expect(warnings.some((message) => message.includes('Missing frontmatter'))).toBe(true);
expect(warnings.some((message) => message.includes('"name"'))).toBe(true);
expect(warnings.some((message) => message.includes('"description"'))).toBe(true);
});
});
describe('discoverSkills shape and ordering', () => {
it('parses frontmatter name/description for subdir skills', async () => {
const { repoDir } = await makeWorkspace();
const root = path.join(repoDir, '.kimi-code', 'skills');
await writeSkill(root, path.join('alpha', 'SKILL.md'), [
'---',
'name: alpha-skill',
'description: Alpha description',
'---',
]);
const skills = await discoverSkills({ roots: [{ path: root, source: 'user' }] });
expect(skills.map((skill) => [skill.name, skill.description, skill.source])).toEqual([
['alpha-skill', 'Alpha description', 'user'],
]);
});
it('keeps the first roots version when a skill name appears in multiple roots', async () => {
const { repoDir } = await makeWorkspace();
const rootA = path.join(repoDir, 'root_a');
const rootB = path.join(repoDir, 'root_b');
await writeSkill(rootA, path.join('greet', 'SKILL.md'), [
'---',
'name: greet',
'description: A',
'---',
'Hello from A',
]);
await writeSkill(rootB, path.join('greet', 'SKILL.md'), [
'---',
'name: greet',
'description: B',
'---',
'Hello from B',
]);
const skills = await discoverSkills({
roots: [
{ path: rootA, source: 'builtin' },
{ path: rootB, source: 'user' },
],
});
expect(skills).toHaveLength(1);
expect(skills[0]?.description).toBe('A');
});
it('does not register a top-level SKILL.md as a flat skill', async () => {
const { repoDir } = await makeWorkspace();
const root = path.join(repoDir, '.kimi-code', 'skills');
await mkdir(root, { recursive: true });
await writeFile(
path.join(root, 'SKILL.md'),
['---', 'name: not-a-skill', 'description: accidental', '---'].join('\n'),
);
const skills = await discoverSkills({ roots: [{ path: root, source: 'user' }] });
expect(skills).toEqual([]);
});
it('lists flat skills with frontmatter name and description', async () => {
const { repoDir } = await makeWorkspace();
const root = path.join(repoDir, '.kimi-code', 'skills');
await mkdir(root, { recursive: true });
await writeFile(
path.join(root, 'demo-ui-components.md'),
['---', 'name: demo-ui-components', 'description: Demo UI', '---', 'Body'].join('\n'),
);
const skills = await discoverSkills({ roots: [{ path: root, source: 'user' }] });
expect(skills).toHaveLength(1);
expect(skills[0]?.name).toBe('demo-ui-components');
expect(skills[0]?.description).toBe('Demo UI');
});
it('discovers both flat and subdir skills alongside in the same root', async () => {
const { repoDir } = await makeWorkspace();
const root = path.join(repoDir, '.kimi-code', 'skills');
await writeSkill(root, path.join('subdir-skill', 'SKILL.md'), [
'---',
'name: subdir-skill',
'description: From subdir',
'---',
]);
await mkdir(root, { recursive: true });
await writeFile(
path.join(root, 'flat-skill.md'),
['---', 'name: flat-skill', 'description: From flat', '---'].join('\n'),
);
const skills = await discoverSkills({ roots: [{ path: root, source: 'user' }] });
expect(skills.map((s) => s.name).toSorted()).toEqual(['flat-skill', 'subdir-skill']);
});
it('prefers the subdir version when a flat and subdir skill share a name', async () => {
const { repoDir } = await makeWorkspace();
const root = path.join(repoDir, '.kimi-code', 'skills');
await writeSkill(root, path.join('greet', 'SKILL.md'), [
'---',
'name: greet',
'description: From subdir',
'---',
]);
await mkdir(root, { recursive: true });
await writeFile(
path.join(root, 'greet.md'),
['---', 'name: greet', 'description: From flat', '---'].join('\n'),
);
const skills = await discoverSkills({ roots: [{ path: root, source: 'user' }] });
expect(skills).toHaveLength(1);
expect(skills[0]?.description).toBe('From subdir');
});
it('discovers skills nested in subdirectories at multiple depths', async () => {
const { repoDir } = await makeWorkspace();
const root = path.join(repoDir, '.kimi-code', 'skills');
await writeSkill(root, path.join('top', 'SKILL.md'), [
'---',
'name: top',
'description: Top level',
'---',
]);
await writeSkill(root, path.join('group', 'alpha', 'SKILL.md'), [
'---',
'name: alpha',
'description: One level deep',
'---',
]);
await writeSkill(root, path.join('a', 'b', 'beta', 'SKILL.md'), [
'---',
'name: beta',
'description: Two levels deep',
'---',
]);
// A loose .md below the top level is skill payload, not a skill.
await writeFile(path.join(root, 'group', 'notes.md'), 'just some notes');
const skills = await discoverSkills({ roots: [{ path: root, source: 'user' }] });
expect(skills.map((s) => s.name)).toEqual(['alpha', 'beta', 'top']);
});
it('discovers nested SKILL.md files inside a skill bundle when has-sub-skill is declared', async () => {
const { repoDir } = await makeWorkspace();
const root = path.join(repoDir, '.kimi-code', 'skills');
await writeSkill(root, path.join('outer', 'SKILL.md'), [
'---',
'name: outer',
'description: Parent skill',
'has-sub-skill: true',
'---',
'',
'Outer body.',
]);
await writeSkill(root, path.join('outer', 'references', 'inner', 'SKILL.md'), [
'---',
'name: inner',
'description: Nested skill',
'---',
'',
'Inner body should stay private until activation.',
]);
await writeFile(
path.join(root, 'outer', 'references', 'notes.md'),
'Nested payload should not be listed.',
);
const skills = await discoverSkills({ roots: [{ path: root, source: 'user' }] });
expect(skills.map((s) => s.name)).toEqual(['outer', 'outer.inner']);
expect(skills.find((s) => s.name === 'outer')?.metadata.isSubSkill).toBeUndefined();
expect(skills.find((s) => s.name === 'outer.inner')?.metadata.isSubSkill).toBe(true);
});
it('does not discover nested SKILL.md files when the parent bundle disables sub-skills', async () => {
const { repoDir } = await makeWorkspace();
const root = path.join(repoDir, '.kimi-code', 'skills');
await writeSkill(root, path.join('outer', 'SKILL.md'), [
'---',
'name: outer',
'description: Parent skill',
'---',
'',
'Outer body.',
]);
await writeSkill(root, path.join('outer', 'references', 'inner', 'SKILL.md'), [
'---',
'name: inner',
'description: Nested skill',
'---',
'',
'Inner body should stay private until activation.',
]);
const skills = await discoverSkills({ roots: [{ path: root, source: 'user' }] });
expect(skills.map((s) => s.name)).toEqual(['outer']);
});
it('discovers nested SKILL.md files when has-sub-skill is nested under metadata', async () => {
const { repoDir } = await makeWorkspace();
const root = path.join(repoDir, '.kimi-code', 'skills');
await writeSkill(root, path.join('outer', 'SKILL.md'), [
'---',
'name: outer',
'description: Parent skill',
'metadata:',
' has-sub-skill: true',
'---',
'',
'Outer body.',
]);
await writeSkill(root, path.join('outer', 'inner', 'SKILL.md'), [
'---',
'name: outer.inner',
'description: Nested skill',
'---',
'',
'Inner body.',
]);
const skills = await discoverSkills({ roots: [{ path: root, source: 'user' }] });
expect(skills.map((s) => s.name)).toEqual(['outer', 'outer.inner']);
});
it('discovers declared sub-skills without a feature flag', async () => {
const { repoDir } = await makeWorkspace();
const root = path.join(repoDir, '.kimi-code', 'skills');
await writeSkill(root, path.join('outer', 'SKILL.md'), [
'---',
'name: outer',
'description: Parent skill',
'has-sub-skill: true',
'---',
'',
'Outer body.',
]);
await writeSkill(root, path.join('outer', 'inner', 'SKILL.md'), [
'---',
'name: outer.inner',
'description: Nested skill',
'---',
'',
'Inner body.',
]);
const skills = await discoverSkills({
roots: [{ path: root, source: 'user' }],
});
expect(skills.map((s) => s.name)).toEqual(['outer', 'outer.inner']);
});
it('skips node_modules when scanning nested directories', async () => {
const { repoDir } = await makeWorkspace();
const root = path.join(repoDir, '.kimi-code', 'skills');
await writeSkill(root, path.join('real', 'SKILL.md'), [
'---',
'name: real',
'description: A real skill',
'---',
]);
await writeSkill(root, path.join('node_modules', 'pkg', 'vendored', 'SKILL.md'), [
'---',
'name: vendored',
'description: Should never load',
'---',
]);
const skills = await discoverSkills({ roots: [{ path: root, source: 'user' }] });
expect(skills.map((s) => s.name)).toEqual(['real']);
});
it('stops recursing past the maximum scan depth', async () => {
const { repoDir } = await makeWorkspace();
const root = path.join(repoDir, '.kimi-code', 'skills');
await writeSkill(root, path.join('shallow', 'SKILL.md'), [
'---',
'name: shallow',
'description: Near the root',
'---',
]);
const deepSegments = Array.from({ length: 11 }, (_, i) => `lvl-${i}`);
await writeSkill(root, path.join(...deepSegments, 'deep', 'SKILL.md'), [
'---',
'name: deep',
'description: Far below the depth cap',
'---',
]);
const skills = await discoverSkills({ roots: [{ path: root, source: 'user' }] });
expect(skills.map((s) => s.name)).toEqual(['shallow']);
});
it('prefers a shallower skill over a deeper one with the same name', async () => {
const { repoDir } = await makeWorkspace();
const root = path.join(repoDir, '.kimi-code', 'skills');
await writeSkill(root, path.join('dup', 'SKILL.md'), [
'---',
'name: dup',
'description: Shallow wins',
'---',
]);
await writeSkill(root, path.join('group', 'dup', 'SKILL.md'), [
'---',
'name: dup',
'description: Deep loses',
'---',
]);
const skills = await discoverSkills({ roots: [{ path: root, source: 'user' }] });
expect(skills).toHaveLength(1);
expect(skills[0]?.description).toBe('Shallow wins');
});
it('resolves a same-name collision across sibling directories deterministically', async () => {
const { repoDir } = await makeWorkspace();
const root = path.join(repoDir, '.kimi-code', 'skills');
// Created out of alphabetical order so the result cannot depend on
// filesystem readdir order; the alphabetically-first sibling must win.
await writeSkill(root, path.join('group-b', 'dup', 'SKILL.md'), [
'---',
'name: dup',
'description: From group-b',
'---',
]);
await writeSkill(root, path.join('group-a', 'dup', 'SKILL.md'), [
'---',
'name: dup',
'description: From group-a',
'---',
]);
const skills = await discoverSkills({ roots: [{ path: root, source: 'user' }] });
expect(skills).toHaveLength(1);
expect(skills[0]?.description).toBe('From group-a');
});
it('returns an empty list when the skill root cannot be read', async () => {
const { repoDir } = await makeWorkspace();
const root = path.join(repoDir, 'locked');
await mkdir(root, { recursive: true });
const skills = await discoverSkills({
roots: [{ path: root, source: 'extra' }],
readdir: async () => {
throw Object.assign(new Error('simulated'), { code: 'EACCES' });
},
});
expect(skills).toEqual([]);
});
});
describe('resolveSkillRoots ordering and priority', () => {
it('returns project then user then builtin in priority order', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const userGeneric = path.join(homeDir, '.agents', 'skills');
await mkdir(userGeneric, { recursive: true });
const projectGeneric = path.join(repoDir, '.agents', 'skills');
await mkdir(projectGeneric, { recursive: true });
const builtin = path.join(repoDir, 'builtin');
await mkdir(builtin, { recursive: true });
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
builtinDir: builtin,
});
expect(roots.map((root) => root.source)).toEqual(['project', 'user', 'builtin']);
});
it('treats empty explicit dirs identically to omitting them', async () => {
const { homeDir, workDir } = await makeWorkspace();
const omitted = await resolveSkillRoots({ paths: { userHomeDir: homeDir, workDir } });
const empty = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
explicitDirs: [],
});
expect(empty).toEqual(omitted);
});
it('returns no user roots when no skill dirs exist in the home directory', async () => {
const { homeDir, workDir } = await makeWorkspace();
const roots = await resolveSkillRoots({ paths: { userHomeDir: homeDir, workDir } });
expect(roots.filter((r) => r.source === 'user')).toEqual([]);
});
it('prefers brand over generic user dirs even when both contain skills with the same name', async () => {
const { homeDir, workDir } = await makeWorkspace();
const generic = path.join(homeDir, '.agents', 'skills');
await writeSkill(generic, path.join('greet', 'SKILL.md'), [
'---',
'name: greet',
'description: generic version',
'---',
]);
const brand = path.join(homeDir, '.kimi-code', 'skills');
await writeSkill(brand, path.join('greet', 'SKILL.md'), [
'---',
'name: greet',
'description: brand version',
'---',
]);
const roots = await resolveSkillRoots({ paths: { userHomeDir: homeDir, workDir } });
const skills = await discoverSkills({ roots });
expect(skills).toHaveLength(1);
expect(skills[0]?.description).toBe('brand version');
});
it('returns proj-brand, proj-generic, user-brand, user-generic, builtin in that order without merging', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const userBrand = path.join(homeDir, '.kimi-code', 'skills');
const userGeneric = path.join(homeDir, '.agents', 'skills');
const projBrand = path.join(repoDir, '.kimi-code', 'skills');
const projGeneric = path.join(repoDir, '.agents', 'skills');
const builtin = path.join(repoDir, 'builtin');
for (const d of [userBrand, userGeneric, projBrand, projGeneric, builtin]) {
await mkdir(d, { recursive: true });
}
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
builtinDir: builtin,
mergeAllAvailableSkills: false,
});
expect(roots.map((r) => r.path)).toEqual([
await realpath(projBrand),
await realpath(projGeneric),
await realpath(userBrand),
await realpath(userGeneric),
await realpath(builtin),
]);
});
it('keeps brand user skills visible when the generic group is empty', async () => {
const { homeDir, workDir } = await makeWorkspace();
const generic = path.join(homeDir, '.config', 'agents', 'skills');
await mkdir(generic, { recursive: true });
const brand = path.join(homeDir, '.kimi-code', 'skills');
await writeSkill(brand, path.join('deploy', 'SKILL.md'), [
'---',
'name: deploy',
'description: Deploy to prod',
'---',
]);
const roots = await resolveSkillRoots({ paths: { userHomeDir: homeDir, workDir } });
const skills = await discoverSkills({ roots });
expect(skills.map((s) => s.name)).toContain('deploy');
});
it('defaults to merging user brand dirs', async () => {
const { homeDir, workDir } = await makeWorkspace();
await mkdir(path.join(homeDir, '.kimi-code', 'skills'), { recursive: true });
const roots = await resolveSkillRoots({ paths: { userHomeDir: homeDir, workDir } });
const paths = roots.map((r) => r.path);
expect(paths).toContain(await realpath(path.join(homeDir, '.kimi-code', 'skills')));
});
});
describe('resolveSkillRoots brand home (KIMI_CODE_HOME)', () => {
it('resolves the user brand skills root under brandHomeDir while keeping generic under the real home', async () => {
const { homeDir, workDir } = await makeWorkspace();
const brandHomeDir = path.join(homeDir, '..', 'brand-home');
await mkdir(path.join(brandHomeDir, 'skills'), { recursive: true });
await mkdir(path.join(homeDir, '.agents', 'skills'), { recursive: true });
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, brandHomeDir, workDir },
});
const userRoots = roots.filter((r) => r.source === 'user').map((r) => r.path);
expect(userRoots).toContain(await realpath(path.join(brandHomeDir, 'skills')));
expect(userRoots).toContain(await realpath(path.join(homeDir, '.agents', 'skills')));
});
it('never nests a second .kimi-code under the brand home', async () => {
const { homeDir, workDir } = await makeWorkspace();
// Mirrors the default case where brandHomeDir already IS the ~/.kimi-code dir.
const brandHomeDir = path.join(homeDir, '.kimi-code');
await mkdir(path.join(brandHomeDir, 'skills'), { recursive: true });
// The doubled-prefix path that must never be selected.
await mkdir(path.join(brandHomeDir, '.kimi-code', 'skills'), { recursive: true });
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, brandHomeDir, workDir },
});
const userRoots = roots.filter((r) => r.source === 'user').map((r) => r.path);
expect(userRoots).toContain(await realpath(path.join(brandHomeDir, 'skills')));
expect(userRoots).not.toContain(
await realpath(path.join(brandHomeDir, '.kimi-code', 'skills')),
);
});
it('falls back to <userHomeDir>/.kimi-code/skills when brandHomeDir is omitted', async () => {
const { homeDir, workDir } = await makeWorkspace();
await mkdir(path.join(homeDir, '.kimi-code', 'skills'), { recursive: true });
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
});
const userRoots = roots.filter((r) => r.source === 'user').map((r) => r.path);
expect(userRoots).toContain(await realpath(path.join(homeDir, '.kimi-code', 'skills')));
});
});
describe('resolveSkillRoots extra dirs', () => {
it('appends extra dirs to the resolved roots', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const extra = path.join(repoDir, 'my-extra');
await mkdir(extra, { recursive: true });
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
extraDirs: [extra],
});
expect(roots.map((r) => r.path)).toContain(await realpath(extra));
});
it('expands a leading ~/ in extra dirs against the user home directory', async () => {
const { homeDir, workDir } = await makeWorkspace();
const target = path.join(homeDir, 'my-skills');
await mkdir(target, { recursive: true });
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
extraDirs: ['~/my-skills'],
});
expect(roots.map((r) => r.path)).toContain(await realpath(target));
});
it('resolves a relative extra dir against the project root (.git ancestor), not the work dir', async () => {
const { homeDir, repoDir } = await makeWorkspace();
const nested = path.join(repoDir, 'sub', 'dir');
await mkdir(nested, { recursive: true });
const extraAtRoot = path.join(repoDir, 'my-dir');
await mkdir(extraAtRoot, { recursive: true });
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir: nested },
extraDirs: ['my-dir'],
});
const paths = roots.map((r) => r.path);
expect(paths).toContain(await realpath(extraAtRoot));
expect(paths).not.toContain(path.join(nested, 'my-dir'));
});
it('uses absolute extra-dir paths as-is', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const absExtra = path.join(repoDir, '..', 'somewhere-else');
await mkdir(absExtra, { recursive: true });
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
extraDirs: [absExtra],
});
expect(roots.map((r) => r.path)).toContain(await realpath(absExtra));
});
it('silently drops missing extra-dir entries', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const real = path.join(repoDir, 'real');
await mkdir(real, { recursive: true });
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
extraDirs: [real, path.join(repoDir, 'nowhere'), path.join(repoDir, 'nope')],
});
const paths = roots.map((r) => r.path);
expect(paths).toContain(await realpath(real));
expect(paths).not.toContain(path.join(repoDir, 'nowhere'));
});
it('deduplicates duplicate entries in extra dirs', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const real = path.join(repoDir, 'real');
await mkdir(real, { recursive: true });
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
extraDirs: [real, real],
});
const realResolved = await realpath(real);
const matches = roots.filter((r) => r.path === realResolved);
expect(matches).toHaveLength(1);
});
it('preserves plugin metadata when a plugin skill root duplicates an extra dir', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const real = path.join(repoDir, 'real');
await mkdir(real, { recursive: true });
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
extraDirs: [real],
pluginSkillRoots: [
{
path: real,
source: 'extra',
plugin: {
id: 'superpowers',
instructions: 'Use AskUserQuestion.',
},
},
],
});
const realResolved = await realpath(real);
const matches = roots.filter((r) => r.path === realResolved);
expect(matches).toHaveLength(1);
expect(matches[0]?.plugin).toEqual({
id: 'superpowers',
instructions: 'Use AskUserQuestion.',
});
});
it('keeps plugin-specific skill lookup when a project skill has the same name', async () => {
const { repoDir } = await makeWorkspace();
const projectRoot = path.join(repoDir, '.kimi-code', 'skills');
const pluginRoot = path.join(repoDir, 'plugin-skills');
await writeSkill(projectRoot, path.join('using-superpowers', 'SKILL.md'), [
'---',
'name: using-superpowers',
'description: Project override',
'---',
'',
'project body',
]);
await writeSkill(pluginRoot, path.join('using-superpowers', 'SKILL.md'), [
'---',
'name: using-superpowers',
'description: Plugin startup',
'---',
'',
'plugin body',
]);
const registry = new SessionSkillRegistry();
await registry.loadRoots([
{ path: projectRoot, source: 'project' },
{
path: pluginRoot,
source: 'extra',
plugin: { id: 'superpowers' },
},
]);
expect(registry.getSkill('using-superpowers')?.content).toBe('project body');
expect(registry.getPluginSkill('superpowers', 'using-superpowers')?.content).toBe(
'plugin body',
);
});
it('stamps skills discovered via extra dirs with source=extra', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const extra = path.join(repoDir, 'my-extra');
await writeSkill(extra, path.join('xs', 'SKILL.md'), [
'---',
'name: xs',
'description: x',
'---',
]);
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
extraDirs: [extra],
});
const skills = await discoverSkills({ roots });
const xs = skills.find((s) => s.name === 'xs');
expect(xs?.source).toBe('extra');
});
it('combines explicit dirs with extra dirs and suppresses auto-discovery', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const userBrand = path.join(homeDir, '.kimi-code', 'skills');
await mkdir(userBrand, { recursive: true });
const projectBrand = path.join(repoDir, '.kimi-code', 'skills');
await mkdir(projectBrand, { recursive: true });
const cli = path.join(repoDir, 'cli');
await mkdir(cli, { recursive: true });
const extra = path.join(repoDir, 'extra');
await mkdir(extra, { recursive: true });
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
explicitDirs: [cli],
extraDirs: [extra],
});
const paths = roots.map((r) => r.path);
expect(paths).toContain(await realpath(cli));
expect(paths).toContain(await realpath(extra));
expect(paths).not.toContain(await realpath(userBrand));
expect(paths).not.toContain(await realpath(projectBrand));
});
it('collapses a real dir and a symlink to the same target into one root', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const real = path.join(repoDir, 'real');
await mkdir(real, { recursive: true });
const link = path.join(repoDir, 'link');
await symlink(real, link);
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
extraDirs: [real, link],
});
const extras = roots.filter((r) => r.source === 'extra');
expect(extras).toHaveLength(1);
expect(extras[0]?.path).toBe(await realpath(real));
});
it('collapses entries differing only by trailing slash', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const real = path.join(repoDir, 'real');
await mkdir(real, { recursive: true });
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
extraDirs: [real, `${real}/`],
});
const extras = roots.filter((r) => r.source === 'extra');
expect(extras).toHaveLength(1);
});
it('preserves source=extra on the surviving root after symlink dedup', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const real = path.join(repoDir, 'real');
await mkdir(real, { recursive: true });
const link = path.join(repoDir, 'link');
await symlink(real, link);
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
extraDirs: [real, link],
});
const extras = roots.filter((r) => r.source === 'extra');
expect(extras).toHaveLength(1);
expect(extras[0]?.source).toBe('extra');
});
it('stores the real path, not the symlink path, for a symlinked extra dir', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const real = path.join(repoDir, 'real');
await mkdir(real, { recursive: true });
const link = path.join(repoDir, 'link');
await symlink(real, link);
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
extraDirs: [link],
});
const extras = roots.filter((r) => r.source === 'extra');
expect(extras).toHaveLength(1);
expect(extras[0]?.path).toBe(await realpath(real));
expect(extras[0]?.path).not.toBe(link);
});
it('keeps the higher-priority scope when an extra dir overlaps with auto-discovered user dirs', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const userBrand = path.join(homeDir, '.kimi-code', 'skills');
await writeSkill(userBrand, path.join('foo', 'SKILL.md'), [
'---',
'name: foo',
'description: user version',
'---',
]);
await mkdir(path.join(repoDir, '.git'), { recursive: true });
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
extraDirs: [userBrand],
});
const realUserBrand = await realpath(userBrand);
const matching = roots.filter((r) => r.path === realUserBrand);
expect(matching).toHaveLength(1);
expect(matching[0]?.source).toBe('user');
});
});
describe('scope priority across resolution and discovery', () => {
it('lets a user-scope skill win over a same-named builtin', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const builtin = path.join(repoDir, 'builtin');
await writeSkill(builtin, path.join('foo', 'SKILL.md'), [
'---',
'name: foo',
'description: builtin version',
'---',
]);
const userBrand = path.join(homeDir, '.kimi-code', 'skills');
await writeSkill(userBrand, path.join('foo', 'SKILL.md'), [
'---',
'name: foo',
'description: user version',
'---',
]);
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
builtinDir: builtin,
});
const skills = await discoverSkills({ roots });
const foo = skills.find((s) => s.name === 'foo');
expect(foo?.source).toBe('user');
expect(foo?.description).toBe('user version');
});
it('lets a project-scope skill win over a same-named user-scope skill', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const userBrand = path.join(homeDir, '.kimi-code', 'skills');
await writeSkill(userBrand, path.join('foo', 'SKILL.md'), [
'---',
'name: foo',
'description: user version',
'---',
]);
const projBrand = path.join(repoDir, '.kimi-code', 'skills');
await writeSkill(projBrand, path.join('foo', 'SKILL.md'), [
'---',
'name: foo',
'description: project version',
'---',
]);
const roots = await resolveSkillRoots({ paths: { userHomeDir: homeDir, workDir } });
const skills = await discoverSkills({ roots });
const foo = skills.find((s) => s.name === 'foo');
expect(foo?.source).toBe('project');
expect(foo?.description).toBe('project version');
});
it('lets a project-scope skill win over a same-named builtin', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const builtin = path.join(repoDir, 'builtin');
await writeSkill(builtin, path.join('foo', 'SKILL.md'), [
'---',
'name: foo',
'description: builtin version',
'---',
]);
const projBrand = path.join(repoDir, '.kimi-code', 'skills');
await writeSkill(projBrand, path.join('foo', 'SKILL.md'), [
'---',
'name: foo',
'description: project version',
'---',
]);
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
builtinDir: builtin,
});
const skills = await discoverSkills({ roots });
const foo = skills.find((s) => s.name === 'foo');
expect(foo?.source).toBe('project');
expect(foo?.description).toBe('project version');
});
it('lets an extra-scope skill win over a same-named builtin', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const builtin = path.join(repoDir, 'builtin');
await writeSkill(builtin, path.join('foo', 'SKILL.md'), [
'---',
'name: foo',
'description: builtin version',
'---',
]);
const extra = path.join(repoDir, 'extra');
await writeSkill(extra, path.join('foo', 'SKILL.md'), [
'---',
'name: foo',
'description: extra version',
'---',
]);
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
builtinDir: builtin,
extraDirs: [extra],
});
const skills = await discoverSkills({ roots });
const foo = skills.find((s) => s.name === 'foo');
expect(foo?.source).toBe('extra');
expect(foo?.description).toBe('extra version');
});
it('lets a user-scope skill win over a same-named extra-scope skill', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const userBrand = path.join(homeDir, '.kimi-code', 'skills');
await writeSkill(userBrand, path.join('foo', 'SKILL.md'), [
'---',
'name: foo',
'description: user version',
'---',
]);
const extra = path.join(repoDir, 'extra');
await writeSkill(extra, path.join('foo', 'SKILL.md'), [
'---',
'name: foo',
'description: extra version',
'---',
]);
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
extraDirs: [extra],
});
const skills = await discoverSkills({ roots });
const foo = skills.find((s) => s.name === 'foo');
expect(foo?.source).toBe('user');
expect(foo?.description).toBe('user version');
});
it('fully excludes user and project scopes when explicit dirs are supplied', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const userBrand = path.join(homeDir, '.kimi-code', 'skills');
await writeSkill(userBrand, path.join('foo', 'SKILL.md'), [
'---',
'name: foo',
'description: user version',
'---',
]);
const projBrand = path.join(repoDir, '.kimi-code', 'skills');
await writeSkill(projBrand, path.join('foo', 'SKILL.md'), [
'---',
'name: foo',
'description: project version',
'---',
]);
const cli = path.join(repoDir, 'cli');
await writeSkill(cli, path.join('bar', 'SKILL.md'), [
'---',
'name: bar',
'description: cli version',
'---',
]);
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
explicitDirs: [cli],
});
const skills = await discoverSkills({ roots });
const names = new Set(skills.map((s) => s.name));
expect(names.has('bar')).toBe(true);
expect(names.has('foo')).toBe(false);
});
it('resolves a three-scope (builtin/user/project) conflict to a single project entry', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const builtin = path.join(repoDir, 'builtin');
await writeSkill(builtin, path.join('foo', 'SKILL.md'), [
'---',
'name: foo',
'description: builtin version',
'---',
]);
const userBrand = path.join(homeDir, '.kimi-code', 'skills');
await writeSkill(userBrand, path.join('foo', 'SKILL.md'), [
'---',
'name: foo',
'description: user version',
'---',
]);
const projBrand = path.join(repoDir, '.kimi-code', 'skills');
await writeSkill(projBrand, path.join('foo', 'SKILL.md'), [
'---',
'name: foo',
'description: project version',
'---',
]);
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
builtinDir: builtin,
});
const skills = await discoverSkills({ roots });
const foos = skills.filter((s) => s.name === 'foo');
expect(foos).toHaveLength(1);
expect(foos[0]?.source).toBe('project');
expect(foos[0]?.description).toBe('project version');
});
it('lets explicit (CLI) skills win over extra skills with the same name', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const cli = path.join(repoDir, 'cli');
await writeSkill(cli, path.join('foo', 'SKILL.md'), [
'---',
'name: foo',
'description: cli version',
'---',
]);
const extra = path.join(repoDir, 'extra');
await writeSkill(extra, path.join('foo', 'SKILL.md'), [
'---',
'name: foo',
'description: extra version',
'---',
]);
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
explicitDirs: [cli],
extraDirs: [extra],
});
const skills = await discoverSkills({ roots });
const foos = skills.filter((s) => s.name === 'foo');
expect(foos).toHaveLength(1);
expect(foos[0]?.description).toBe('cli version');
});
});
describe('explicit dir override and scope stamping', () => {
it('suppresses user and project auto-discovery when explicit dirs are present', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const userBrand = path.join(homeDir, '.kimi-code', 'skills');
await mkdir(userBrand, { recursive: true });
const projBrand = path.join(repoDir, '.kimi-code', 'skills');
await mkdir(projBrand, { recursive: true });
const extraA = path.join(repoDir, 'extra_a');
const extraB = path.join(repoDir, 'extra_b');
await mkdir(extraA, { recursive: true });
await mkdir(extraB, { recursive: true });
const builtin = path.join(repoDir, 'builtin');
await mkdir(builtin, { recursive: true });
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
builtinDir: builtin,
explicitDirs: [extraA, extraB],
});
const paths = roots.map((r) => r.path);
expect(paths).toContain(await realpath(extraA));
expect(paths).toContain(await realpath(extraB));
expect(paths).toContain(await realpath(builtin));
expect(paths).not.toContain(await realpath(userBrand));
expect(paths).not.toContain(await realpath(projBrand));
});
it('returns both brand and generic user dirs when generic is empty (no shadowing)', async () => {
const { homeDir, workDir } = await makeWorkspace();
const generic = path.join(homeDir, '.agents', 'skills');
await mkdir(generic, { recursive: true });
const brand = path.join(homeDir, '.kimi-code', 'skills');
await mkdir(brand, { recursive: true });
const roots = await resolveSkillRoots({ paths: { userHomeDir: homeDir, workDir } });
const userPaths = roots.filter((r) => r.source === 'user').map((r) => r.path);
expect(userPaths).toContain(await realpath(brand));
expect(userPaths).toContain(await realpath(generic));
expect(userPaths.indexOf(await realpath(brand))).toBeLessThan(
userPaths.indexOf(await realpath(generic)),
);
});
it('stamps each discovered skill with the scope of its root', async () => {
const { homeDir, repoDir, workDir } = await makeWorkspace();
const userBrand = path.join(homeDir, '.kimi-code', 'skills');
await writeSkill(userBrand, path.join('user-skill', 'SKILL.md'), [
'---',
'name: user-skill',
'description: u',
'---',
]);
const projBrand = path.join(repoDir, '.kimi-code', 'skills');
await writeSkill(projBrand, path.join('proj-skill', 'SKILL.md'), [
'---',
'name: proj-skill',
'description: p',
'---',
]);
const roots = await resolveSkillRoots({ paths: { userHomeDir: homeDir, workDir } });
const skills = await discoverSkills({ roots });
const byName = new Map(skills.map((s) => [s.name, s] as const));
expect(byName.get('user-skill')?.source).toBe('user');
expect(byName.get('proj-skill')?.source).toBe('project');
});
});
async function makeWorkspace(): Promise<{
readonly homeDir: string;
readonly repoDir: string;
readonly workDir: string;
}> {
const tmp = await mkdtemp(path.join(tmpdir(), 'kimi-skill-scanner-'));
tempDirs.push(tmp);
const homeDir = path.join(tmp, 'home');
const repoDir = path.join(tmp, 'repo');
const workDir = path.join(repoDir, 'packages', 'app');
await mkdir(path.join(repoDir, '.git'), { recursive: true });
await mkdir(workDir, { recursive: true });
return { homeDir, repoDir, workDir };
}
describe('project root discovery (.git walk-up)', () => {
it('walks up to the nearest .git ancestor for project-scope discovery', async () => {
const { homeDir } = await makeWorkspace();
const repo = await mkdtemp(path.join(tmpdir(), 'kimi-skill-walkup-'));
tempDirs.push(repo);
await mkdir(path.join(repo, '.git'), { recursive: true });
const repoKimi = path.join(repo, '.kimi-code', 'skills');
await writeSkill(repoKimi, path.join('foo', 'SKILL.md'), [
'---',
'name: foo',
'description: repo-root foo',
'---',
]);
const nested = path.join(repo, 'packages', 'sub', 'pkg');
await mkdir(nested, { recursive: true });
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir: nested },
});
const projectPaths = roots.filter((r) => r.source === 'project').map((r) => r.path);
expect(projectPaths).toContain(await realpath(repoKimi));
});
it('falls back to the work dir when no .git marker is found anywhere up the chain', async () => {
const { homeDir } = await makeWorkspace();
const noGitTmp = await mkdtemp(path.join(tmpdir(), 'kimi-skill-nogit-'));
tempDirs.push(noGitTmp);
const project = path.join(noGitTmp, 'project');
await mkdir(path.join(project, '.kimi-code', 'skills'), { recursive: true });
const workDir = path.join(project, 'foo');
await mkdir(workDir, { recursive: true });
const roots = await resolveSkillRoots({
paths: { userHomeDir: homeDir, workDir },
});
const projectPaths = roots.filter((r) => r.source === 'project').map((r) => r.path);
expect(projectPaths.some((p) => p.includes(path.join('project', '.kimi-code', 'skills')))).toBe(false);
});
});
async function writeSkill(
root: string,
relativePath: string,
lines: readonly string[],
): Promise<void> {
const target = path.join(root, relativePath);
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, lines.join('\n'));
}