Merge pull request #743 from viktorplus/kimi-runtime-support
feat(#744): add Kimi CLI runtime support
This commit is contained in:
5
.changeset/kimi-runtime-support.md
Normal file
5
.changeset/kimi-runtime-support.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 743
|
||||
---
|
||||
**Kimi CLI runtime support is now documented and installable** — users can install global GSD Agent Skills with `--kimi --global`, invoke them as `/skill:gsd-*`, and launch the generated custom agent explicitly with `kimi --agent-file`. The custom-agent (`--agent-file`) surface targets the legacy/Python `kimi-cli` contract; newer Kimi Code (`@moonshot-ai/kimi-code`) consumes the same `/skill:gsd-*` skills via `--skills-dir` instead.
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
**English** · [Português](README.pt-BR.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [한국어](README.ko-KR.md)
|
||||
|
||||
**A light-weight meta-prompting, context engineering, and spec-driven development system for Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf, and more.**
|
||||
**A light-weight meta-prompting, context engineering, and spec-driven development system for Claude Code, OpenCode, Gemini CLI, Kimi CLI, Kilo, Codex, Copilot, Cursor, Windsurf, and more.**
|
||||
|
||||
[](https://www.npmjs.com/package/@opengsd/gsd-core)
|
||||
[](https://www.npmjs.com/package/@opengsd/gsd-core)
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
## What is GSD Core
|
||||
|
||||
GSD Core is a context-engineering and spec-driven development framework that drives AI coding agents (Claude Code, Codex, Gemini CLI, Copilot, Cursor, and more) through a disciplined phase loop. It solves [context rot](docs/explanation/context-engineering.md) — the quality degradation that accumulates as an AI fills its context window — by running all heavy research, planning, and execution work in fresh-context subagents while keeping your main session lean.
|
||||
GSD Core is a context-engineering and spec-driven development framework that drives AI coding agents (Claude Code, Codex, Gemini CLI, Kimi CLI, Copilot, Cursor, and more) through a disciplined phase loop. It solves [context rot](docs/explanation/context-engineering.md) — the quality degradation that accumulates as an AI fills its context window — by running all heavy research, planning, and execution work in fresh-context subagents while keeping your main session lean.
|
||||
|
||||
---
|
||||
|
||||
@@ -43,7 +43,7 @@ Each milestone repeats the same five-step loop, one phase at a time:
|
||||
npx @opengsd/gsd-core@latest
|
||||
```
|
||||
|
||||
The installer prompts for your runtime (Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf, and more) and whether to install globally or locally. The installer is required for cross-runtime compatibility — do not copy files from `agents/` or `commands/` directly.
|
||||
The installer prompts for your runtime (Claude Code, OpenCode, Gemini CLI, Kimi CLI, Kilo, Codex, Copilot, Cursor, Windsurf, and more) and whether to install globally or locally. The installer is required for cross-runtime compatibility — do not copy files from `agents/` or `commands/` directly.
|
||||
|
||||
On another runtime or without Node.js? See [Install on your runtime](docs/how-to/install-on-your-runtime.md).
|
||||
|
||||
|
||||
617
bin/install.js
617
bin/install.js
File diff suppressed because one or more lines are too long
@@ -21,7 +21,7 @@
|
||||
|
||||
## System Overview
|
||||
|
||||
GSD Core is a **meta-prompting framework** that sits between the user and AI coding agents (Claude Code, Gemini CLI, OpenCode, Kilo, Codex, Copilot, Antigravity, Trae, Cline, Augment Code). It provides:
|
||||
GSD Core is a **meta-prompting framework** that sits between the user and AI coding agents (Claude Code, Gemini CLI, Kimi CLI, OpenCode, Kilo, Codex, Copilot, Antigravity, Trae, Cline, Augment Code). It provides:
|
||||
|
||||
1. **Context engineering** — Structured artifacts that give the AI everything it needs per task (see [Context engineering](explanation/context-engineering.md))
|
||||
2. **Multi-agent orchestration** — Thin orchestrators that spawn specialized agents with fresh context windows (see [Multi-agent orchestration](explanation/multi-agent-orchestration.md))
|
||||
@@ -116,6 +116,7 @@ User-facing entry points. Each file contains YAML frontmatter (name, description
|
||||
- **Codex:** Skills (`$gsd-command-name`)
|
||||
- **Copilot:** Slash commands (hyphen form, `/gsd-command-name`)
|
||||
- **Gemini CLI:** Slash commands under the `gsd:` namespace (colon form, `/gsd:command-name`) — Gemini namespaces all custom commands under their plugin id, so the install path rewrites every body-text reference to colon form
|
||||
- **Kimi CLI:** Agent Skills (`/skill:gsd-command-name`) plus an explicit custom agent launch with `kimi --agent-file`
|
||||
- **Antigravity:** Skills
|
||||
|
||||
**Total commands:** see [`docs/INVENTORY.md`](INVENTORY.md#commands) for the authoritative count and full roster.
|
||||
@@ -575,6 +576,7 @@ Equivalent paths for other runtimes:
|
||||
- **OpenCode:** `~/.config/opencode/` global or `./.opencode/` local
|
||||
- **Kilo:** `~/.config/kilo/` global or `./.kilo/` local
|
||||
- **Gemini CLI:** `~/.gemini/` global or `./.gemini/` local
|
||||
- **Kimi CLI:** first-existing generic global root (`~/.config/agents/` recommended, then `~/.agents/` if its `skills/` directory already exists); local install is deferred and guarded
|
||||
- **Codex:** `~/.codex/` global or `./.codex/` local
|
||||
- **Copilot:** `~/.copilot/` global or `./.github/` local
|
||||
- **Antigravity:** auto-detected global root (`~/.gemini/antigravity/`, `~/.gemini/antigravity-ide/`, or `~/.gemini/antigravity-cli/`) or `./.agent/` local
|
||||
@@ -669,7 +671,7 @@ verification.
|
||||
|
||||
The installer (`bin/install.js`, ~10,700 lines) handles:
|
||||
|
||||
1. **Runtime detection** — Interactive prompt or CLI flags (`--claude`, `--opencode`, `--gemini`, `--kilo`, `--codex`, `--copilot`, `--antigravity`, `--cursor`, `--windsurf`, `--augment`, `--trae`, `--qwen`, `--hermes`, `--codebuddy`, `--cline`, `--all`)
|
||||
1. **Runtime detection** — Interactive prompt or CLI flags (`--claude`, `--opencode`, `--gemini`, `--kimi`, `--kilo`, `--codex`, `--copilot`, `--antigravity`, `--cursor`, `--windsurf`, `--augment`, `--trae`, `--qwen`, `--hermes`, `--codebuddy`, `--cline`, `--all`)
|
||||
2. **Location selection** — Global (`--global`) or local (`--local`)
|
||||
3. **File deployment** — Copies commands, skills, workflows, references, templates, agents, and hooks
|
||||
4. **Runtime adaptation** — Transforms file content per runtime:
|
||||
@@ -677,6 +679,7 @@ The installer (`bin/install.js`, ~10,700 lines) handles:
|
||||
- OpenCode: Converts commands/agents to OpenCode-compatible flat command + subagent format
|
||||
- Kilo: Reuses the OpenCode conversion pipeline with Kilo config paths
|
||||
- Codex: Generates TOML config + skills from commands
|
||||
- Kimi CLI: Generates Agent Skills under `skills/gsd-*/SKILL.md`, custom agent YAML/prompt files, and explicit `kimi_cli.tools.*` module paths
|
||||
- Copilot: Maps tool names (Read→read, Bash→execute, etc.)
|
||||
- Gemini: Adjusts hook event names (`AfterTool` instead of `PostToolUse`)
|
||||
- Antigravity: Skills-first with Google model equivalents
|
||||
@@ -812,6 +815,7 @@ The migration-specific ownership and source snapshots live in
|
||||
| OpenCode | `~/.config/opencode` | `./.opencode` | `command/gsd-*.md` | `agents/gsd-*.md` | `opencode.json` or `opencode.jsonc`; no GSD hooks |
|
||||
| Kilo | `~/.config/kilo` | `./.kilo` | `command/gsd-*.md` | `agents/gsd-*.md` | `kilo.json` or `kilo.jsonc`; no GSD hooks |
|
||||
| Gemini CLI | `~/.gemini` | `./.gemini` | `commands/gsd/*.toml` | `agents/gsd-*.md` | `settings.json` feature flag, hooks, and statusline |
|
||||
| Kimi CLI | First-existing generic root: `~/.config/agents` recommended, then `~/.agents` when `~/.agents/skills` exists and `~/.config/agents/skills` does not | Deferred and guarded | `skills/gsd-*/SKILL.md` (flat) invoked as `/skill:gsd-*` | `agents/gsd.yaml`, `agents/gsd.md`, and `agents/subagents/gsd-*` YAML/prompt pairs | Explicit `kimi --agent-file <configRoot>/agents/gsd.yaml`; no GSD hooks or statusline |
|
||||
| Codex | `~/.codex` | `./.codex` | `skills/gsd-*/SKILL.md` (flat) | `agents/` source markdown plus per-agent TOML | `config.toml` `[agents.gsd-*]`, `[features].hooks` (canonical; legacy alias `codex_hooks` is recognized and migrated forward on reinstall, #3566), and hook tables |
|
||||
| GitHub Copilot | `~/.copilot` | `./.github` | `skills/gsd-*/SKILL.md` (flat), `copilot-instructions.md`, and `AGENTS.md` (repo root, local) | `.agent.md` files | Self-contained `sessionStart` hook (`hooks/gsd-session.json`, inline `command` type); no statusline |
|
||||
| Antigravity | auto-detected: `~/.gemini/antigravity`, `~/.gemini/antigravity-ide`, or `~/.gemini/antigravity-cli` | `./.agent` | `skills/gsd-ns-*/SKILL.md` (6 routers) + `skills/gsd-ns-*/skills/<name>/SKILL.md` (nested concretes) | `agents/gsd-*.md` | Gemini-style `settings.json` hook entries when installed by GSD |
|
||||
@@ -827,12 +831,17 @@ The migration-specific ownership and source snapshots live in
|
||||
### Upstream Contract Sources
|
||||
|
||||
Runtime install expectations are checked against primary documentation where
|
||||
available. The current source snapshot is 2026-05-11:
|
||||
available. The current source snapshot is 2026-05-11, with Kimi CLI rechecked
|
||||
on 2026-06-07:
|
||||
|
||||
- Claude Code: Anthropic slash commands, settings, hooks, and subagents docs.
|
||||
- OpenCode and Kilo: OpenCode config docs and Kilo custom subagent docs.
|
||||
- Gemini CLI and Qwen Code: command/config docs; Qwen command docs were last
|
||||
updated 2026-05-06.
|
||||
- Kimi CLI: Agent Skills docs for user-level brand roots and first-existing
|
||||
generic roots (`~/.config/agents/skills/` recommended, then
|
||||
`~/.agents/skills/`), plus Agents docs for YAML files, `system_prompt_path`,
|
||||
`kimi_cli.tools.*` module paths, and explicit `kimi --agent-file` launch.
|
||||
- Codex: OpenAI Codex docs and `config-schema.json`; the installer also carries
|
||||
Codex 0.124.0 compatibility for agent table shape.
|
||||
- Copilot, Cursor, Cline, Augment, Hermes, and CodeBuddy: vendor docs for
|
||||
|
||||
@@ -15,7 +15,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md)
|
||||
|
||||
## How-to guides
|
||||
|
||||
- [Install on your runtime](how-to/install-on-your-runtime.md) — runtime-specific install steps for all 15 supported runtimes
|
||||
- [Install on your runtime](how-to/install-on-your-runtime.md) — runtime-specific install steps for all 16 supported runtimes
|
||||
- [Install a minimal GSD and add skills later](how-to/install-minimal-and-add-skills.md) — install only the core skills, then grow the surface with profiles and `/gsd:surface`
|
||||
- [Discuss a phase](how-to/discuss-a-phase.md) — capture implementation decisions before planning begins
|
||||
- [Plan a phase](how-to/plan-a-phase.md) — run research, decompose work, and verify plan quality
|
||||
|
||||
@@ -212,6 +212,63 @@ All registered hooks are managed by GSD and are removed cleanly on `--uninstall`
|
||||
|
||||
---
|
||||
|
||||
### Kimi CLI
|
||||
|
||||
> **Support boundary — legacy `kimi-cli` vs Kimi Code.** This integration targets the legacy/Python `kimi-cli` custom-agent contract. The `kimi --agent-file <configRoot>/agents/gsd.yaml` launch shown below is accepted by `kimi-cli`. The newer npm Kimi Code (`@moonshot-ai/kimi-code`, e.g. `0.11.0`) does **not** accept `--agent-file`; it discovers skills through fixed skill roots and `--skills-dir`. The generated `/skill:gsd-*` skills work in both, but the custom-agent (`--agent-file`) surface is specific to legacy `kimi-cli`. For Kimi Code, point it at the installed skills root with `--skills-dir <configRoot>/skills` instead of using `--agent-file`.
|
||||
|
||||
```bash
|
||||
npx @opengsd/gsd-core@latest --kimi --global
|
||||
```
|
||||
|
||||
Skills land in Kimi's first existing generic user skills root:
|
||||
|
||||
- `~/.config/agents/skills/gsd-*/SKILL.md` when `~/.config/agents/skills` already exists, or when neither generic root exists yet
|
||||
- `~/.agents/skills/gsd-*/SKILL.md` when `~/.agents/skills` already exists and `~/.config/agents/skills` does not
|
||||
|
||||
Start a new Kimi CLI session after install, then invoke GSD skills with `/skill:gsd-*`, for example:
|
||||
|
||||
```text
|
||||
/skill:gsd-new-project
|
||||
```
|
||||
|
||||
The installer also writes the GSD custom agent definition to the same selected config root: `<configRoot>/agents/gsd.yaml` with its prompt at `<configRoot>/agents/gsd.md`; subagents land under `<configRoot>/agents/subagents/gsd-*.yaml` and `<configRoot>/agents/subagents/gsd-*.md`.
|
||||
|
||||
Kimi custom agents do not auto-activate just because the files exist. Launch Kimi with the generated agent file when you want the GSD agent surface:
|
||||
|
||||
```bash
|
||||
kimi --agent-file ~/.config/agents/agents/gsd.yaml
|
||||
```
|
||||
|
||||
If your machine already uses `~/.agents/skills` and does not have `~/.config/agents/skills`, GSD installs there instead and the launch command becomes:
|
||||
|
||||
```bash
|
||||
kimi --agent-file ~/.agents/agents/gsd.yaml
|
||||
```
|
||||
|
||||
Kimi also discovers user skills from the brand-specific `~/.kimi-code` directory. If your Kimi setup is already centered on `~/.kimi-code`, install there explicitly:
|
||||
|
||||
```bash
|
||||
npx @opengsd/gsd-core@latest --kimi --global --config-dir ~/.kimi-code
|
||||
```
|
||||
|
||||
Then launch the generated agent from that directory:
|
||||
|
||||
```bash
|
||||
kimi --agent-file ~/.kimi-code/agents/gsd.yaml
|
||||
```
|
||||
|
||||
For brand-specific scripted installs, use:
|
||||
|
||||
```bash
|
||||
KIMI_CONFIG_DIR=~/.kimi-code npx @opengsd/gsd-core@latest --kimi --global
|
||||
```
|
||||
|
||||
Avoid arbitrary `KIMI_CONFIG_DIR` roots unless your Kimi configuration also adds the matching `skills/` directory to Kimi's extra skill directories. GSD can write files there, but Kimi will not auto-discover skills outside its documented generic and brand-specific roots without that Kimi-side configuration.
|
||||
|
||||
`--kimi --local` is intentionally deferred and guarded in v1; use the global install path above for Kimi CLI.
|
||||
|
||||
---
|
||||
|
||||
### GitHub Copilot
|
||||
|
||||
```bash
|
||||
|
||||
@@ -345,7 +345,8 @@ only the owned portion.
|
||||
|
||||
## Runtime Configuration Contract Registry
|
||||
|
||||
Last upstream documentation check: 2026-05-11.
|
||||
Last upstream documentation check: 2026-05-11. Kimi CLI was rechecked on
|
||||
2026-06-07 against the MoonshotAI docs.
|
||||
|
||||
This registry is the source of truth for migrations that touch host runtime
|
||||
configuration. Each row records:
|
||||
@@ -367,6 +368,7 @@ for the new shape before changing migration behavior.
|
||||
| OpenCode | Flat markdown commands in `command/gsd-*.md`; agents in `agents/gsd-*.md`; config updates in `opencode.json` or `opencode.jsonc` | Global `OPENCODE_CONFIG_DIR`, `dirname(OPENCODE_CONFIG)`, `XDG_CONFIG_HOME/opencode`, or `~/.config/opencode`; local `./.opencode` | GSD owns generated command/agent files and GSD entries in structured config only | [Config](https://opencode.ai/docs/config/); docs published 2026-05, checked 2026-05-11 |
|
||||
| Kilo | OpenCode-style flat markdown commands in `command/gsd-*.md`; agents in `agents/gsd-*.md`; config updates in `kilo.json` or `kilo.jsonc` | Global `KILO_CONFIG_DIR`, `dirname(KILO_CONFIG)`, `XDG_CONFIG_HOME/kilo`, or `~/.config/kilo`; local `./.kilo` | GSD owns generated command/agent files and GSD entries in structured config only | [Custom subagents](https://docs.kilo.ai/docs/customize/custom-subagents); docs not versioned, checked 2026-05-11 |
|
||||
| Gemini CLI | TOML slash commands in `commands/gsd/*.toml`; agents in `agents/gsd-*.md`; `settings.json` feature flag, hooks, and statusline | Global `GEMINI_CONFIG_DIR` or `~/.gemini`; local `./.gemini` | GSD owns generated commands/agents/hooks and only GSD settings entries; local command copy may be skipped when global GSD commands already exist | [Custom commands](https://google-gemini.github.io/gemini-cli/docs/cli/custom-commands.html), [configuration](https://google-gemini.github.io/gemini-cli/docs/cli/configuration.html); docs checked 2026-05-11 |
|
||||
| Kimi CLI | Agent Skills in `skills/gsd-*/SKILL.md`; explicit custom agent YAML/prompt artifacts in `agents/gsd.yaml`, `agents/gsd.md`, and `agents/subagents/gsd-*`; `gsd-core/` payload files referenced by generated skills; manifest, pristine, local-patch, and migration journal files from the normal installer safety pipeline | Global `KIMI_CONFIG_DIR`, explicit `--config-dir`, or first-existing generic skills root: `~/.config/agents` when `~/.config/agents/skills` exists or no generic skills root exists yet, otherwise `~/.agents` when `~/.agents/skills` exists and `~/.config/agents/skills` does not; `KIMI_CONFIG_DIR` and `--config-dir` are GSD write-location overrides and arbitrary roots require Kimi-side `--skills-dir` or `extra_skill_dirs` configuration for skill discovery; local `--kimi --local` is guarded and writes no project-level artifacts | GSD owns only generated `skills/gsd-*`, `agents/gsd.*`, `agents/subagents/gsd-*`, installed `gsd-core/` payload files, and manifest/preservation/migration records. GSD does not own Kimi config files, hooks, settings, rules, statusline, update-banner registration, or non-GSD Kimi skills/agents. Reinstall/update must preserve locally modified generated Kimi artifacts through manifest-backed `gsd-local-patches/`; uninstall removes only GSD-owned Kimi artifacts and preserves non-GSD user content. | [Agent Skills](https://moonshotai.github.io/kimi-cli/en/customization/skills.html), [Agents and Subagents](https://moonshotai.github.io/kimi-cli/en/customization/agents.html), [Tools](https://moonshotai.github.io/kimi-code/en/reference/tools.html); docs checked 2026-06-07 |
|
||||
| Codex | Skills in `skills/gsd-*/SKILL.md`; agents as source markdown plus per-agent TOML in `agents/`; `[agents.gsd-*]` and hooks in `config.toml` | Global `CODEX_HOME` or `~/.codex`; local `./.codex` | GSD owns generated skills, generated agent TOML, `agents.gsd-*` config sections, `[features].hooks` when added by GSD (canonical; legacy alias `codex_hooks` is recognized and migrated forward, #3566), and GSD hook entries | [Codex config schema](https://developers.openai.com/codex/config-schema.json), [Codex developer docs](https://developers.openai.com/codex/); docs not versioned, checked 2026-05-15; installer compatibility sentinel: Codex 0.130.0 features.hooks key (legacy `codex_hooks` recognized) |
|
||||
| GitHub Copilot | Skills in `skills/gsd-*/SKILL.md`; agents as `.agent.md`; repository instructions in `copilot-instructions.md` | Global `COPILOT_CONFIG_DIR`, `COPILOT_HOME`, or `~/.copilot`; local `./.github` | GSD owns generated skill/agent files and GSD-authored instruction files; no hook/statusline ownership | [Repository custom instructions](https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions), [Copilot CLI custom instructions](https://docs.github.com/en/copilot/how-tos/copilot-cli/add-custom-instructions); GitHub Docs product docs, checked 2026-05-11 |
|
||||
| Antigravity | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; Gemini-style `settings.json` hooks when installed by GSD | Global `ANTIGRAVITY_CONFIG_DIR` or `~/.gemini/antigravity`; local `./.agent` | GSD owns generated skills/agents/hooks and GSD settings entries only | Public Antigravity install/config docs for this file layout were not stable or complete as of 2026-05-11; installer compatibility therefore uses GSD's Gemini-compatible settings policy, documented shim baseline. |
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
## How-to guides
|
||||
|
||||
- [ランタイムへのインストール](how-to/install-on-your-runtime.md) — サポートされる全 15 ランタイムのランタイム別インストール手順
|
||||
- [ランタイムへのインストール](how-to/install-on-your-runtime.md) — サポートされる全 16 ランタイムのランタイム別インストール手順
|
||||
- [フェーズを議論する](how-to/discuss-a-phase.md) — 計画を始める前に実装上の決定事項を記録する
|
||||
- [フェーズを計画する](how-to/plan-a-phase.md) — リサーチを実行し、作業を分解し、計画の品質を検証する
|
||||
- [フェーズを実行する](how-to/execute-a-phase.md) — 新鮮なコンテキストのサブエージェントで並列ウェーブとして計画を実行する
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
## How-to guides
|
||||
|
||||
- [在你的运行时上安装](how-to/install-on-your-runtime.md) — 适用于全部 15 个受支持运行时的安装步骤
|
||||
- [在你的运行时上安装](how-to/install-on-your-runtime.md) — 适用于全部 16 个受支持运行时的安装步骤
|
||||
- [讨论一个阶段](how-to/discuss-a-phase.md) — 在规划开始前记录实现决策
|
||||
- [规划一个阶段](how-to/plan-a-phase.md) — 执行调研、分解工作并验证计划质量
|
||||
- [执行一个阶段](how-to/execute-a-phase.md) — 使用全新上下文的子代理以并行波次运行计划
|
||||
|
||||
@@ -52,6 +52,11 @@
|
||||
"sonnet": null,
|
||||
"haiku": null
|
||||
},
|
||||
"kimi": {
|
||||
"opus": null,
|
||||
"sonnet": null,
|
||||
"haiku": null
|
||||
},
|
||||
"cursor": {
|
||||
"opus": null,
|
||||
"sonnet": null,
|
||||
|
||||
@@ -64,6 +64,9 @@
|
||||
"hermes-agent",
|
||||
"hermes-cli"
|
||||
],
|
||||
"kimi": [
|
||||
"kimi"
|
||||
],
|
||||
"codebuddy": [
|
||||
"codebuddy",
|
||||
"codebuddy-cli"
|
||||
|
||||
13
src/core.cts
13
src/core.cts
@@ -241,7 +241,18 @@ function checkAgentsInstalled(runtime?: string): AgentsInstalledResult {
|
||||
const agentFile = path.join(agentsDir, `${agent}.md`);
|
||||
const agentFileCopilot = path.join(agentsDir, `${agent}.agent.md`);
|
||||
const agentFileCodex = path.join(agentsDir, `${agent}.toml`);
|
||||
if (fs.existsSync(agentFile) || fs.existsSync(agentFileCopilot) || fs.existsSync(agentFileCodex)) {
|
||||
const agentFileKimiYaml = path.join(agentsDir, 'subagents', `${agent}.yaml`);
|
||||
const agentFileKimiPrompt = path.join(agentsDir, 'subagents', `${agent}.md`);
|
||||
const kimiAgentInstalled =
|
||||
resolvedRuntime === 'kimi' &&
|
||||
fs.existsSync(agentFileKimiYaml) &&
|
||||
fs.existsSync(agentFileKimiPrompt);
|
||||
if (
|
||||
fs.existsSync(agentFile) ||
|
||||
fs.existsSync(agentFileCopilot) ||
|
||||
fs.existsSync(agentFileCodex) ||
|
||||
kimiAgentInstalled
|
||||
) {
|
||||
installed.push(agent);
|
||||
} else {
|
||||
missing.push(agent);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import installProfiles = require('./install-profiles.cjs');
|
||||
const {
|
||||
@@ -67,6 +68,7 @@ function getInstallExports(): InstallExports {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ArtifactKindName = 'commands' | 'agents' | 'skills';
|
||||
type KimiArtifactKindName = ArtifactKindName | 'kimi-agents';
|
||||
|
||||
// Mirrors the (unexported) ResolvedProfile in install-profiles.cts.
|
||||
// Must stay in sync if that shape changes.
|
||||
@@ -77,7 +79,7 @@ interface ResolvedProfile {
|
||||
}
|
||||
|
||||
interface ArtifactKind {
|
||||
kind: ArtifactKindName;
|
||||
kind: KimiArtifactKindName;
|
||||
destSubpath: string;
|
||||
prefix: string;
|
||||
stage: (resolvedProfile: ResolvedProfile) => string;
|
||||
@@ -171,7 +173,7 @@ function findAgentsSourceRoot(runtimeConfigDir?: string): string {
|
||||
const ALLOWED_RUNTIMES = new Set([
|
||||
'claude', 'cursor', 'gemini', 'codex', 'copilot', 'antigravity',
|
||||
'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy',
|
||||
'cline', 'opencode', 'kilo',
|
||||
'cline', 'kimi', 'opencode', 'kilo',
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -196,6 +198,50 @@ function agentsKind(destSubpath: string, prefix: string, configDir: string): Art
|
||||
};
|
||||
}
|
||||
|
||||
function kimiAgentsKind(destSubpath: string, prefix: string, configDir: string): ArtifactKind {
|
||||
return {
|
||||
kind: 'kimi-agents',
|
||||
destSubpath,
|
||||
prefix,
|
||||
stage: (resolved) => {
|
||||
const installExports = getInstallExports();
|
||||
const buildKimiAgentArtifacts = installExports['buildKimiAgentArtifacts'] as (opts: {
|
||||
rootAgent?: string;
|
||||
subagents?: Array<{ path: string; content: string }>;
|
||||
}) => {
|
||||
root: { yaml: string; prompt: string };
|
||||
subagents: Array<{ name: string; yaml: string; prompt: string }>;
|
||||
};
|
||||
const stagedAgents = stageAgentsForProfile(findAgentsSourceRoot(configDir), resolved);
|
||||
const subagents: Array<{ path: string; content: string }> = [];
|
||||
if (fs.existsSync(stagedAgents)) {
|
||||
for (const entry of fs.readdirSync(stagedAgents, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||||
const agentPath = path.join(stagedAgents, entry.name);
|
||||
subagents.push({
|
||||
path: path.join('agents', entry.name).replace(/\\/g, '/'),
|
||||
content: fs.readFileSync(agentPath, 'utf8'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rootAgent = `---\nname: gsd\ndescription: Run GSD workflows in Kimi CLI.\ntools: Agent\n---\n\n# GSD for Kimi CLI\n\nCoordinate installed /skill:gsd-* workflows and route work to generated GSD subagents when a workflow requires an agent handoff.\n`;
|
||||
const artifacts = buildKimiAgentArtifacts({ rootAgent, subagents });
|
||||
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-agents-'));
|
||||
installProfiles.STAGED_DIRS.add(stageDir);
|
||||
fs.writeFileSync(path.join(stageDir, 'gsd.yaml'), artifacts.root.yaml);
|
||||
fs.writeFileSync(path.join(stageDir, 'gsd.md'), artifacts.root.prompt);
|
||||
const subagentsDir = path.join(stageDir, 'subagents');
|
||||
fs.mkdirSync(subagentsDir, { recursive: true });
|
||||
for (const artifact of artifacts.subagents) {
|
||||
fs.writeFileSync(path.join(subagentsDir, `${artifact.name}.yaml`), artifact.yaml);
|
||||
fs.writeFileSync(path.join(subagentsDir, `${artifact.name}.md`), artifact.prompt);
|
||||
}
|
||||
return stageDir;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a skills kind descriptor.
|
||||
*
|
||||
@@ -401,6 +447,15 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope:
|
||||
kinds = scope === 'global' ? [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClineSkill', 'cline', configDir, true /* #69 nested */)] : [];
|
||||
break;
|
||||
|
||||
case 'kimi':
|
||||
kinds = scope === 'global'
|
||||
? [
|
||||
skillsKind('skills', 'gsd-', 'convertClaudeCommandToKimiSkill', 'kimi', configDir),
|
||||
kimiAgentsKind('agents', 'gsd', configDir),
|
||||
]
|
||||
: [];
|
||||
break;
|
||||
|
||||
case 'opencode':
|
||||
// OpenCode reads flat slash commands from command/ and on-demand skills
|
||||
// from skills/<name>/SKILL.md (https://opencode.ai/docs/skills). Emit both.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* 'cursor-hooks-json' → early-return after writing .cursor/hooks.json (issue #777).
|
||||
* 'profile-marker-only' → early-return after writing only the profile marker.
|
||||
* - `writesSharedSettings` is the finishInstall writeSettings gate:
|
||||
* false for codex / copilot / kilo / cursor / windsurf / trae / cline (legacy exclusion list).
|
||||
* false for codex / copilot / kilo / cursor / windsurf / trae / cline / kimi (legacy exclusion list).
|
||||
* true for all other runtimes.
|
||||
* - `finishPermissionWriter` names the finishInstall-phase dedicated config writer:
|
||||
* 'opencode' → writes BOTH shared settings AND its own permissions file.
|
||||
@@ -69,13 +69,14 @@ const REGISTRY: Record<string, Readonly<RegistryEntry>> = Object.freeze({
|
||||
cursor: Object.freeze({ installSurface: 'cursor-hooks-json', writesSharedSettings: false, finishPermissionWriter: null } as const),
|
||||
windsurf: Object.freeze({ installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null } as const),
|
||||
trae: Object.freeze({ installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null } as const),
|
||||
kimi: Object.freeze({ installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null } as const),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The complete set of 15 supported runtimes for config-adapter dispatch. */
|
||||
/** The complete set of 16 supported runtimes for config-adapter dispatch. */
|
||||
const ALLOWED_CONFIG_RUNTIMES: ReadonlySet<string> = new Set(Object.keys(REGISTRY));
|
||||
|
||||
/** All valid installSurface values. */
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
* cline — Skills-capable since v3.48.0 (#782). SKILL.md files live at
|
||||
* ~/.cline/skills/<skillName>/SKILL.md (same flat layout as cursor/codex).
|
||||
* .clinerules is also emitted (rules-based compatibility layer).
|
||||
* kimi — Agent Skills are discovered from Kimi's generic user roots:
|
||||
* ~/.config/agents/skills (recommended) then ~/.agents/skills,
|
||||
* with Kimi selecting the first existing generic skills directory.
|
||||
* ~/.kimi-code/skills is brand-specific and can be selected as a
|
||||
* GSD write target with --config-dir or KIMI_CONFIG_DIR.
|
||||
*/
|
||||
|
||||
import os from 'node:os';
|
||||
@@ -35,6 +40,12 @@ export interface ResolveAntigravityOpts {
|
||||
existsSync?: (p: string) => boolean;
|
||||
}
|
||||
|
||||
export interface ResolveKimiOpts {
|
||||
env?: Record<string, string | undefined>;
|
||||
home?: string;
|
||||
existsSync?: (p: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Antigravity global config dir across 1.x and 2.x layouts.
|
||||
*/
|
||||
@@ -58,6 +69,38 @@ export function resolveAntigravityGlobalDir(opts: ResolveAntigravityOpts = {}):
|
||||
return path.join(base, 'antigravity');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Kimi's generic user root using Kimi CLI's documented first-existing
|
||||
* generic skills directory policy:
|
||||
*
|
||||
* 1. ~/.config/agents/skills (recommended)
|
||||
* 2. ~/.agents/skills
|
||||
*
|
||||
* If neither generic skills directory exists yet, install to the recommended
|
||||
* ~/.config/agents root so the generated skills become the first generic
|
||||
* candidate Kimi discovers.
|
||||
*
|
||||
* KIMI_CONFIG_DIR is a GSD installer write-location override. It is not Kimi's
|
||||
* upstream data-root variable, and arbitrary roots are discoverable by Kimi only
|
||||
* when the user also configures Kimi --skills-dir or extra_skill_dirs.
|
||||
*/
|
||||
export function resolveKimiGlobalDir(opts: ResolveKimiOpts = {}): string {
|
||||
const env: Record<string, string | undefined> = opts.env ?? process.env;
|
||||
const home = opts.home ?? os.homedir();
|
||||
const existsSyncFn = opts.existsSync ?? fs.existsSync;
|
||||
|
||||
if (env['KIMI_CONFIG_DIR']) return expandTilde(env['KIMI_CONFIG_DIR']);
|
||||
|
||||
const recommendedRoot = path.join(home, '.config', 'agents');
|
||||
const fallbackRoot = path.join(home, '.agents');
|
||||
const candidates = [recommendedRoot, fallbackRoot];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSyncFn(path.join(candidate, 'skills'))) return candidate;
|
||||
}
|
||||
|
||||
return recommendedRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the global config base directory for the given runtime.
|
||||
* Respects the same env-var overrides as bin/install.js getGlobalDir().
|
||||
@@ -134,6 +177,11 @@ export function getGlobalConfigDir(runtime: string, explicitDir?: string | null)
|
||||
case 'cline':
|
||||
return env['CLINE_CONFIG_DIR'] ? expandTilde(env['CLINE_CONFIG_DIR']) : path.join(home, '.cline');
|
||||
|
||||
// ── Kimi CLI (generic agents user root) ────────────────────────────────
|
||||
case 'kimi': {
|
||||
return resolveKimiGlobalDir({ env, home });
|
||||
}
|
||||
|
||||
// ── OpenCode (XDG) ───────────────────────────────────────────────────────
|
||||
case 'opencode': {
|
||||
if (env['OPENCODE_CONFIG_DIR']) return expandTilde(env['OPENCODE_CONFIG_DIR']);
|
||||
|
||||
@@ -28,6 +28,7 @@ const FALLBACK_ALIASES: Readonly<Record<string, string[]>> = {
|
||||
trae: ['trae', 'trae-cli'],
|
||||
qwen: ['qwen', 'qwen-code', 'qwen-cli'],
|
||||
hermes: ['hermes', 'hermes-agent', 'hermes-cli'],
|
||||
kimi: ['kimi'],
|
||||
codebuddy: ['codebuddy', 'codebuddy-cli'],
|
||||
cline: ['cline', 'cline-cli'],
|
||||
};
|
||||
|
||||
@@ -10,12 +10,17 @@
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
const { installerEnv } = require('./helpers/install-shared.cjs');
|
||||
|
||||
const AGENTS_DIR_NAME = 'agents';
|
||||
const MODEL_PROFILES = require('../gsd-core/bin/lib/model-profiles.cjs').MODEL_PROFILES;
|
||||
const EXPECTED_AGENTS = Object.keys(MODEL_PROFILES);
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const INSTALL_SCRIPT = path.join(ROOT, 'bin', 'install.js');
|
||||
|
||||
/**
|
||||
* Create a fake GSD install directory structure that mirrors what the installer
|
||||
@@ -276,6 +281,76 @@ describe('checkAgentsInstalled: Copilot .agent.md format (#1512)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Kimi agents/subagents detection (#743 review) ─────────────────────────
|
||||
|
||||
describe('checkAgentsInstalled: Kimi agents/subagents layout', () => {
|
||||
test('Kimi install is detected by init, validate agents, and health checks', () => {
|
||||
const tmpDir = createTempProject('gsd-kimi-agent-status-project-');
|
||||
const tmpConfig = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-agent-status-config-'));
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-agent-status-home-'));
|
||||
const env = installerEnv({
|
||||
HOME: tmpHome,
|
||||
USERPROFILE: tmpHome,
|
||||
KIMI_CONFIG_DIR: tmpConfig,
|
||||
});
|
||||
|
||||
try {
|
||||
const installResult = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', tmpConfig, '--no-sdk'],
|
||||
{
|
||||
cwd: tmpDir,
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
},
|
||||
);
|
||||
assert.strictEqual(
|
||||
installResult.status,
|
||||
0,
|
||||
`Kimi install failed\nstdout: ${installResult.stdout}\nstderr: ${installResult.stderr}`,
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ runtime: 'kimi', model_profile: 'balanced' }, null, 2),
|
||||
);
|
||||
|
||||
const initResult = runGsdTools('init new-workspace --raw', tmpDir, env);
|
||||
assert.ok(initResult.success, `init failed: ${initResult.error}`);
|
||||
const initOutput = JSON.parse(initResult.output);
|
||||
assert.strictEqual(initOutput.agent_runtime, 'kimi');
|
||||
assert.strictEqual(initOutput.agents_dir, path.join(tmpConfig, 'agents'));
|
||||
assert.strictEqual(initOutput.agents_installed, true);
|
||||
assert.deepStrictEqual(initOutput.missing_agents, []);
|
||||
|
||||
const validateResult = runGsdTools('validate agents --raw', tmpDir, {
|
||||
...env,
|
||||
GSD_RUNTIME: 'kimi',
|
||||
});
|
||||
assert.ok(validateResult.success, `validate agents failed: ${validateResult.error}`);
|
||||
const validateOutput = JSON.parse(validateResult.output);
|
||||
assert.strictEqual(validateOutput.agents_dir, path.join(tmpConfig, 'agents'));
|
||||
assert.strictEqual(validateOutput.agents_found, true);
|
||||
assert.deepStrictEqual(validateOutput.missing, []);
|
||||
|
||||
const healthResult = runGsdTools('validate health --raw', tmpDir, {
|
||||
...env,
|
||||
GSD_RUNTIME: 'kimi',
|
||||
});
|
||||
assert.ok(healthResult.success, `validate health failed: ${healthResult.error}`);
|
||||
const healthOutput = JSON.parse(healthResult.output);
|
||||
const agentWarnings = (healthOutput.warnings || []).filter(
|
||||
(warning) => warning.code === 'W010' || /GSD agents/i.test(warning.message || ''),
|
||||
);
|
||||
assert.deepStrictEqual(agentWarnings, []);
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
cleanup(tmpConfig);
|
||||
cleanup(tmpHome);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── validate agents subcommand ─────────────────────────────────────────────
|
||||
|
||||
describe('validate agents subcommand (#1371)', () => {
|
||||
|
||||
418
tests/bug-kimi-path-layout-local-guard.test.cjs
Normal file
418
tests/bug-kimi-path-layout-local-guard.test.cjs
Normal file
@@ -0,0 +1,418 @@
|
||||
'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 { spawnSync } = require('node:child_process');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { installerEnv } = require('./helpers/install-shared.cjs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const INSTALL_SCRIPT = path.join(ROOT, 'bin', 'install.js');
|
||||
|
||||
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', () => {
|
||||
withEnv({ KIMI_CONFIG_DIR: '/tmp/custom-kimi-code', XDG_CONFIG_HOME: undefined }, () => {
|
||||
assert.strictEqual(getGlobalConfigDir('kimi'), '/tmp/custom-kimi-code');
|
||||
assert.strictEqual(
|
||||
getGlobalSkillsBase('kimi'),
|
||||
path.join('/tmp/custom-kimi-code', 'skills'),
|
||||
);
|
||||
assert.strictEqual(getGlobalDir('kimi'), '/tmp/custom-kimi-code');
|
||||
});
|
||||
});
|
||||
|
||||
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: '/tmp/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 = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL_SCRIPT, '--kimi', '--global', '--no-sdk'],
|
||||
{
|
||||
cwd: tmpProject,
|
||||
encoding: 'utf8',
|
||||
env: installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome }),
|
||||
},
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
result.status,
|
||||
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', '/tmp/kimi-config', 'global');
|
||||
assert.strictEqual(globalLayout.runtime, 'kimi');
|
||||
assert.strictEqual(globalLayout.configDir, '/tmp/kimi-config');
|
||||
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', '/tmp/kimi-config', '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 = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL_SCRIPT, '--kimi', '--local', '--no-sdk'],
|
||||
{
|
||||
cwd: tmpProject,
|
||||
encoding: 'utf8',
|
||||
env: installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome }),
|
||||
},
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
result.status,
|
||||
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 = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', tmpConfig, '--no-sdk'],
|
||||
{
|
||||
cwd: tmpProject,
|
||||
encoding: 'utf8',
|
||||
env: installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome }),
|
||||
},
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
result.status,
|
||||
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 = spawnSync(process.execPath, installArgs, {
|
||||
cwd: tmpProject,
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
});
|
||||
assert.strictEqual(
|
||||
first.status,
|
||||
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 = spawnSync(process.execPath, installArgs, {
|
||||
cwd: tmpProject,
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
});
|
||||
assert.strictEqual(
|
||||
second.status,
|
||||
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 = spawnSync(process.execPath, installArgs, {
|
||||
cwd: tmpProject,
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
});
|
||||
assert.strictEqual(
|
||||
installResult.status,
|
||||
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 = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', tmpConfig, '--uninstall'],
|
||||
{
|
||||
cwd: tmpProject,
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
},
|
||||
);
|
||||
assert.strictEqual(
|
||||
uninstallResult.status,
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,7 @@ const RUNTIME_META = {
|
||||
cursor: { localDir: '.cursor', globalSuffix: '.cursor' },
|
||||
gemini: { localDir: '.gemini', globalSuffix: '.gemini' },
|
||||
hermes: { localDir: '.hermes', globalSuffix: '.hermes' },
|
||||
kimi: { localDir: '.kimi-code', globalSuffix: path.join('.config', 'agents') },
|
||||
kilo: { localDir: '.kilo', globalSuffix: path.join('.config', 'kilo') },
|
||||
opencode: { localDir: '.opencode', globalSuffix: path.join('.config', 'opencode') },
|
||||
qwen: { localDir: '.qwen', globalSuffix: '.qwen' },
|
||||
|
||||
@@ -50,13 +50,13 @@ const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST });
|
||||
|
||||
const SKILLS_RUNTIMES_LAYOUT = [
|
||||
'claude', 'cursor', 'codex', 'copilot', 'antigravity',
|
||||
'windsurf', 'augment', 'trae', 'qwen', 'codebuddy',
|
||||
'windsurf', 'augment', 'trae', 'qwen', 'kimi', 'codebuddy',
|
||||
];
|
||||
|
||||
const ALL_RUNTIMES_LAYOUT = [
|
||||
'claude', 'cursor', 'gemini', 'codex', 'copilot', 'antigravity',
|
||||
'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy',
|
||||
'cline', 'opencode', 'kilo',
|
||||
'cline', 'kimi', 'opencode', 'kilo',
|
||||
];
|
||||
|
||||
function countPrefixedEntries(destDir, prefix) {
|
||||
@@ -95,6 +95,40 @@ describe('installRuntimeArtifacts — skills runtimes write gsd-prefixed skill d
|
||||
`${runtime}: ${skillsKind.prefix}help/SKILL.md must exist`
|
||||
);
|
||||
|
||||
if (runtime === 'kimi') {
|
||||
const newProjectSkill = path.join(destDir, 'gsd-new-project', 'SKILL.md');
|
||||
assert.ok(fs.existsSync(newProjectSkill), 'kimi: gsd-new-project/SKILL.md must exist');
|
||||
const content = fs.readFileSync(newProjectSkill, 'utf8');
|
||||
assert.match(content, /^name: gsd-new-project$/m);
|
||||
assert.match(content, /\/skill:gsd-new-project/);
|
||||
assert.doesNotMatch(content, /kimi_cli\.tools|system_prompt_path|^version: 1$/m);
|
||||
|
||||
const agentsDir = path.join(configDir, 'agents');
|
||||
const rootYaml = path.join(agentsDir, 'gsd.yaml');
|
||||
const rootPrompt = path.join(agentsDir, 'gsd.md');
|
||||
const executorYaml = path.join(agentsDir, 'subagents', 'gsd-executor.yaml');
|
||||
const executorPrompt = path.join(agentsDir, 'subagents', 'gsd-executor.md');
|
||||
assert.ok(fs.existsSync(rootYaml), 'kimi: agents/gsd.yaml must exist');
|
||||
assert.ok(fs.existsSync(rootPrompt), 'kimi: agents/gsd.md must exist');
|
||||
assert.ok(fs.existsSync(executorYaml), 'kimi: agents/subagents/gsd-executor.yaml must exist');
|
||||
assert.ok(fs.existsSync(executorPrompt), 'kimi: agents/subagents/gsd-executor.md must exist');
|
||||
|
||||
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__/);
|
||||
}
|
||||
|
||||
if (RESOLVED_CORE.skills !== '*') {
|
||||
const prefixedCount = countPrefixedEntries(destDir, skillsKind.prefix || 'gsd-');
|
||||
assert.strictEqual(prefixedCount, RESOLVED_CORE.skills.size,
|
||||
@@ -312,6 +346,14 @@ describe('uninstallRuntimeArtifacts — removes gsd-owned entries, preserves for
|
||||
const foreignDir = path.join(destDir, 'user-custom-skill');
|
||||
fs.mkdirSync(foreignDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(foreignDir, 'SKILL.md'), '# user\n');
|
||||
} else if (kind.kind === 'kimi-agents') {
|
||||
fs.mkdirSync(path.join(destDir, 'subagents'), { recursive: true });
|
||||
fs.writeFileSync(path.join(destDir, 'gsd.yaml'), 'version: 1\n');
|
||||
fs.writeFileSync(path.join(destDir, 'gsd.md'), '# gsd\n');
|
||||
fs.writeFileSync(path.join(destDir, 'subagents', 'gsd-executor.yaml'), 'version: 1\n');
|
||||
fs.writeFileSync(path.join(destDir, 'subagents', 'gsd-executor.md'), '# executor\n');
|
||||
fs.writeFileSync(path.join(destDir, 'user-agent.yaml'), 'version: 1\n');
|
||||
fs.writeFileSync(path.join(destDir, 'subagents', 'user-agent.yaml'), 'version: 1\n');
|
||||
} else {
|
||||
writeCommandEntry(destDir, kind.prefix, 'help');
|
||||
writeCommandEntry(destDir, kind.prefix, 'phase');
|
||||
@@ -327,6 +369,13 @@ describe('uninstallRuntimeArtifacts — removes gsd-owned entries, preserves for
|
||||
assert.ok(!fs.existsSync(path.join(destDir, `${kind.prefix}help`)));
|
||||
assert.ok(!fs.existsSync(path.join(destDir, `${kind.prefix}phase`)));
|
||||
assert.ok(fs.existsSync(path.join(destDir, 'user-custom-skill', 'SKILL.md')));
|
||||
} else if (kind.kind === 'kimi-agents') {
|
||||
assert.ok(!fs.existsSync(path.join(destDir, 'gsd.yaml')));
|
||||
assert.ok(!fs.existsSync(path.join(destDir, 'gsd.md')));
|
||||
assert.ok(!fs.existsSync(path.join(destDir, 'subagents', 'gsd-executor.yaml')));
|
||||
assert.ok(!fs.existsSync(path.join(destDir, 'subagents', 'gsd-executor.md')));
|
||||
assert.ok(fs.existsSync(path.join(destDir, 'user-agent.yaml')));
|
||||
assert.ok(fs.existsSync(path.join(destDir, 'subagents', 'user-agent.yaml')));
|
||||
} else {
|
||||
assert.ok(!fs.existsSync(path.join(destDir, `${kind.prefix}help.md`)));
|
||||
assert.ok(!fs.existsSync(path.join(destDir, `${kind.prefix}phase.md`)));
|
||||
|
||||
@@ -92,7 +92,7 @@ describe('getGlobalConfigDir — all runtimes default paths', () => {
|
||||
}
|
||||
});
|
||||
|
||||
for (const runtime of allRuntimes) {
|
||||
for (const runtime of allRuntimes.filter(runtime => runtime !== 'kimi')) {
|
||||
test(`getGlobalConfigDir('${runtime}') returns expected home-relative path`, () => {
|
||||
const expected = path.join(os.homedir(), RUNTIME_META[runtime].globalSuffix);
|
||||
assert.strictEqual(getGlobalConfigDir(runtime), expected);
|
||||
@@ -676,17 +676,17 @@ describe('Kilo source integration assertions', () => {
|
||||
path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'update-context.cjs'), 'utf8');
|
||||
|
||||
test('--kilo flag parsing exists', () => {
|
||||
assert.ok(src.includes("args.includes('--kilo')"));
|
||||
assert.ok(src.includes("runtimeArgs.includes('--kilo')"));
|
||||
});
|
||||
|
||||
test('runtimeMap has Kilo as option 11', () => {
|
||||
assert.strictEqual(runtimeMap['11'], 'kilo');
|
||||
test('runtimeMap has Kilo as option 12 after Kimi', () => {
|
||||
assert.strictEqual(runtimeMap['12'], 'kilo');
|
||||
});
|
||||
|
||||
test('prompt text shows Kilo above OpenCode without marketing copy', () => {
|
||||
const plain = stripAnsi(buildRuntimePromptText());
|
||||
assert.ok(/\b11\)\s*Kilo\b/.test(plain));
|
||||
assert.ok(plain.indexOf('11) Kilo') < plain.indexOf('OpenCode'));
|
||||
assert.ok(/\b12\)\s*Kilo\b/.test(plain));
|
||||
assert.ok(plain.indexOf('12) Kilo') < plain.indexOf('OpenCode'));
|
||||
assert.ok(!plain.includes('the #1 AI coding platform on OpenRouter'));
|
||||
});
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ const RUNTIME_INSTALL_CONTRACTS = {
|
||||
cursor: { surface: 'flat-skills', settings: false, packageJson: false },
|
||||
gemini: { surface: 'commands-gsd', settings: true, packageJson: true },
|
||||
hermes: { surface: 'hermes-skills', settings: true, packageJson: true },
|
||||
kimi: { surface: 'kimi-skills-agents', settings: false, packageJson: false },
|
||||
kilo: { surface: 'flat-command', settings: false, packageJson: true },
|
||||
opencode: { surface: 'flat-command', settings: true, packageJson: true },
|
||||
qwen: { surface: 'flat-skills', settings: true, packageJson: true },
|
||||
@@ -179,27 +180,35 @@ function assertFreshInstallContract(runtime, targetDir) {
|
||||
const contract = RUNTIME_INSTALL_CONTRACTS[runtime];
|
||||
assert.ok(contract, `missing runtime install contract for ${runtime}`);
|
||||
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(targetDir, 'gsd-core', 'VERSION'), 'utf8'),
|
||||
pkg.version,
|
||||
`${runtime} should install the package VERSION`
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(targetDir, 'gsd-core', 'bin', 'gsd-tools.cjs')),
|
||||
`${runtime} should install the GSD tool payload`
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(targetDir, 'gsd-file-manifest.json')),
|
||||
`${runtime} should write the install manifest`
|
||||
);
|
||||
if (contract.workflowPayload !== false) {
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(targetDir, 'gsd-core', 'VERSION'), 'utf8'),
|
||||
pkg.version,
|
||||
`${runtime} should install the package VERSION`
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(targetDir, 'gsd-core', 'bin', 'gsd-tools.cjs')),
|
||||
`${runtime} should install the GSD tool payload`
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(targetDir, 'gsd-file-manifest.json')),
|
||||
`${runtime} should write the install manifest`
|
||||
);
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(targetDir, 'gsd-file-manifest.json'), 'utf8'));
|
||||
assert.equal(manifest.version, pkg.version, `${runtime} manifest should record the package version`);
|
||||
assert.equal(manifest.mode, 'full', `${runtime} manifest should record a full install`);
|
||||
assert.ok(
|
||||
manifest.files['gsd-core/VERSION'],
|
||||
`${runtime} manifest should track the installed VERSION file`
|
||||
);
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(targetDir, 'gsd-file-manifest.json'), 'utf8'));
|
||||
assert.equal(manifest.version, pkg.version, `${runtime} manifest should record the package version`);
|
||||
assert.equal(manifest.mode, 'full', `${runtime} manifest should record a full install`);
|
||||
assert.ok(
|
||||
manifest.files['gsd-core/VERSION'],
|
||||
`${runtime} manifest should track the installed VERSION file`
|
||||
);
|
||||
} else {
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(targetDir, 'gsd-core')),
|
||||
false,
|
||||
`${runtime} should not install the GSD workflow payload`
|
||||
);
|
||||
}
|
||||
|
||||
if (contract.surface === 'flat-skills') {
|
||||
// Pre-#3562: codex was special-cased to expect zero gsd-* skill dirs
|
||||
@@ -231,6 +240,20 @@ function assertFreshInstallContract(runtime, targetDir) {
|
||||
listDirNames(targetDir, path.join('commands', 'gsd')).length > 0,
|
||||
`${runtime} should install commands/gsd entries`
|
||||
);
|
||||
} else if (contract.surface === 'kimi-skills-agents') {
|
||||
assertHasGsdDirectory(targetDir, 'skills');
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(targetDir, 'agents', 'gsd.yaml')),
|
||||
'Kimi should install the root agent YAML'
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(targetDir, 'agents', 'gsd.md')),
|
||||
'Kimi should install the root agent prompt'
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(targetDir, 'agents', 'subagents', 'gsd-executor.yaml')),
|
||||
'Kimi should install GSD subagent YAML'
|
||||
);
|
||||
} else if (contract.surface === 'clinerules') {
|
||||
// #787: Cline now uses the .clinerules/ directory form (rules at gsd.md).
|
||||
assert.match(
|
||||
@@ -240,10 +263,12 @@ function assertFreshInstallContract(runtime, targetDir) {
|
||||
);
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
listDirNames(targetDir, 'agents').some((name) => name.startsWith('gsd-')),
|
||||
`${runtime} full install should install agents`
|
||||
);
|
||||
if (contract.surface !== 'kimi-skills-agents') {
|
||||
assert.ok(
|
||||
listDirNames(targetDir, 'agents').some((name) => name.startsWith('gsd-')),
|
||||
`${runtime} full install should install agents`
|
||||
);
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(targetDir, 'settings.json')),
|
||||
@@ -428,7 +453,11 @@ describe('installer migration install integration', { concurrency: false }, () =
|
||||
assert.match(output, /Installing for /);
|
||||
assert.match(output, /Installer migrations/);
|
||||
assert.match(output, /removed\s+hooks\/statusline\.js/);
|
||||
assert.match(output, /Installed workflow assets/);
|
||||
if (runtime === 'kimi') {
|
||||
assert.match(output, /Generated Kimi root agent/);
|
||||
} else {
|
||||
assert.match(output, /Installed workflow assets/);
|
||||
}
|
||||
assert.match(output, /Done!/);
|
||||
assert.equal(fs.existsSync(path.join(targetDir, 'hooks/statusline.js')), false);
|
||||
|
||||
|
||||
159
tests/kimi-agent-converter.test.cjs
Normal file
159
tests/kimi-agent-converter.test.cjs
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
94
tests/kimi-skill-converter.test.cjs
Normal file
94
tests/kimi-skill-converter.test.cjs
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Kimi CLI skill converter tests.
|
||||
*
|
||||
* Kimi Agent Skills use gsd-<command>/SKILL.md directories with lowercase
|
||||
* hyphenated frontmatter names and /skill:<name> invocation syntax.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
convertClaudeCommandToKimiSkill,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
function sampleCommand() {
|
||||
return [
|
||||
'---',
|
||||
'name: gsd:new-project',
|
||||
'description: Create a new GSD project',
|
||||
'allowed-tools:',
|
||||
' - Read',
|
||||
' - Bash',
|
||||
'agent: gsd-planner',
|
||||
'tools: Read, Bash',
|
||||
'model: opus',
|
||||
'color: blue',
|
||||
'skills:',
|
||||
' - gsd-planner-workflow',
|
||||
'hooks:',
|
||||
' PostToolUse:',
|
||||
' - matcher: "Write|Edit"',
|
||||
'---',
|
||||
'',
|
||||
'Invoke /gsd:new-project from slash form.',
|
||||
'Invoke gsd:new-project from bare colon form.',
|
||||
'Invoke /gsd-new-project from hyphen slash form.',
|
||||
'Invoke $gsd-new-project from shell-style form.',
|
||||
'Do not rewrite /gsd-tools, gsd-new-projector, or $gsd-planets.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
describe('convertClaudeCommandToKimiSkill', () => {
|
||||
test('emits Kimi SKILL.md frontmatter with name and description only', () => {
|
||||
const result = convertClaudeCommandToKimiSkill(sampleCommand(), 'gsd-new-project');
|
||||
|
||||
assert.ok(result.startsWith('---\n'), 'frontmatter starts with ---');
|
||||
assert.ok(result.includes('\n---\n'), 'frontmatter closes with ---');
|
||||
assert.ok(result.includes('name: gsd-new-project'), 'frontmatter name uses Kimi-safe hyphen form');
|
||||
assert.ok(
|
||||
result.includes('description: "Create a new GSD project"'),
|
||||
'description is preserved from source frontmatter'
|
||||
);
|
||||
|
||||
for (const unsupported of [
|
||||
'allowed-tools',
|
||||
'agent',
|
||||
'tools',
|
||||
'model',
|
||||
'color',
|
||||
'skills',
|
||||
'hooks',
|
||||
]) {
|
||||
assert.ok(!result.includes(`${unsupported}:`), `${unsupported}: is stripped from Kimi frontmatter`);
|
||||
}
|
||||
});
|
||||
|
||||
test('rewrites GSD command references to Kimi skill invocation syntax', () => {
|
||||
const result = convertClaudeCommandToKimiSkill(sampleCommand(), 'gsd-new-project');
|
||||
|
||||
assert.equal(
|
||||
(result.match(/\/skill:gsd-new-project/g) || []).length,
|
||||
5,
|
||||
'self invocation hint and all supported source invocation forms are emitted'
|
||||
);
|
||||
assert.ok(!result.includes('/gsd:new-project'), 'slash colon form is removed');
|
||||
assert.ok(!result.includes('gsd:new-project'), 'bare colon form is removed');
|
||||
assert.ok(!result.includes('/gsd-new-project'), 'slash hyphen form is removed');
|
||||
assert.ok(!result.includes('$gsd-new-project'), 'shell-style form is removed');
|
||||
});
|
||||
|
||||
test('does not rewrite unrelated words or introduce Phase 3 artifacts', () => {
|
||||
const result = convertClaudeCommandToKimiSkill(sampleCommand(), 'gsd-new-project');
|
||||
|
||||
assert.ok(result.includes('/gsd-tools'), 'non-command slash token is preserved');
|
||||
assert.ok(result.includes('gsd-new-projector'), 'longer hyphenated word is preserved');
|
||||
assert.ok(result.includes('$gsd-planets'), 'unrelated shell-style token is preserved');
|
||||
assert.ok(!result.includes('type: flow'), 'Phase 2 does not create Kimi flow skills');
|
||||
assert.ok(!result.includes('kimi_cli.tools'), 'Phase 2 does not emit Kimi tool module paths');
|
||||
assert.ok(!result.includes('system_prompt_path'), 'Phase 2 does not emit custom agent YAML');
|
||||
assert.ok(!result.includes('version: 1'), 'Phase 2 does not emit Kimi agent YAML markers');
|
||||
});
|
||||
});
|
||||
107
tests/kimi-tool-mapping.test.cjs
Normal file
107
tests/kimi-tool-mapping.test.cjs
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Kimi CLI tool module mapper tests.
|
||||
*
|
||||
* Kimi custom agent YAML requires tool module paths such as
|
||||
* `kimi_cli.tools.file:ReadFile`; raw Claude tool names and MCP-managed tools
|
||||
* must not be emitted.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
convertKimiToolName,
|
||||
mapClaudeToolsToKimiTools,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
describe('convertKimiToolName', () => {
|
||||
test('maps Claude and GSD tool names to documented Kimi module paths', () => {
|
||||
const expectedMappings = new Map([
|
||||
['Read', 'kimi_cli.tools.file:ReadFile'],
|
||||
['Write', 'kimi_cli.tools.file:WriteFile'],
|
||||
['Edit', 'kimi_cli.tools.file:StrReplaceFile'],
|
||||
['MultiEdit', 'kimi_cli.tools.file:StrReplaceFile'],
|
||||
['Bash', 'kimi_cli.tools.shell:Shell'],
|
||||
['Grep', 'kimi_cli.tools.file:Grep'],
|
||||
['Glob', 'kimi_cli.tools.file:Glob'],
|
||||
['Agent', 'kimi_cli.tools.agent:Agent'],
|
||||
['Task', 'kimi_cli.tools.agent:Agent'],
|
||||
['AskUserQuestion', 'kimi_cli.tools.ask_user:AskUserQuestion'],
|
||||
['TodoWrite', 'kimi_cli.tools.todo:SetTodoList'],
|
||||
['WebSearch', 'kimi_cli.tools.web:SearchWeb'],
|
||||
['WebFetch', 'kimi_cli.tools.web:FetchURL'],
|
||||
['TaskList', 'kimi_cli.tools.background:TaskList'],
|
||||
['TaskOutput', 'kimi_cli.tools.background:TaskOutput'],
|
||||
['TaskStop', 'kimi_cli.tools.background:TaskStop'],
|
||||
]);
|
||||
|
||||
for (const [claudeTool, kimiTool] of expectedMappings) {
|
||||
assert.equal(convertKimiToolName(claudeTool), kimiTool, `${claudeTool} maps to Kimi module path`);
|
||||
}
|
||||
});
|
||||
|
||||
test('returns null for MCP-managed and unsupported tools', () => {
|
||||
assert.equal(convertKimiToolName('mcp__context7__resolve-library-id'), null);
|
||||
assert.equal(convertKimiToolName('DefinitelyUnknownTool'), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapClaudeToolsToKimiTools', () => {
|
||||
test('deduplicates mapped tools while preserving first-seen order', () => {
|
||||
const result = mapClaudeToolsToKimiTools([
|
||||
'Read',
|
||||
'ReadFile',
|
||||
'Edit',
|
||||
'MultiEdit',
|
||||
'Bash',
|
||||
'Task',
|
||||
'Agent',
|
||||
'WebFetch',
|
||||
]);
|
||||
|
||||
assert.deepEqual(result.tools, [
|
||||
'kimi_cli.tools.file:ReadFile',
|
||||
'kimi_cli.tools.file:StrReplaceFile',
|
||||
'kimi_cli.tools.shell:Shell',
|
||||
'kimi_cli.tools.agent:Agent',
|
||||
'kimi_cli.tools.web:FetchURL',
|
||||
]);
|
||||
assert.deepEqual(result.diagnostics, []);
|
||||
});
|
||||
|
||||
test('excludes MCP-managed tools with diagnostics', () => {
|
||||
const result = mapClaudeToolsToKimiTools([
|
||||
'Read',
|
||||
'mcp__context7__resolve-library-id',
|
||||
]);
|
||||
|
||||
assert.deepEqual(result.tools, ['kimi_cli.tools.file:ReadFile']);
|
||||
assert.ok(
|
||||
result.diagnostics.some((item) =>
|
||||
item.reason === 'mcp_managed' &&
|
||||
item.code === 'kimi_mcp_tool_excluded' &&
|
||||
item.value === 'mcp__context7__resolve-library-id'
|
||||
),
|
||||
'MCP-managed exclusion is diagnosed'
|
||||
);
|
||||
});
|
||||
|
||||
test('excludes unsupported tools with diagnostics', () => {
|
||||
const result = mapClaudeToolsToKimiTools([
|
||||
'Read',
|
||||
'DefinitelyUnknownTool',
|
||||
]);
|
||||
|
||||
assert.deepEqual(result.tools, ['kimi_cli.tools.file:ReadFile']);
|
||||
assert.ok(
|
||||
result.diagnostics.some((item) =>
|
||||
item.reason === 'unsupported_tool' &&
|
||||
item.code === 'kimi_unsupported_tool' &&
|
||||
item.value === 'DefinitelyUnknownTool'
|
||||
),
|
||||
'unsupported tool exclusion is diagnosed'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { catalog, KNOWN_RUNTIMES } = require('../gsd-core/bin/lib/model-catalog.cjs');
|
||||
const { allRuntimes } = require('../bin/install.js');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const SETTINGS_ADVANCED = fs.readFileSync(path.join(ROOT, 'gsd-core', 'workflows', 'settings-advanced.md'), 'utf8');
|
||||
@@ -17,9 +18,14 @@ const CONFIG_DOC = fs.readFileSync(path.join(ROOT, 'docs', 'CONFIGURATION.md'),
|
||||
describe('model catalog runtime defaults parity (#3229)', () => {
|
||||
test('known runtimes include hermes and match catalog keys', () => {
|
||||
assert.ok(KNOWN_RUNTIMES.has('hermes'));
|
||||
assert.ok(KNOWN_RUNTIMES.has('kimi'));
|
||||
assert.deepStrictEqual([...KNOWN_RUNTIMES].sort(), Object.keys(catalog.runtimeTierDefaults).sort());
|
||||
});
|
||||
|
||||
test('installer-supported runtimes are all known to the model catalog', () => {
|
||||
assert.deepStrictEqual([...allRuntimes].sort(), [...KNOWN_RUNTIMES].sort());
|
||||
});
|
||||
|
||||
test('settings-advanced runtime defaults table matches catalog for concrete runtimes', () => {
|
||||
for (const [runtime, tiers] of Object.entries(catalog.runtimeTierDefaults)) {
|
||||
if (!tiers.opus) continue; // Group B runtimes intentionally have no built-ins
|
||||
|
||||
@@ -18,6 +18,7 @@ const assert = require('node:assert/strict');
|
||||
const {
|
||||
runtimeMap,
|
||||
allRuntimes,
|
||||
selectRuntimesFromArgs,
|
||||
parseRuntimeInput,
|
||||
buildRuntimePromptText,
|
||||
} = require('../bin/install.js');
|
||||
@@ -48,7 +49,7 @@ describe('multi-runtime selection parsing', () => {
|
||||
|
||||
test('space-separated choices return multiple runtimes', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('1 7 9'), ['claude', 'copilot', 'gemini']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('8 11'), ['cursor', 'kilo']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('8 12'), ['cursor', 'kilo']);
|
||||
});
|
||||
|
||||
test('mixed comma and space separators work', () => {
|
||||
@@ -61,38 +62,42 @@ describe('multi-runtime selection parsing', () => {
|
||||
});
|
||||
|
||||
test('single choice for kilo', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('11'), ['kilo']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('12'), ['kilo']);
|
||||
});
|
||||
|
||||
test('single choice for opencode', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('12'), ['opencode']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('13'), ['opencode']);
|
||||
});
|
||||
|
||||
test('single choice for qwen', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('13'), ['qwen']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('14'), ['qwen']);
|
||||
});
|
||||
|
||||
test('single choice for trae', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('14'), ['trae']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('15'), ['trae']);
|
||||
});
|
||||
|
||||
test('single choice for windsurf', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('15'), ['windsurf']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('16'), ['windsurf']);
|
||||
});
|
||||
|
||||
test('choice 16 returns all runtimes', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('16'), allRuntimes);
|
||||
test('single choice for kimi', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('11'), ['kimi']);
|
||||
});
|
||||
|
||||
test('choice 16 returns all runtimes when mixed with separators or other tokens', () => {
|
||||
// CR feedback: tokenized inputs that include 16 (e.g. trailing comma, or
|
||||
test('choice 17 returns all runtimes', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('17'), allRuntimes);
|
||||
});
|
||||
|
||||
test('choice 17 returns all runtimes when mixed with separators or other tokens', () => {
|
||||
// CR feedback: tokenized inputs that include 17 (e.g. trailing comma, or
|
||||
// alongside other choices) must still expand to all-runtimes — previously
|
||||
// only the bare "16" matched, so "16," or "16 1" silently installed a
|
||||
// only the bare all-runtimes option matched, so "17," or "17 1" silently installed a
|
||||
// subset.
|
||||
assert.deepStrictEqual(parseRuntimeInput('16,'), allRuntimes);
|
||||
assert.deepStrictEqual(parseRuntimeInput('16 1'), allRuntimes);
|
||||
assert.deepStrictEqual(parseRuntimeInput('1,16'), allRuntimes);
|
||||
assert.deepStrictEqual(parseRuntimeInput(' 16 '), allRuntimes);
|
||||
assert.deepStrictEqual(parseRuntimeInput('17,'), allRuntimes);
|
||||
assert.deepStrictEqual(parseRuntimeInput('17 1'), allRuntimes);
|
||||
assert.deepStrictEqual(parseRuntimeInput('1,17'), allRuntimes);
|
||||
assert.deepStrictEqual(parseRuntimeInput(' 17 '), allRuntimes);
|
||||
});
|
||||
|
||||
test('empty input defaults to claude', () => {
|
||||
@@ -101,13 +106,13 @@ describe('multi-runtime selection parsing', () => {
|
||||
});
|
||||
|
||||
test('invalid choices are ignored, falls back to claude if all invalid', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('17'), ['claude']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('18'), ['claude']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('0'), ['claude']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('abc'), ['claude']);
|
||||
});
|
||||
|
||||
test('invalid choices mixed with valid are filtered out', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('1,17,7'), ['claude', 'copilot']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('1,18,7'), ['claude', 'copilot']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('abc 3 xyz'), ['augment']);
|
||||
});
|
||||
|
||||
@@ -118,7 +123,7 @@ describe('multi-runtime selection parsing', () => {
|
||||
|
||||
test('preserves selection order', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('9,1,7'), ['gemini', 'claude', 'copilot']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('11,2,8'), ['kilo', 'antigravity', 'cursor']);
|
||||
assert.deepStrictEqual(parseRuntimeInput('12,2,8'), ['kilo', 'antigravity', 'cursor']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,16 +139,17 @@ describe('install.js exports multi-select runtime metadata', () => {
|
||||
'8': 'cursor',
|
||||
'9': 'gemini',
|
||||
'10': 'hermes',
|
||||
'11': 'kilo',
|
||||
'12': 'opencode',
|
||||
'13': 'qwen',
|
||||
'14': 'trae',
|
||||
'15': 'windsurf',
|
||||
'11': 'kimi',
|
||||
'12': 'kilo',
|
||||
'13': 'opencode',
|
||||
'14': 'qwen',
|
||||
'15': 'trae',
|
||||
'16': 'windsurf',
|
||||
};
|
||||
const expectedRuntimes = [
|
||||
'claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex',
|
||||
'copilot', 'cursor', 'gemini', 'hermes', 'kilo', 'opencode', 'qwen',
|
||||
'trae', 'windsurf',
|
||||
'copilot', 'cursor', 'gemini', 'hermes', 'kimi', 'kilo', 'opencode',
|
||||
'qwen', 'trae', 'windsurf',
|
||||
];
|
||||
|
||||
test('runtimeMap exports every option key bound to the right runtime', () => {
|
||||
@@ -160,20 +166,35 @@ describe('install.js exports multi-select runtime metadata', () => {
|
||||
'allRuntimes has no duplicates');
|
||||
});
|
||||
|
||||
test('"All" shortcut (option 16) selects every runtime', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('16'), allRuntimes);
|
||||
test('"All" shortcut (option 17) selects every runtime', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('17'), allRuntimes);
|
||||
});
|
||||
|
||||
test('prompt lists Hermes Agent (10), Qwen Code (13), Trae (14), and All (16)', () => {
|
||||
test('--kimi flag selects Kimi without interactive prompt', () => {
|
||||
assert.deepStrictEqual(selectRuntimesFromArgs(['--kimi']), ['kimi']);
|
||||
});
|
||||
|
||||
test('--all flag includes Kimi exactly once', () => {
|
||||
const selected = selectRuntimesFromArgs(['--all']);
|
||||
assert.ok(selected.includes('kimi'), '--all includes kimi');
|
||||
assert.strictEqual(selected.filter((runtime) => runtime === 'kimi').length, 1,
|
||||
'--all includes kimi exactly once');
|
||||
});
|
||||
|
||||
test('prompt lists Hermes Agent (10), Kimi (11), Qwen Code (14), Trae (15), and All (17)', () => {
|
||||
const prompt = stripAnsi(buildRuntimePromptText());
|
||||
assert.ok(/\b10\)\s*Hermes Agent\b/.test(prompt),
|
||||
'prompt lists Hermes Agent as option 10');
|
||||
assert.ok(/\b13\)\s*Qwen Code\b/.test(prompt),
|
||||
'prompt lists Qwen Code as option 13');
|
||||
assert.ok(/\b14\)\s*Trae\b/.test(prompt),
|
||||
'prompt lists Trae as option 14');
|
||||
assert.ok(/\b16\)\s*All\b/.test(prompt),
|
||||
'prompt lists All as option 16');
|
||||
assert.ok(/\b11\)\s*Kimi\b/.test(prompt),
|
||||
'prompt lists Kimi as option 11');
|
||||
assert.ok(/Kimi\s+\(~\/\.config\/agents, then ~\/\.agents if existing\)/.test(prompt),
|
||||
'prompt shows the Kimi first-existing generic root policy');
|
||||
assert.ok(/\b14\)\s*Qwen Code\b/.test(prompt),
|
||||
'prompt lists Qwen Code as option 14');
|
||||
assert.ok(/\b15\)\s*Trae\b/.test(prompt),
|
||||
'prompt lists Trae as option 15');
|
||||
assert.ok(/\b17\)\s*All\b/.test(prompt),
|
||||
'prompt lists All as option 17');
|
||||
});
|
||||
|
||||
test('prompt text shows multi-select hint', () => {
|
||||
|
||||
@@ -23,6 +23,8 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
||||
const installProfiles = require('../gsd-core/bin/lib/install-profiles.cjs');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const FAKE_DIR = '/tmp/fake-config-dir';
|
||||
|
||||
@@ -189,6 +191,28 @@ describe('resolveRuntimeArtifactLayout — qwen', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRuntimeArtifactLayout — kimi', () => {
|
||||
test('returns global skills layout and guarded empty local layout for kimi', () => {
|
||||
const globalLayout = resolveRuntimeArtifactLayout('kimi', FAKE_DIR, 'global');
|
||||
assert.strictEqual(globalLayout.runtime, 'kimi');
|
||||
assert.strictEqual(globalLayout.configDir, FAKE_DIR);
|
||||
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', FAKE_DIR, 'local');
|
||||
assert.strictEqual(localLayout.runtime, 'kimi');
|
||||
assert.strictEqual(localLayout.configDir, FAKE_DIR);
|
||||
assert.deepStrictEqual(localLayout.kinds, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRuntimeArtifactLayout — hermes', () => {
|
||||
test('returns correct layout for hermes', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('hermes', FAKE_DIR);
|
||||
@@ -444,6 +468,87 @@ describe('stage — skills kind (claude global)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('stage — skills kind (kimi global)', () => {
|
||||
test('stage returns Kimi SKILL.md dirs and agent YAML/prompt artifacts', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('kimi', FAKE_STAGE_DIR, 'global');
|
||||
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
|
||||
assert.ok(skillsKind, 'should have a skills kind');
|
||||
|
||||
const stagedDir = skillsKind.stage(PROFILE_CORE);
|
||||
assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist');
|
||||
const skillMd = path.join(stagedDir, 'gsd-new-project', 'SKILL.md');
|
||||
assert.ok(fs.existsSync(skillMd), 'gsd-new-project/SKILL.md must exist');
|
||||
const content = fs.readFileSync(skillMd, 'utf8');
|
||||
assert.match(content, /^name: gsd-new-project$/m);
|
||||
assert.match(content, /\/skill:gsd-new-project/);
|
||||
assert.doesNotMatch(content, /kimi_cli\.tools|system_prompt_path|^version: 1$/m);
|
||||
|
||||
const agentsKind = layout.kinds.find(k => k.kind === 'kimi-agents');
|
||||
assert.ok(agentsKind, 'should have a kimi-agents kind');
|
||||
|
||||
const stagedAgentsDir = agentsKind.stage(PROFILE_FULL);
|
||||
const rootYamlPath = path.join(stagedAgentsDir, 'gsd.yaml');
|
||||
const rootPromptPath = path.join(stagedAgentsDir, 'gsd.md');
|
||||
const executorYamlPath = path.join(stagedAgentsDir, 'subagents', 'gsd-executor.yaml');
|
||||
const executorPromptPath = path.join(stagedAgentsDir, 'subagents', 'gsd-executor.md');
|
||||
assert.ok(fs.existsSync(rootYamlPath), 'agents/gsd.yaml must be staged');
|
||||
assert.ok(fs.existsSync(rootPromptPath), 'agents/gsd.md must be staged');
|
||||
assert.ok(fs.existsSync(executorYamlPath), 'agents/subagents/gsd-executor.yaml must be staged');
|
||||
assert.ok(fs.existsSync(executorPromptPath), 'agents/subagents/gsd-executor.md must be staged');
|
||||
|
||||
const rootYaml = fs.readFileSync(rootYamlPath, 'utf8');
|
||||
assert.match(rootYaml, /^version: 1$/m);
|
||||
assert.match(rootYaml, /^agent:$/m);
|
||||
assert.match(rootYaml, /extend: default/);
|
||||
assert.match(rootYaml, /system_prompt_path: \.\/gsd\.md/);
|
||||
assert.match(rootYaml, /tools:/);
|
||||
assert.match(rootYaml, /subagents:/);
|
||||
assert.match(rootYaml, /kimi_cli\.tools\./);
|
||||
assert.doesNotMatch(rootYaml, /mcp__/);
|
||||
|
||||
const executorYaml = fs.readFileSync(executorYamlPath, 'utf8');
|
||||
assert.match(executorYaml, /system_prompt_path: \.\/gsd-executor\.md/);
|
||||
assert.match(executorYaml, /kimi_cli\.tools\./);
|
||||
assert.doesNotMatch(executorYaml, /mcp__/);
|
||||
});
|
||||
|
||||
test('tracks Kimi agent staging dir before writing artifacts', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('kimi', FAKE_STAGE_DIR, 'global');
|
||||
const agentsKind = layout.kinds.find(k => k.kind === 'kimi-agents');
|
||||
assert.ok(agentsKind, 'should have a kimi-agents kind');
|
||||
|
||||
const originalWriteFileSync = fs.writeFileSync;
|
||||
const before = new Set(installProfiles.STAGED_DIRS);
|
||||
let added = [];
|
||||
|
||||
try {
|
||||
fs.writeFileSync = function writeFileSyncWithInjectedFailure(file, ...args) {
|
||||
const filePath = String(file);
|
||||
if (filePath.includes('gsd-kimi-agents-') && path.basename(filePath) === 'gsd.yaml') {
|
||||
throw new Error('forced Kimi stage write failure');
|
||||
}
|
||||
return originalWriteFileSync.call(this, file, ...args);
|
||||
};
|
||||
|
||||
assert.throws(
|
||||
() => agentsKind.stage(PROFILE_FULL),
|
||||
/forced Kimi stage write failure/
|
||||
);
|
||||
|
||||
added = [...installProfiles.STAGED_DIRS]
|
||||
.filter(dir => !before.has(dir) && path.basename(dir).startsWith('gsd-kimi-agents-'));
|
||||
assert.strictEqual(added.length, 1, 'partially written Kimi stage dir must be tracked for cleanup');
|
||||
assert.ok(fs.existsSync(added[0]), 'tracked partial Kimi stage dir should exist');
|
||||
} finally {
|
||||
fs.writeFileSync = originalWriteFileSync;
|
||||
for (const dir of added) {
|
||||
cleanup(dir);
|
||||
installProfiles.STAGED_DIRS.delete(dir);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('stage — opencode commands kind', () => {
|
||||
test('opencode stage returns directory with .md files for selected skills', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('opencode', FAKE_STAGE_DIR);
|
||||
|
||||
@@ -34,6 +34,7 @@ const EXPECTED_TABLE = [
|
||||
{ runtime: 'cursor', installSurface: 'cursor-hooks-json', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
{ runtime: 'windsurf', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
{ runtime: 'trae', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
{ runtime: 'kimi', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -97,7 +98,7 @@ describe('resolveRuntimeConfigIntent — unknown runtime throws TypeError', () =
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('writesSharedSettings exclusion equivalence', () => {
|
||||
const EXPECTED_FALSE_SET = new Set(['codex', 'copilot', 'kilo', 'cursor', 'windsurf', 'trae', 'cline']);
|
||||
const EXPECTED_FALSE_SET = new Set(['codex', 'copilot', 'kilo', 'cursor', 'windsurf', 'trae', 'cline', 'kimi']);
|
||||
|
||||
test('runtimes with writesSharedSettings===false are exactly the exclusion set', () => {
|
||||
const falseRuntimes = EXPECTED_TABLE
|
||||
@@ -172,6 +173,10 @@ describe('installSurface correctness', () => {
|
||||
assert.strictEqual(resolveRuntimeConfigIntent('trae').installSurface, 'profile-marker-only');
|
||||
});
|
||||
|
||||
test('kimi -> "profile-marker-only"', () => {
|
||||
assert.strictEqual(resolveRuntimeConfigIntent('kimi').installSurface, 'profile-marker-only');
|
||||
});
|
||||
|
||||
test('the 7 passthroughs + opencode + kilo -> "settings-json"', () => {
|
||||
const settingsJsonRuntimes = ['claude', 'gemini', 'antigravity', 'augment', 'qwen', 'hermes', 'codebuddy', 'opencode', 'kilo'];
|
||||
for (const runtime of settingsJsonRuntimes) {
|
||||
@@ -205,14 +210,15 @@ describe('resolveRuntimeConfigIntent — fresh object each call', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ALLOWED_CONFIG_RUNTIMES completeness', () => {
|
||||
const EXPECTED_15 = new Set([
|
||||
const EXPECTED_16 = new Set([
|
||||
'claude', 'gemini', 'antigravity', 'augment', 'qwen', 'hermes', 'codebuddy',
|
||||
'opencode', 'kilo', 'codex', 'copilot', 'cline', 'cursor', 'windsurf', 'trae',
|
||||
'kimi',
|
||||
]);
|
||||
|
||||
test('ALLOWED_CONFIG_RUNTIMES contains exactly the 15 expected runtimes', () => {
|
||||
test('ALLOWED_CONFIG_RUNTIMES contains exactly the 16 expected runtimes', () => {
|
||||
const runtimeSet = new Set(ALLOWED_CONFIG_RUNTIMES);
|
||||
assert.deepStrictEqual(runtimeSet, EXPECTED_15);
|
||||
assert.deepStrictEqual(runtimeSet, EXPECTED_16);
|
||||
});
|
||||
|
||||
test('every member of ALLOWED_CONFIG_RUNTIMES resolves without throwing', () => {
|
||||
@@ -221,8 +227,8 @@ describe('ALLOWED_CONFIG_RUNTIMES completeness', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('ALLOWED_CONFIG_RUNTIMES has exactly 15 entries', () => {
|
||||
assert.strictEqual([...ALLOWED_CONFIG_RUNTIMES].length, 15);
|
||||
test('ALLOWED_CONFIG_RUNTIMES has exactly 16 entries', () => {
|
||||
assert.strictEqual([...ALLOWED_CONFIG_RUNTIMES].length, 16);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
20
tests/runtime-name-policy.test.cjs
Normal file
20
tests/runtime-name-policy.test.cjs
Normal file
@@ -0,0 +1,20 @@
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const {
|
||||
canonicalizeRuntimeName,
|
||||
resolveRuntimeNameFromCandidates,
|
||||
} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-name-policy.cjs'));
|
||||
|
||||
describe('runtime-name-policy canonical runtime ids', () => {
|
||||
test('canonicalizes Kimi without adding extra aliases', () => {
|
||||
assert.strictEqual(canonicalizeRuntimeName('kimi'), 'kimi');
|
||||
assert.strictEqual(canonicalizeRuntimeName(' KIMI '), 'kimi');
|
||||
assert.strictEqual(resolveRuntimeNameFromCandidates('', null, 'kimi'), 'kimi');
|
||||
assert.strictEqual(canonicalizeRuntimeName('kimi-cli'), null);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user