* 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>
591 lines
26 KiB
JavaScript
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);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|