Files
msd-core/tests/kimi-agent-converter.test.cjs
Tom Boucher 3fac6e629f test(#3145): bound the installer/runtime cluster onto the process seam (#3176)
* test(#3145): bound the installer/runtime cluster onto the process seam

Migrates 156 unbounded sync spawn sites across 47 files. Allowlist 120 to 73.

Timeouts are sized from evidence already in the tree rather than a house
default, because this wave spawns installers rather than git plumbing and an
undersized bound does not catch a hang -- it manufactures CI flake, which is
worse, since a flake gets re-run instead of investigated. install.test.cjs
records a real spawnSync ETIMEDOUT at a 60000ms cap on a loaded bench while
another lane passed the same commit in 12.7s, so full installs are bound at
120000ms against that recorded incident.

Also adds an auditable escape to the guard's timeout ceiling. The 600000ms
cap was set in #3143 from partial evidence, but fragment-single-edit-
propagation carries a documented, load-tested 900000ms bound on a run that
chains a full build plus eight generators -- the guard would have rejected a
correct timeout the moment that file left the allowlist. A value above the
ceiling is now permitted only with an inline allow-spawn-timeout-ceiling
marker carrying a non-empty reason. It raises the ceiling; it never waives
the requirement for a bound, which is asserted directly.

install-shared.cjs keeps its hand-rolled assert rather than routing through
throwIfFailed: its message embeds both streams, and throwIfFailed carries
only a trimmed stderr. The message now also names the outcome, so a bounded
timeout reads as such across its 38 importers instead of as
expected null to equal 0.

* test(#3145): extract class-norm timeouts and correct the build-hooks sizing

A pre-PR review found 52 copies of four class-norm timeout constants across
this wave. These are not per-suite fixture bindings -- they are shared facts
about how long a class of subprocess takes, derived from a recorded bench
incident. That norm already moved once (60000 to 120000 after a real
ETIMEDOUT), and 52 copies would have drifted the next time it moved.

Extracts tests/helpers/timeouts.cjs, where each norm is justified once, and
converts the copies. A site that genuinely differs -- a real tsc compile, or
regen:derived -- keeps its own local constant with its own justification.

Also corrects a misclassification: scripts/build-hooks.js was sized as a
build at 120000 in twelve places and 60000 in another, but it compiles and
bundles nothing. Its own header says no bundling needed; it copies pre-built
files and syntax-checks them with vm. Three different values bounded one
script; now there is one.

* test(#3145): fix red CI — lint self-match and a Windows chunk overrun

Two failures on PR 3176.

lint-allow-test-rule-refs read a RuleTester fixture as a real exemption. The
fixture exists to prove an unrelated marker does NOT suppress the rule, so it
carries that marker's literal text as test data. Split via concatenation, the
same idiom no-unbounded-spawn-allowlist.test.cjs already uses for its own
self-match problem. The explanatory comment needed the same treatment.

The Windows shard 3/3 chunk was killed at its 600000ms budget. Output stopped
seven minutes before the kill, so this was an overrun rather than a slow
chunk: regenDerivedPropagatesSingleFragmentEditWithNoSecondSourceSurface runs
regen:derived bounded at 900000ms, which is larger than the whole chunk
budget, so the chunk killer always fires first and it can never complete
there. Both the test and that bound predate this change; modifying the file
pulled it into the Windows targeted set and exposed it. Skipped on Windows
with the reason recorded; the Linux lanes cover it. The 900000 bound and its
ceiling marker are unchanged -- they are correct.

* test(#3145): refresh the stale test-timings cost table

The Windows shard was killed at its 600000ms per-chunk budget. run-tests.cjs
packs chunks by measured duration from tests/test-timings.json, and an
unknown file falls back to the table's median weight -- advisory by design,
but it silently underweights exactly the files that matter.

Four of the failing chunk's 22 files were absent from the table, including
the two heaviest: fragment-single-edit-propagation.install.test.cjs at 230s
(it runs regen:derived) and agent-fragments-emission.install.test.cjs at 79s.
Both were weighted as average, so the chunk's total weight read 53.68 against
a budget of 60 and the packer produced a single chunk.

Regenerated from a passing full-suite run, per the remedy the script itself
documents. 700 to 770 entries, 70 added, 0 dropped -- verified, since
gen-test-timings.cjs replaces the table wholesale rather than merging.

Proven against the real packer: the same 22 files now weigh 103.91 and split
into two chunks. No logic, budget, or timeout was changed; raising a budget
to make a red gate pass is not a fix.

---------

Co-authored-by: sim <sim@local>
2026-08-07 15:18:18 -04:00

591 lines
26 KiB
JavaScript

/**
* Kimi CLI agent artifact contract tests.
*
* Kimi custom agents are explicit YAML files loaded with `kimi --agent-file`.
* This suite tests the in-memory artifact contract only; install/layout wiring
* belongs to the later Phase 3 slices.
*/
process.env.GSD_TEST_MODE = '1';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const {
buildKimiAgentArtifacts,
} = require('../bin/install.js');
const ROOT_AGENT = `---
name: gsd
description: Root GSD agent for Kimi CLI
tools: Agent, mcp__github__search, DefinitelyUnknownTool
color: blue
---
# GSD Root
Coordinate GSD workflows through subagents.
Read ~/.claude/gsd-core when source-package context is needed.`;
const EXECUTOR_AGENT = `---
name: gsd-executor
description: Execute planned GSD task slices with atomic commits.
tools: Read, Write, Edit, Bash, Grep, Glob
color: yellow
---
<role>
You are a GSD plan executor.
</role>`;
const INVALID_AGENT = `---
name: not a valid kimi agent
description: Invalid name should be diagnosed and skipped.
tools: Agent
---
This should not become a Kimi custom subagent.`;
describe('buildKimiAgentArtifacts', () => {
test('builds a root Kimi agent YAML contract with explicit subagent paths', () => {
const result = buildKimiAgentArtifacts({
rootAgent: ROOT_AGENT,
subagents: [
{ path: 'agents/gsd-executor.md', content: EXECUTOR_AGENT },
],
requestedSubagents: ['gsd-executor', 'gsd-missing'],
});
assert.equal(result.root.yamlPath, 'agents/gsd.yaml');
assert.equal(result.root.promptPath, 'agents/gsd.md');
assert.ok(result.root.yaml.includes('version: 1'), 'root YAML includes Kimi version marker');
assert.ok(result.root.yaml.includes('agent:'), 'root YAML includes agent object');
assert.ok(result.root.yaml.includes('name: gsd'), 'root agent name is gsd');
assert.ok(result.root.yaml.includes('extend: default'), 'root agent extends default Kimi behavior');
assert.ok(result.root.yaml.includes('system_prompt_path: ./gsd.md'), 'root prompt path is relative');
assert.ok(result.root.yaml.includes('tools:'), 'root YAML has a tools field');
assert.ok(result.root.yaml.includes('kimi_cli.tools.agent:Agent'), 'root can call Kimi subagents');
assert.ok(
result.root.yaml.includes('kimi_cli.tools.agent:Agent'),
'root tools use Kimi module paths'
);
assert.ok(!result.root.yaml.includes('- Agent'), 'root YAML does not emit raw Claude Agent tool');
assert.ok(result.root.yaml.includes('subagents:'), 'root YAML declares custom subagents');
assert.ok(result.root.yaml.includes('gsd-executor:'), 'known GSD subagent key is canonical');
assert.ok(
result.root.yaml.includes('path: ./subagents/gsd-executor.yaml'),
'known GSD subagent path is relative to root YAML'
);
assert.ok(!result.root.yaml.includes('gsd-missing'), 'unknown requested subagent is excluded');
});
test('emits separate frontmatter-free Markdown prompts for root and subagents', () => {
const result = buildKimiAgentArtifacts({
rootAgent: ROOT_AGENT,
subagents: [
{ path: 'agents/gsd-executor.md', content: EXECUTOR_AGENT },
],
requestedSubagents: ['gsd-executor'],
});
const executor = result.subagents.find((artifact) => artifact.name === 'gsd-executor');
assert.ok(executor, 'executor subagent artifact exists');
assert.equal(executor.yamlPath, 'agents/subagents/gsd-executor.yaml');
assert.equal(executor.promptPath, 'agents/subagents/gsd-executor.md');
assert.ok(executor.yaml.includes('system_prompt_path: ./gsd-executor.md'));
assert.ok(executor.yaml.includes('kimi_cli.tools.file:ReadFile'), 'Read maps to Kimi file tool');
assert.ok(executor.yaml.includes('kimi_cli.tools.file:WriteFile'), 'Write maps to Kimi file tool');
assert.ok(executor.yaml.includes('kimi_cli.tools.file:StrReplaceFile'), 'Edit maps to Kimi file tool');
assert.ok(executor.yaml.includes('kimi_cli.tools.shell:Shell'), 'Bash maps to Kimi shell tool');
assert.ok(executor.yaml.includes('kimi_cli.tools.file:Grep'), 'Grep maps to Kimi grep tool');
assert.ok(executor.yaml.includes('kimi_cli.tools.file:Glob'), 'Glob maps to Kimi glob tool');
assert.ok(!executor.yaml.includes('- Read'), 'subagent YAML does not emit raw Claude Read tool');
assert.ok(!executor.yaml.includes('- Bash'), 'subagent YAML does not emit raw Claude Bash tool');
assert.ok(!executor.yaml.includes('kimi_cli.tools.agent:Agent'), 'subagent does not inherit nested Agent tool');
for (const prompt of [result.root.prompt, executor.prompt]) {
assert.ok(!prompt.trimStart().startsWith('---'), 'source frontmatter is removed');
assert.ok(!prompt.includes('tools:'), 'source frontmatter tools do not leak into prompt');
assert.ok(!prompt.includes('color:'), 'source frontmatter color does not leak into prompt');
}
assert.ok(result.root.prompt.includes('# GSD Root'), 'root body content is preserved');
assert.ok(executor.prompt.includes('You are a GSD plan executor.'), 'subagent body content is preserved');
assert.ok(!result.root.prompt.includes('~/.claude/gsd-core'), 'Claude-specific path is neutralized');
});
test('diagnoses unknown subagents and unsupported inputs instead of emitting invalid names', () => {
const result = buildKimiAgentArtifacts({
rootAgent: ROOT_AGENT,
subagents: [
{ path: 'agents/gsd-executor.md', content: EXECUTOR_AGENT },
{ path: 'agents/not-valid.md', content: INVALID_AGENT },
],
requestedSubagents: ['gsd-executor', 'gsd-missing'],
});
assert.deepEqual(
result.subagents.map((artifact) => artifact.name),
['gsd-executor'],
'invalid and unknown subagents are not emitted'
);
assert.ok(
result.diagnostics.some((item) => item.code === 'kimi_unknown_subagent' && item.value === 'gsd-missing'),
'unknown requested subagent is diagnosed'
);
assert.ok(
result.diagnostics.some((item) => item.code === 'kimi_invalid_subagent_name'),
'invalid source subagent name is diagnosed'
);
assert.ok(
result.diagnostics.some((item) => item.code === 'kimi_mcp_tool_excluded'),
'MCP-managed tools are diagnosed and excluded'
);
assert.ok(
result.diagnostics.some((item) => item.reason === 'mcp_managed'),
'MCP diagnostics expose mapper reason'
);
assert.ok(
result.diagnostics.some((item) => item.code === 'kimi_unsupported_tool'),
'unsupported tools are diagnosed and excluded'
);
assert.ok(
result.diagnostics.some((item) => item.reason === 'unsupported_tool'),
'unsupported diagnostics expose mapper reason'
);
assert.ok(!result.root.yaml.includes('mcp__github__search'), 'MCP tool names are not emitted');
assert.ok(!result.root.yaml.includes('DefinitelyUnknownTool'), 'unsupported tool names are not emitted');
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-kimi-path-layout-local-guard.test.cjs — consolidation epic #1969 (B3 #1972)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-kimi-path-layout-local-guard (consolidation epic #1969 B3 #1972)", () => {
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { runNode } = require('./helpers/process-seam.cjs');
const { cleanup } = require('./helpers.cjs');
const { installerEnv } = require('./helpers/install-shared.cjs');
const ROOT = path.join(__dirname, '..');
// Cross-platform temp paths for test fixtures (avoids hardcoded /tmp)
const KIMI_CFG = path.join(os.tmpdir(), 'gsd-kimi-config-test').replace(/\\/g, '/');
const XDG_HOME = path.join(os.tmpdir(), 'gsd-xdg-home-test');
const INSTALL_SCRIPT = path.join(ROOT, 'bin', 'install.js');
// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs.
const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const {
getGlobalConfigDir,
getGlobalSkillsBase,
getGlobalSkillDir,
resolveKimiGlobalDir,
} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs'));
const {
resolveRuntimeArtifactLayout,
} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-artifact-layout.cjs'));
const {
getGlobalDir,
getConfigDirFromHome,
} = require('../bin/install.js');
function withEnv(updates, fn) {
const saved = {};
for (const key of Object.keys(updates)) {
saved[key] = process.env[key];
const value = updates[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
try {
return fn();
} finally {
for (const key of Object.keys(updates)) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
}
}
describe('Kimi runtime homes', () => {
test('default Kimi global root is recommended ~/.config/agents when no generic skills root exists', () => {
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-home-default-'));
try {
withEnv({ KIMI_CONFIG_DIR: undefined, XDG_CONFIG_HOME: undefined, HOME: tmpHome, USERPROFILE: tmpHome }, () => {
assert.strictEqual(
getGlobalConfigDir('kimi'),
path.join(tmpHome, '.config', 'agents'),
);
assert.strictEqual(
getGlobalSkillsBase('kimi'),
path.join(tmpHome, '.config', 'agents', 'skills'),
);
assert.strictEqual(
getGlobalSkillDir('kimi', 'gsd-help'),
path.join(tmpHome, '.config', 'agents', 'skills', 'gsd-help'),
);
assert.notStrictEqual(
getGlobalSkillsBase('kimi'),
path.join(tmpHome, '.kimi-code', 'skills'),
);
});
} finally {
cleanup(tmpHome);
}
});
test('Kimi root resolution follows first-existing generic skills directory order', () => {
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-home-existing-'));
try {
const recommendedRoot = path.join(tmpHome, '.config', 'agents');
const fallbackRoot = path.join(tmpHome, '.agents');
assert.strictEqual(
resolveKimiGlobalDir({ env: {}, home: tmpHome, existsSync: fs.existsSync }),
recommendedRoot,
);
fs.mkdirSync(path.join(fallbackRoot, 'skills'), { recursive: true });
assert.strictEqual(
resolveKimiGlobalDir({ env: {}, home: tmpHome, existsSync: fs.existsSync }),
fallbackRoot,
);
fs.mkdirSync(path.join(recommendedRoot, 'skills'), { recursive: true });
assert.strictEqual(
resolveKimiGlobalDir({ env: {}, home: tmpHome, existsSync: fs.existsSync }),
recommendedRoot,
);
} finally {
cleanup(tmpHome);
}
});
test('KIMI_CONFIG_DIR can select the brand-specific ~/.kimi-code root', () => {
const customKimiDir = path.join(os.tmpdir(), 'custom-kimi-code');
withEnv({ KIMI_CONFIG_DIR: customKimiDir, XDG_CONFIG_HOME: undefined }, () => {
assert.strictEqual(String(getGlobalConfigDir('kimi')).replace(/\\/g, '/'), customKimiDir.replace(/\\/g, '/'));
assert.strictEqual(
getGlobalSkillsBase('kimi'),
path.join(customKimiDir, 'skills'),
);
assert.strictEqual(String(getGlobalDir('kimi')).replace(/\\/g, '/'), customKimiDir.replace(/\\/g, '/'));
});
});
test('XDG_CONFIG_HOME does not change Kimi default root', () => {
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-home-xdg-'));
try {
withEnv({ KIMI_CONFIG_DIR: undefined, XDG_CONFIG_HOME: XDG_HOME, HOME: tmpHome, USERPROFILE: tmpHome }, () => {
assert.strictEqual(
getGlobalConfigDir('kimi'),
path.join(tmpHome, '.config', 'agents'),
);
assert.strictEqual(
getConfigDirFromHome('kimi', true),
"'.config', 'agents'",
);
});
} finally {
cleanup(tmpHome);
}
});
test('Kimi global install reuses existing ~/.agents/skills when recommended root is absent', () => {
const tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-existing-agents-project-'));
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-existing-agents-home-'));
try {
fs.mkdirSync(path.join(tmpHome, '.agents', 'skills'), { recursive: true });
const result = runNode(
[INSTALL_SCRIPT, '--kimi', '--global', '--no-sdk'],
{
cwd: tmpProject,
env: installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome }),
timeoutMs: INSTALL_TIMEOUT_MS,
},
);
assert.strictEqual(
result.exitCode,
0,
`expected --kimi --global to reuse existing ~/.agents/skills\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
);
assert.strictEqual(
fs.existsSync(path.join(tmpHome, '.agents', 'skills', 'gsd-new-project', 'SKILL.md')),
true,
);
assert.strictEqual(
fs.existsSync(path.join(tmpHome, '.config', 'agents', 'skills', 'gsd-new-project', 'SKILL.md')),
false,
);
} finally {
cleanup(tmpProject);
cleanup(tmpHome);
}
});
});
describe('Kimi runtime artifact layout', () => {
test('global layout stages Kimi skills and agents while local layout remains guarded', () => {
const globalLayout = resolveRuntimeArtifactLayout('kimi', KIMI_CFG, 'global');
assert.strictEqual(globalLayout.runtime, 'kimi');
assert.strictEqual(String(globalLayout.configDir).replace(/\\/g, '/'), KIMI_CFG);
assert.strictEqual(globalLayout.kinds.length, 2);
assert.strictEqual(globalLayout.kinds[0].kind, 'skills');
assert.strictEqual(globalLayout.kinds[0].destSubpath, 'skills');
assert.strictEqual(globalLayout.kinds[0].prefix, 'gsd-');
assert.strictEqual(typeof globalLayout.kinds[0].stage, 'function');
assert.strictEqual(globalLayout.kinds[1].kind, 'kimi-agents');
assert.strictEqual(globalLayout.kinds[1].destSubpath, 'agents');
assert.strictEqual(globalLayout.kinds[1].prefix, 'gsd');
assert.strictEqual(typeof globalLayout.kinds[1].stage, 'function');
const localLayout = resolveRuntimeArtifactLayout('kimi', KIMI_CFG, 'local');
assert.strictEqual(localLayout.runtime, 'kimi');
assert.deepStrictEqual(localLayout.kinds, []);
});
});
describe('Kimi local install guard', () => {
test('--kimi --local exits successfully without writing local Kimi project artifacts', () => {
const tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-local-project-'));
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-local-home-'));
try {
const result = runNode(
[INSTALL_SCRIPT, '--kimi', '--local', '--no-sdk'],
{
cwd: tmpProject,
env: installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome }),
timeoutMs: INSTALL_TIMEOUT_MS,
},
);
assert.strictEqual(
result.exitCode,
0,
`expected --kimi --local guard to no-op successfully\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
);
const combined = `${result.stdout}\n${result.stderr}`;
assert.match(combined, /Kimi local install/i);
assert.match(combined, /deferred/i);
assert.ok(!fs.existsSync(path.join(tmpProject, '.kimi')), 'must not create legacy .kimi/');
assert.ok(!fs.existsSync(path.join(tmpProject, '.kimi-code')), 'must not create .kimi-code/');
assert.ok(!fs.existsSync(path.join(tmpProject, '.agents')), 'must not create .agents/');
assert.ok(!fs.existsSync(path.join(tmpProject, '.claude')), 'must not fall back to Claude local install');
} finally {
cleanup(tmpProject);
cleanup(tmpHome);
}
});
test('--kimi --global writes converted Kimi skills and agent artifacts', () => {
const tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-global-project-'));
const tmpConfig = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-global-config-'));
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-global-home-'));
try {
const result = runNode(
[INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', tmpConfig, '--no-sdk'],
{
cwd: tmpProject,
env: installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome }),
timeoutMs: INSTALL_TIMEOUT_MS,
},
);
assert.strictEqual(
result.exitCode,
0,
`expected --kimi --global to install Kimi skills successfully\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
);
const combined = `${result.stdout}\n${result.stderr}`;
assert.match(combined, /Installing for .*Kimi/i);
assert.match(combined, /Installed \d+ Kimi skills to skills\//i);
assert.match(combined, /Generated Kimi root agent: .*agents.*gsd\.yaml/i);
assert.match(combined, /kimi --agent-file/i);
assert.match(combined, /Wrote file manifest/i);
const skillFile = path.join(tmpConfig, 'skills', 'gsd-new-project', 'SKILL.md');
assert.ok(fs.existsSync(skillFile), 'must write gsd-new-project/SKILL.md');
const skillContent = fs.readFileSync(skillFile, 'utf8');
assert.match(skillContent, /^name: gsd-new-project$/m);
assert.match(skillContent, /\/skill:gsd-new-project/);
assert.match(skillContent, /gsd-core\/workflows\/new-project\.md/);
assert.doesNotMatch(skillContent, /@~\/\.claude\/gsd-core|@\$HOME\/\.claude\/gsd-core/);
assert.doesNotMatch(skillContent, /@[^\r\n]*\\/, 'serialized Kimi payload references must use forward slashes');
assert.doesNotMatch(skillContent, /kimi_cli\.tools|system_prompt_path|^version: 1$/m);
const rootYaml = path.join(tmpConfig, 'agents', 'gsd.yaml');
const rootPrompt = path.join(tmpConfig, 'agents', 'gsd.md');
const executorYaml = path.join(tmpConfig, 'agents', 'subagents', 'gsd-executor.yaml');
const executorPrompt = path.join(tmpConfig, 'agents', 'subagents', 'gsd-executor.md');
assert.ok(fs.existsSync(rootYaml), 'must write agents/gsd.yaml');
assert.ok(fs.existsSync(rootPrompt), 'must write agents/gsd.md');
assert.ok(fs.existsSync(executorYaml), 'must write agents/subagents/gsd-executor.yaml');
assert.ok(fs.existsSync(executorPrompt), 'must write agents/subagents/gsd-executor.md');
const rootYamlContent = fs.readFileSync(rootYaml, 'utf8');
assert.match(rootYamlContent, /^version: 1$/m);
assert.match(rootYamlContent, /^agent:$/m);
assert.match(rootYamlContent, /extend: default/);
assert.match(rootYamlContent, /system_prompt_path: \.\/gsd\.md/);
assert.match(rootYamlContent, /tools:/);
assert.match(rootYamlContent, /subagents:/);
assert.match(rootYamlContent, /kimi_cli\.tools\./);
assert.doesNotMatch(rootYamlContent, /mcp__/);
const executorYamlContent = fs.readFileSync(executorYaml, 'utf8');
assert.match(executorYamlContent, /system_prompt_path: \.\/gsd-executor\.md/);
assert.match(executorYamlContent, /kimi_cli\.tools\./);
assert.doesNotMatch(executorYamlContent, /mcp__/);
assert.ok(fs.existsSync(path.join(tmpConfig, 'gsd-core', 'workflows', 'new-project.md')), 'must write workflow payloads used by Kimi skills');
const manifestPath = path.join(tmpConfig, 'gsd-file-manifest.json');
assert.ok(fs.existsSync(manifestPath), 'must write gsd-file-manifest.json for Kimi installs');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
assert.ok(manifest.files['skills/gsd-new-project/SKILL.md'], 'manifest tracks generated Kimi skill');
assert.ok(manifest.files['agents/gsd.yaml'], 'manifest tracks Kimi root agent YAML');
assert.ok(manifest.files['agents/gsd.md'], 'manifest tracks Kimi root agent prompt');
assert.ok(manifest.files['agents/subagents/gsd-executor.yaml'], 'manifest tracks Kimi subagent YAML');
assert.ok(manifest.files['agents/subagents/gsd-executor.md'], 'manifest tracks Kimi subagent prompt');
assert.ok(manifest.files['gsd-core/workflows/new-project.md'], 'manifest tracks installed workflow payload');
assert.ok(!fs.existsSync(path.join(tmpConfig, 'hooks')), 'must not write hooks under the Kimi root');
assert.ok(!fs.existsSync(path.join(tmpConfig, 'settings.json')), 'must not write settings.json under the Kimi root');
assert.ok(!fs.existsSync(path.join(tmpConfig, '.clinerules')), 'must not write rules under the Kimi root');
} finally {
cleanup(tmpProject);
cleanup(tmpConfig);
cleanup(tmpHome);
}
});
test('--kimi --global backs up local edits to generated skills and agent artifacts on reinstall', () => {
const tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-reinstall-project-'));
const tmpConfig = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-reinstall-config-'));
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-reinstall-home-'));
const env = installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome });
try {
const installArgs = [INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', tmpConfig, '--no-sdk'];
const first = runNode(installArgs, {
cwd: tmpProject,
env,
timeoutMs: INSTALL_TIMEOUT_MS,
});
assert.strictEqual(
first.exitCode,
0,
`first install failed\nstdout: ${first.stdout}\nstderr: ${first.stderr}`,
);
const skillFile = path.join(tmpConfig, 'skills', 'gsd-new-project', 'SKILL.md');
const agentPrompt = path.join(tmpConfig, 'agents', 'subagents', 'gsd-executor.md');
fs.appendFileSync(skillFile, '\nUSER LOCAL KIMI SKILL EDIT\n');
fs.appendFileSync(agentPrompt, '\nUSER LOCAL KIMI AGENT EDIT\n');
const second = runNode(installArgs, {
cwd: tmpProject,
env,
timeoutMs: INSTALL_TIMEOUT_MS,
});
assert.strictEqual(
second.exitCode,
0,
`second install failed\nstdout: ${second.stdout}\nstderr: ${second.stderr}`,
);
const secondOutput = `${second.stdout}\n${second.stderr}`;
assert.match(secondOutput, /locally modified GSD file/i);
assert.match(secondOutput, /\/skill:gsd-update --reapply/);
assert.doesNotMatch(secondOutput, /Run\s+\/gsd-update --reapply/);
const skillBackup = path.join(tmpConfig, 'gsd-local-patches', 'skills', 'gsd-new-project', 'SKILL.md');
const agentBackup = path.join(tmpConfig, 'gsd-local-patches', 'agents', 'subagents', 'gsd-executor.md');
assert.match(fs.readFileSync(skillBackup, 'utf8'), /USER LOCAL KIMI SKILL EDIT/);
assert.match(fs.readFileSync(agentBackup, 'utf8'), /USER LOCAL KIMI AGENT EDIT/);
const meta = JSON.parse(fs.readFileSync(path.join(tmpConfig, 'gsd-local-patches', 'backup-meta.json'), 'utf8'));
assert.ok(meta.files.includes('skills/gsd-new-project/SKILL.md'));
assert.ok(meta.files.includes('agents/subagents/gsd-executor.md'));
} finally {
cleanup(tmpProject);
cleanup(tmpConfig);
cleanup(tmpHome);
}
});
test('--kimi --global --uninstall removes GSD artifacts and preserves non-GSD Kimi content', () => {
const tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-uninstall-project-'));
const tmpConfig = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-uninstall-config-'));
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-uninstall-home-'));
const env = installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome });
try {
const installArgs = [INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', tmpConfig, '--no-sdk'];
const installResult = runNode(installArgs, {
cwd: tmpProject,
env,
timeoutMs: INSTALL_TIMEOUT_MS,
});
assert.strictEqual(
installResult.exitCode,
0,
`install failed\nstdout: ${installResult.stdout}\nstderr: ${installResult.stderr}`,
);
const foreignSkill = path.join(tmpConfig, 'skills', 'user-custom-skill', 'SKILL.md');
const foreignRootAgent = path.join(tmpConfig, 'agents', 'user-agent.yaml');
const foreignSubagent = path.join(tmpConfig, 'agents', 'subagents', 'user-agent.yaml');
fs.mkdirSync(path.dirname(foreignSkill), { recursive: true });
fs.mkdirSync(path.dirname(foreignSubagent), { recursive: true });
fs.writeFileSync(foreignSkill, '# user skill\n', 'utf8');
fs.writeFileSync(foreignRootAgent, 'version: 1\n', 'utf8');
fs.writeFileSync(foreignSubagent, 'version: 1\n', 'utf8');
const uninstallResult = runNode(
[INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', tmpConfig, '--uninstall'],
{
cwd: tmpProject,
env,
timeoutMs: INSTALL_TIMEOUT_MS,
},
);
assert.strictEqual(
uninstallResult.exitCode,
0,
`uninstall failed\nstdout: ${uninstallResult.stdout}\nstderr: ${uninstallResult.stderr}`,
);
assert.match(`${uninstallResult.stdout}\n${uninstallResult.stderr}`, /Kimi CLI/);
assert.ok(!fs.existsSync(path.join(tmpConfig, 'skills', 'gsd-new-project')), 'must remove generated Kimi skills');
assert.ok(!fs.existsSync(path.join(tmpConfig, 'agents', 'gsd.yaml')), 'must remove Kimi root agent YAML');
assert.ok(!fs.existsSync(path.join(tmpConfig, 'agents', 'gsd.md')), 'must remove Kimi root agent prompt');
assert.ok(!fs.existsSync(path.join(tmpConfig, 'agents', 'subagents', 'gsd-executor.yaml')), 'must remove generated Kimi subagent YAML');
assert.ok(!fs.existsSync(path.join(tmpConfig, 'agents', 'subagents', 'gsd-executor.md')), 'must remove generated Kimi subagent prompt');
assert.ok(!fs.existsSync(path.join(tmpConfig, 'gsd-core')), 'must remove installed workflow payload');
assert.ok(!fs.existsSync(path.join(tmpConfig, 'gsd-file-manifest.json')), 'must remove the manifest');
assert.ok(fs.existsSync(foreignSkill), 'must preserve non-GSD Kimi skills');
assert.ok(fs.existsSync(foreignRootAgent), 'must preserve non-GSD root agents');
assert.ok(fs.existsSync(foreignSubagent), 'must preserve non-GSD subagents');
} finally {
cleanup(tmpProject);
cleanup(tmpConfig);
cleanup(tmpHome);
}
});
});
});
}