Merge remote-tracking branch 'upstream/next' into kimi-runtime-support
# Conflicts: # bin/install.js # src/runtime-homes.cts
This commit is contained in:
5
.changeset/782-cline-skills-emission.md
Normal file
5
.changeset/782-cline-skills-emission.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 809
|
||||
---
|
||||
**Cline global installs now emit skills, not just rules:** gsd writes skills to `~/.cline/skills/<name>/SKILL.md` for Cline ≥ v3.48.0 (see [Cline skills docs](https://docs.cline.bot/customization/skills)), in addition to the existing `.clinerules` file. Each `SKILL.md` carries `name`/`description` frontmatter (agentskills.io) with paths rewritten to the `.cline/` convention. Local installs remain `.clinerules`-only. The `.clinerules` rules file continues to be emitted for compatibility, and upgrading over an existing rules-only install emits the new skills on the next run.
|
||||
7
.changeset/783-kilo-global-skills-base.md
Normal file
7
.changeset/783-kilo-global-skills-base.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 806
|
||||
---
|
||||
**`getGlobalSkillsBase('kilo')` now resolves to `~/.kilo/skills`** — where Kilo Code actually discovers global skills — instead of `~/.config/kilo/skills`. Per [Kilo Code docs](https://kilo.ai/docs/customize/skills), global skills live in the `.kilo` directory within HOME (`~/.kilo/skills/`), independent of the XDG-based config dir at `~/.config/kilo`. The kilo.jsonc config dir (`~/.config/kilo`) and the `command/` path used by the installer are correct and unchanged. Blast radius: this corrects the resolved skills-base path used by doctor/status checks and agent-skills-block resolution (`init.cjs`); the installer writes commands (not skills) for Kilo, so no files were previously being written to the wrong location.
|
||||
|
||||
<!-- docs-exempt: internal path-resolution correction; no user-facing how-to surface changed -->
|
||||
5
.changeset/784-opencode-kilo-skills.md
Normal file
5
.changeset/784-opencode-kilo-skills.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 810
|
||||
---
|
||||
Emit native on-demand skills (`skills/<name>/SKILL.md`) for the OpenCode-family runtimes (OpenCode and Kilo) at install time, in addition to the existing flat `command/` and file-based `agents/` surfaces. OpenCode and Kilo share a config schema and both discover skills from `skills/<name>/SKILL.md`; the installer now stages each GSD command as a skill with minimal, spec-compliant frontmatter (`name` matching the directory, `description` 1–1024 chars) via a shared OpenCode-family skill writer. Skills respect the active install profile (core/minimal stage only their subset) and are removed on uninstall. (#784)
|
||||
5
.changeset/785-cursor-slash-commands.md
Normal file
5
.changeset/785-cursor-slash-commands.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 803
|
||||
---
|
||||
`gsd install --cursor` now writes `.cursor/commands/gsd-<name>.md` in addition to the existing `.cursor/skills/` surface. Cursor 1.6 introduced plain-markdown slash commands (no frontmatter) in `.cursor/commands/`; they appear in the `/` menu in the Agent input. Each command file is generated from the same source as the skill but with frontmatter stripped and Cursor-specific content transforms applied (`convertClaudeCommandToCursorCommand`). The skills surface is unchanged — both surfaces are written on every install.
|
||||
5
.changeset/786-copilot-hooks-agents.md
Normal file
5
.changeset/786-copilot-hooks-agents.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 804
|
||||
---
|
||||
The GitHub Copilot installer now reaches lifecycle-hook and instruction parity with other first-class runtimes. It emits a self-contained `sessionStart` hook config (`.github/hooks/gsd-session.json` for local installs, `~/.copilot/hooks/gsd-session.json` for global) and writes `AGENTS.md` at the repository root (which Copilot CLI reads as primary instructions) alongside `copilot-instructions.md`. The hook is an inline `command` hook with no separate script file, so it cannot dangle. Both artifacts are removed — with user-authored content preserved — on `--uninstall`. (#786)
|
||||
5
.changeset/787-cline-hooks-agents.md
Normal file
5
.changeset/787-cline-hooks-agents.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 803
|
||||
---
|
||||
Elevate the Cline runtime to hook parity. The installer now emits the Cline `.clinerules/` directory form (`.clinerules/gsd.md`) instead of a single `.clinerules` file, adds a `.clinerules/hooks/PreToolUse` lifecycle hook (Cline v3.36+ JSON stdin → `{cancel,errorMessage,contextModification}` protocol; guards `.planning/` artifacts and fails open), and merges GSD instructions into the cross-tool global `~/.agents/AGENTS.md` target on global installs. A legacy single-file `.clinerules` is migrated to the directory form in place, and `--uninstall` removes the new artifacts and strips the GSD block from `~/.agents/AGENTS.md`. (#787)
|
||||
5
.changeset/788-qwen-hook-events.md
Normal file
5
.changeset/788-qwen-hook-events.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 807
|
||||
---
|
||||
Qwen Code installs now register three additional hook events that Qwen Code supports beyond Claude Code: `SubagentStop`, `Stop`, and `PreCompact` — all wired to `gsd-context-monitor.js` for context headroom tracking at subagent completion, model stop, and pre-compaction. These events are Qwen-only; Claude Code installs are unchanged. `UserPromptSubmit` is deferred: `gsd-prompt-guard` exits unless `tool_name` is `Write|Edit`, making it a no-op for that payload shape. (#788)
|
||||
7
.changeset/790-augment-commands.md
Normal file
7
.changeset/790-augment-commands.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 801
|
||||
---
|
||||
**Augment (Auggie) installs now emit slash command definitions alongside skills.** A global `--augment` install writes `commands/gsd-<name>.md` files to `~/.augment/commands/` in addition to the existing `skills/gsd-<name>/SKILL.md` files, matching the integration depth of other fully-elevated runtimes and allowing Auggie users to invoke GSD as slash commands (`/gsd-phase`, `/gsd-ship`, etc.) without manual configuration (#790). Content rewrites (path normalisation and Augment-specific branding) are applied at install time. Uninstall removes the `gsd-*` command files while preserving user-owned commands. `mcpServers` registration is explicitly excluded — gsd ships no MCP server and does not register third-party servers.
|
||||
|
||||
<!-- docs-exempt: internal installer enhancement; the user-facing behaviour (slash command availability in Augment) is described in README.md which already covers the supported runtime list -->
|
||||
5
.changeset/812-copilot-home.md
Normal file
5
.changeset/812-copilot-home.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 814
|
||||
---
|
||||
Honor the `COPILOT_HOME` environment variable when resolving the GitHub Copilot global config directory. Previously a global `--copilot` install ignored `COPILOT_HOME` and wrote all artifacts (skills, agents, `copilot-instructions.md`, the session hook) to `~/.copilot` even when the user had relocated their Copilot home, making them undiscoverable by Copilot CLI. Resolution now follows `--config-dir` > `COPILOT_CONFIG_DIR` > `COPILOT_HOME` > `~/.copilot`, mirroring the existing `CODEX_HOME` handling. Uninstall uses the same resolver and stays symmetric. (#812)
|
||||
879
bin/install.js
879
bin/install.js
File diff suppressed because one or more lines are too long
@@ -804,7 +804,7 @@ The migration-specific ownership and source snapshots live in
|
||||
| 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` 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` | `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` and `copilot-instructions.md` | `.agent.md` files | No GSD hooks or statusline |
|
||||
| GitHub Copilot | `~/.copilot` | `./.github` | `skills/gsd-*/SKILL.md`, `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-*/SKILL.md` | `agents/gsd-*.md` | Gemini-style `settings.json` hook entries when installed by GSD |
|
||||
| Cursor | `~/.cursor` | `./.cursor` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks |
|
||||
| Windsurf | `~/.codeium/windsurf` | `./.windsurf` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks |
|
||||
|
||||
@@ -1013,12 +1013,16 @@ fix(03-01): correct auth token expiry
|
||||
|
||||
**Runtime Transformations:**
|
||||
|
||||
| Aspect | Claude Code | OpenCode | Gemini | Kilo | Codex | Copilot | Antigravity | Trae | Cline | Augment | CodeBuddy | Qwen Code |
|
||||
|--------|------------|----------|--------|-------|-------|---------|-------------|------|-------|---------|-----------|-----------|
|
||||
| Commands | Slash commands | Slash commands | Slash commands | Slash commands | Skills (TOML) | Slash commands | Skills | Skills | Rules | Skills | Skills | Skills |
|
||||
| Agent format | Claude native | `mode: subagent` | Claude native | `mode: subagent` | Skills | Tool mapping | Skills | Skills | Rules | Skills | Skills | Skills |
|
||||
| Hook events | `PostToolUse` | N/A | `AfterTool` | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A |
|
||||
| Config | `settings.json` | `opencode.json(c)` | `settings.json` | `kilo.json(c)` | TOML | Instructions | Config | Config | `.clinerules` | Config | Config | Config |
|
||||
| Aspect | Claude Code | OpenCode | Gemini | Kilo | Codex | Copilot | Antigravity | Cursor | Trae | Cline | Augment | CodeBuddy | Qwen Code |
|
||||
|--------|------------|----------|--------|-------|-------|---------|-------------|--------|------|-------|---------|-----------|-----------|
|
||||
| Commands | Slash commands | Slash commands | Slash commands | Slash commands | Skills (TOML) | Slash commands | Skills | Skills + Slash commands | Skills | Rules | Skills | Skills | Skills |
|
||||
| Agent format | Claude native | `mode: subagent` | Claude native | `mode: subagent` | Skills | Tool mapping | Skills | Skills | Skills | Rules | Skills | Skills | Skills |
|
||||
| Hook events | `PostToolUse` | N/A | `AfterTool` | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A |
|
||||
| Config | `settings.json` | `opencode.json(c)` | `settings.json` | `kilo.json(c)` | TOML | Instructions | Config | Config | Config | `.clinerules` | Config | Config | Config |
|
||||
|
||||
**Cursor artifact surfaces:** `gsd install --cursor` writes two artifact kinds:
|
||||
- `~/.cursor/skills/gsd-<name>/SKILL.md` — rich skills with YAML frontmatter, Cursor tool-name mapping, and adapter context header (existing surface)
|
||||
- `~/.cursor/commands/gsd-<name>.md` — plain markdown slash commands (no frontmatter) invocable via `/` in the Agent input (Cursor 1.6+, added in #785)
|
||||
|
||||
**Claude Code native plugin distribution:** GSD Core ships a `.claude-plugin/plugin.json` manifest, enabling installation and lifecycle management via `claude plugin install|enable|disable|update gsd-core`. Commands load under the `/gsd-core:` namespace (e.g. `/gsd-core:plan-phase`), avoiding slash-command collisions with the classic npm installer which uses `/gsd:`. Always-on guard and update hooks are wired automatically via `hooks/hooks.json`. The plugin path is additive — the npm installer (`npx @opengsd/gsd-core`) remains fully supported.
|
||||
|
||||
|
||||
@@ -749,7 +749,7 @@ WINDSURF_CONFIG_DIR=~/.codeium/windsurf-next npx @opengsd/gsd-core@latest --wind
|
||||
| Gemini CLI | `~/.gemini` | `GEMINI_CONFIG_DIR` |
|
||||
| OpenCode | `XDG_CONFIG_HOME/opencode` | `OPENCODE_CONFIG_DIR` |
|
||||
| Codex | (per Codex CLI) | `--config-dir` flag |
|
||||
| Copilot | `~/.copilot` | `COPILOT_CONFIG_DIR` |
|
||||
| Copilot | `~/.copilot` | `COPILOT_CONFIG_DIR` (or `COPILOT_HOME`) |
|
||||
| Cursor | `~/.cursor` | `CURSOR_CONFIG_DIR` |
|
||||
| Windsurf | `~/.codeium/windsurf` | `WINDSURF_CONFIG_DIR` |
|
||||
| Antigravity | auto-detected | `ANTIGRAVITY_CONFIG_DIR` |
|
||||
|
||||
@@ -110,7 +110,7 @@ GEMINI_CONFIG_DIR=~/.gemini-alt npx @opengsd/gsd-core@latest --gemini --global
|
||||
npx @opengsd/gsd-core@latest --opencode --global
|
||||
```
|
||||
|
||||
Skills land in `~/.config/opencode/` (XDG) or `~/.opencode/`. The installer converts agent frontmatter to OpenCode's schema — removing the `tools:` field and converting colour values to hex. See [Installing without Node.js — OpenCode transformations](#opencode--required-transformations) if you need to understand what changes.
|
||||
The installer writes three surfaces under `~/.config/opencode/` (XDG) or `~/.opencode/`: flat slash commands in `command/`, file-based subagents in `agents/`, and on-demand skills in `skills/<name>/SKILL.md`. It converts agent frontmatter to OpenCode's schema — removing the `tools:` field and converting colour values to hex — and emits each skill with spec-compliant frontmatter (`name` matching the skill directory plus a `description`). Skills are loaded on demand via OpenCode's native skill tool; commands remain invokable as `/gsd-*`. See [Installing without Node.js — OpenCode transformations](#opencode--required-transformations) if you need to understand what changes.
|
||||
|
||||
**Override the install directory:**
|
||||
|
||||
@@ -126,7 +126,7 @@ OPENCODE_CONFIG_DIR=~/.config/opencode-alt npx @opengsd/gsd-core@latest --openco
|
||||
npx @opengsd/gsd-core@latest --kilo --global
|
||||
```
|
||||
|
||||
Skills land in `~/.config/kilo/` (XDG) or `~/.kilo/`. Uses the same OpenCode-style flat markdown command format.
|
||||
The installer writes the same three surfaces under `~/.config/kilo/` (XDG) or `~/.kilo/` as for OpenCode — flat commands in `command/`, subagents in `agents/`, and skills in `skills/<name>/SKILL.md` — since Kilo derives from OpenCode and shares its config schema and skill layout.
|
||||
|
||||
**Override the install directory:**
|
||||
|
||||
@@ -215,6 +215,13 @@ npx @opengsd/gsd-core@latest --copilot --global
|
||||
|
||||
Skills land in `~/.copilot/`. GSD installs as agent `.md` files and repository instruction files.
|
||||
|
||||
GSD also wires Copilot's lifecycle hooks and instruction files:
|
||||
|
||||
- **`AGENTS.md`** (local installs) — written at the repository root, which GitHub Copilot CLI reads as primary instructions, alongside `copilot-instructions.md`.
|
||||
- **Lifecycle hook** — a `sessionStart` hook config is written to `.github/hooks/gsd-session.json` (local) or `~/.copilot/hooks/gsd-session.json` (global). It is a self-contained inline `command` hook (no separate hook script to install), so it can never reference a missing script. The hook is advisory-only: at session start it surfaces whether the project has a `.planning/` workflow.
|
||||
|
||||
Both are removed (and any user-authored content preserved) on `--uninstall`.
|
||||
|
||||
**Override the install directory:**
|
||||
|
||||
```bash
|
||||
@@ -257,17 +264,40 @@ WINDSURF_CONFIG_DIR=~/.codeium/windsurf-alt npx @opengsd/gsd-core@latest --winds
|
||||
|
||||
### Cline
|
||||
|
||||
Cline uses a rules-based integration — GSD installs as `.clinerules` rather than slash commands.
|
||||
GSD gives Cline both skills (≥ v3.48.0) and the `.clinerules/` directory integration — no custom slash commands are registered.
|
||||
|
||||
```bash
|
||||
# Global install (all projects)
|
||||
# Global install (all projects — skills + rules directory)
|
||||
npx @opengsd/gsd-core@latest --cline --global
|
||||
|
||||
# Local install (this project only)
|
||||
# Local install (this project only — rules directory only)
|
||||
npx @opengsd/gsd-core@latest --cline --local
|
||||
```
|
||||
|
||||
Global installs write to `~/.cline/`. Local installs write to `./.cline/`. Rules are loaded automatically by Cline — no custom slash commands are registered.
|
||||
GSD writes the [`.clinerules/` directory form](https://docs.cline.bot/customization/cline-rules):
|
||||
|
||||
- **`.clinerules/gsd.md`** — the GSD rule file. Cline loads every `.md`/`.txt` file in
|
||||
the `.clinerules/` directory automatically; no custom slash commands are registered.
|
||||
- **`.clinerules/hooks/PreToolUse`** — a [lifecycle hook](https://cline.bot/blog/cline-v3-36-hooks)
|
||||
(Cline v3.36+). It is an executable script that receives the tool-call context as JSON on
|
||||
stdin and returns a JSON decision (`cancel` / `errorMessage` / `contextModification`). The
|
||||
GSD hook guards `.planning/` artifacts from direct edits and otherwise allows the operation;
|
||||
it fails open, so a hook error never blocks you. Cline runs hooks on macOS and Linux only.
|
||||
|
||||
**Global install additionally:**
|
||||
|
||||
- Emits each GSD command as **`~/.cline/skills/<name>/SKILL.md`**. Cline ≥ v3.48.0 loads
|
||||
skills from `~/.cline/skills/` automatically — no configuration needed.
|
||||
- Merges GSD instructions into **`~/.agents/AGENTS.md`**, the cross-tool global instruction
|
||||
file Cline reads. The block is marker-delimited, so your own `AGENTS.md` content (and other
|
||||
tools' entries) is preserved, and `--uninstall` strips only the GSD block.
|
||||
|
||||
**Local install** writes the `.clinerules/` directory into the current project only. No skills
|
||||
directory is created for local scope.
|
||||
|
||||
> Cline's *global* hook directory (`~/Documents/Cline/Rules/Hooks/`) is not yet populated by the
|
||||
> installer — project-scope hooks (`.clinerules/hooks/`) and the global `AGENTS.md` instruction
|
||||
> target cover the common cases.
|
||||
|
||||
---
|
||||
|
||||
@@ -297,6 +327,19 @@ Skills land in `~/.qwen/skills/gsd-*/SKILL.md`.
|
||||
QWEN_CONFIG_DIR=~/.qwen-alt npx @opengsd/gsd-core@latest --qwen --global
|
||||
```
|
||||
|
||||
**Hook coverage**
|
||||
|
||||
Qwen Code supports 15 hook events. GSD registers the following events automatically on install:
|
||||
|
||||
| Event | Hook | Purpose |
|
||||
|---|---|---|
|
||||
| `SessionStart` | `gsd-check-update.js`, `gsd-session-state.sh` | Update check, session orientation |
|
||||
| `PostToolUse` | `gsd-context-monitor.js`, `gsd-read-injection-scanner.js`, `gsd-phase-boundary.sh`, `gsd-graphify-update.sh` | Context monitoring, read-time scan, phase boundary detection |
|
||||
| `PreToolUse` | `gsd-prompt-guard.js`, `gsd-read-guard.js`, `gsd-workflow-guard.js`, `gsd-worktree-path-guard.js`, `gsd-validate-commit.sh` | Prompt guard, read-before-edit, workflow + worktree safety, commit validation |
|
||||
| `SubagentStop` | `gsd-context-monitor.js` | Context headroom tracking after subagent completion |
|
||||
| `Stop` | `gsd-context-monitor.js` | Context headroom tracking before model stop |
|
||||
| `PreCompact` | `gsd-context-monitor.js` | Context awareness before conversation compaction |
|
||||
|
||||
---
|
||||
|
||||
### Augment Code
|
||||
@@ -305,7 +348,7 @@ QWEN_CONFIG_DIR=~/.qwen-alt npx @opengsd/gsd-core@latest --qwen --global
|
||||
npx @opengsd/gsd-core@latest --augment --global
|
||||
```
|
||||
|
||||
Skills land in `~/.augment/`. GSD installs skills and agents. No hook or statusline ownership.
|
||||
Skills land in `~/.augment/skills/` and slash command definitions land in `~/.augment/commands/`. GSD installs skills, agents, and commands (`/gsd-phase`, `/gsd-ship`, etc.). No hook or statusline ownership.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -370,7 +370,7 @@ for the new shape before changing migration behavior.
|
||||
| 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; 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` 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 |
|
||||
| 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. |
|
||||
| Cursor | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; rule references under `rules/` | Global `CURSOR_CONFIG_DIR` or `~/.cursor`; local `./.cursor` | GSD owns generated skills/agents and GSD rule files or references; no hook/statusline ownership | [Cursor rules](https://docs.cursor.com/context/rules); docs not versioned, checked 2026-05-11 |
|
||||
| Windsurf | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; rule references under `rules/` | Global `WINDSURF_CONFIG_DIR` or `~/.codeium/windsurf`; local `./.windsurf` | GSD owns generated skills/agents and GSD rule files or references; no hook/statusline ownership | Windsurf public rule docs were source-limited in search results as of 2026-05-11; installer targets the common workspace rules convention `./.windsurf/rules` and must be rechecked before migrations rewrite rules |
|
||||
|
||||
@@ -363,6 +363,62 @@ function stageSkillsForRuntimeAsSkills(
|
||||
return stageDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage converted command files as flat `.md` files.
|
||||
*
|
||||
* Analogous to `stageSkillsForRuntimeAsSkills` but for runtimes that use a
|
||||
* flat commands directory (e.g. Cursor's `.cursor/commands/<name>.md`).
|
||||
* Each source `.md` is passed through `converter` and written as a single flat
|
||||
* `${stem}.md` file in the staging directory (no subdirectory, no prefix).
|
||||
*
|
||||
* The `_copyStaged` commands branch in install.js will add the prefix when
|
||||
* copying staged files to the destination directory, so staged files must be
|
||||
* named with just the stem (e.g. `help.md` not `gsd-help.md`).
|
||||
*
|
||||
* The `converter` receives `(content, ${prefix}${stem})` so it can embed the
|
||||
* full command name (e.g. 'gsd-help') into the document body if needed.
|
||||
*
|
||||
* Used by the `convertedCommandsKind` layout descriptor in
|
||||
* runtime-artifact-layout.cts (#785 — Cursor 1.6 slash commands).
|
||||
*
|
||||
* @param srcCommandsDir source commands directory (e.g. commands/gsd/)
|
||||
* @param resolvedProfile profile filter — '*' for all, Set for subset
|
||||
* @param converter (content, commandName) → string pure converter
|
||||
* @param prefix command name prefix (for converter arg), e.g. 'gsd-'
|
||||
*/
|
||||
function stageCommandsForRuntimeFlat(
|
||||
srcCommandsDir: string,
|
||||
resolvedProfile: ResolvedProfile,
|
||||
converter: (content: string, commandName: string) => string,
|
||||
prefix: string,
|
||||
): string {
|
||||
if (!fs.existsSync(srcCommandsDir)) return srcCommandsDir;
|
||||
|
||||
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-runtime-commands-'));
|
||||
try {
|
||||
const entries = fs.readdirSync(srcCommandsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith('.md')) continue;
|
||||
const stem = entry.name.slice(0, -3);
|
||||
if (resolvedProfile.skills !== '*' && !(resolvedProfile.skills).has(stem)) continue;
|
||||
const content = fs.readFileSync(path.join(srcCommandsDir, entry.name), 'utf8');
|
||||
// Pass the full command name (with prefix) to the converter so it can
|
||||
// reference the installed command name in the body (e.g. for descriptions).
|
||||
// The staged file itself is named without the prefix; _copyStaged adds it.
|
||||
const commandName = `${prefix}${stem}`;
|
||||
const converted = converter(content, commandName);
|
||||
fs.writeFileSync(path.join(stageDir, `${stem}.md`), converted);
|
||||
}
|
||||
} catch (err) {
|
||||
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
throw err;
|
||||
}
|
||||
STAGED_DIRS.add(stageDir);
|
||||
ensureExitCleanup();
|
||||
return stageDir;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile marker persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -535,6 +591,7 @@ export = {
|
||||
stageSkillsForProfile,
|
||||
stageAgentsForProfile,
|
||||
stageSkillsForRuntimeAsSkills,
|
||||
stageCommandsForRuntimeFlat,
|
||||
STAGED_DIRS,
|
||||
readActiveProfile,
|
||||
writeActiveProfile,
|
||||
|
||||
@@ -22,6 +22,7 @@ const {
|
||||
stageSkillsForProfile,
|
||||
stageAgentsForProfile,
|
||||
stageSkillsForRuntimeAsSkills,
|
||||
stageCommandsForRuntimeFlat,
|
||||
} = installProfiles;
|
||||
|
||||
// In .cts (CommonJS output) files, `require` is available as a global.
|
||||
@@ -271,6 +272,40 @@ function skillsKind(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a converted-commands kind descriptor for runtimes that use a flat
|
||||
* commands directory with per-file conversion (e.g. Cursor 1.6 slash commands).
|
||||
*
|
||||
* Unlike `commandsKind` (which passes raw source files through), this kind
|
||||
* applies `converterName` from bin/install.js exports to each file during
|
||||
* staging, writing flat `${prefix}${stem}.md` files to the staged directory.
|
||||
*
|
||||
* The staged files are then written by `_copyStaged` (commands branch) which
|
||||
* handles prefix logic via the existing layout machinery.
|
||||
*
|
||||
* @param destSubpath destination subpath within configDir (e.g. 'commands')
|
||||
* @param prefix filename prefix, e.g. 'gsd-'
|
||||
* @param converterName name of converter function in bin/install.js exports
|
||||
* @param configDir runtime config dir (for .gsd-source marker resolution)
|
||||
*/
|
||||
function convertedCommandsKind(
|
||||
destSubpath: string,
|
||||
prefix: string,
|
||||
converterName: string,
|
||||
configDir: string,
|
||||
): ArtifactKind {
|
||||
return {
|
||||
kind: 'commands',
|
||||
destSubpath,
|
||||
prefix,
|
||||
stage: (resolved) => {
|
||||
const installExports = getInstallExports();
|
||||
const converter = installExports[converterName] as (content: string, commandName: string) => string;
|
||||
return stageCommandsForRuntimeFlat(findInstallSourceRoot(configDir), resolved, converter, prefix);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -303,7 +338,14 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope:
|
||||
break;
|
||||
|
||||
case 'cursor':
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCursorSkill', 'cursor', configDir)];
|
||||
// Cursor 1.6+ supports two artifact surfaces:
|
||||
// 1. skills/gsd-<name>/SKILL.md — rich skills with frontmatter + adapter header
|
||||
// 2. commands/gsd-<name>.md — plain markdown slash commands (no frontmatter)
|
||||
// accessed via '/' in the Agent input (#785)
|
||||
kinds = [
|
||||
skillsKind('skills', 'gsd-', 'convertClaudeCommandToCursorSkill', 'cursor', configDir),
|
||||
convertedCommandsKind('commands', 'gsd-', 'convertClaudeCommandToCursorCommand', configDir),
|
||||
];
|
||||
break;
|
||||
|
||||
case 'gemini':
|
||||
@@ -327,7 +369,10 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope:
|
||||
break;
|
||||
|
||||
case 'augment':
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToAugmentSkill', 'augment', configDir)];
|
||||
kinds = [
|
||||
commandsKind('commands', 'gsd-', configDir),
|
||||
skillsKind('skills', 'gsd-', 'convertClaudeCommandToAugmentSkill', 'augment', configDir),
|
||||
];
|
||||
break;
|
||||
|
||||
case 'trae':
|
||||
@@ -347,7 +392,7 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope:
|
||||
break;
|
||||
|
||||
case 'cline':
|
||||
kinds = [];
|
||||
kinds = scope === 'global' ? [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClineSkill', 'cline', configDir)] : [];
|
||||
break;
|
||||
|
||||
case 'kimi':
|
||||
@@ -360,11 +405,21 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope:
|
||||
break;
|
||||
|
||||
case 'opencode':
|
||||
kinds = [commandsKind('command', 'gsd-', configDir)];
|
||||
// OpenCode reads flat slash commands from command/ and on-demand skills
|
||||
// from skills/<name>/SKILL.md (https://opencode.ai/docs/skills). Emit both.
|
||||
kinds = [
|
||||
commandsKind('command', 'gsd-', configDir),
|
||||
skillsKind('skills', 'gsd-', 'convertClaudeCommandToOpencodeSkill', 'opencode', configDir),
|
||||
];
|
||||
break;
|
||||
|
||||
case 'kilo':
|
||||
kinds = [commandsKind('command', 'gsd-', configDir)];
|
||||
// Kilo derives from OpenCode and shares the skills/<name>/SKILL.md layout
|
||||
// (https://kilo.ai/docs/customize/skills). Emit flat commands + skills.
|
||||
kinds = [
|
||||
commandsKind('command', 'gsd-', configDir),
|
||||
skillsKind('skills', 'gsd-', 'convertClaudeCommandToKiloSkill', 'kilo', configDir),
|
||||
];
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
* Runtime-specific notes:
|
||||
* hermes — GSD skills nest under skills/gsd/<skillName>/ (not the flat
|
||||
* skills/<skillName>/ layout used by all other runtimes).
|
||||
* cline — Rules-based; commands are embedded in .clinerules. Cline does
|
||||
* not use a skills/ directory. getGlobalSkillDir() returns null
|
||||
* for cline so the caller can emit an appropriate warning.
|
||||
* 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.
|
||||
@@ -135,7 +135,9 @@ export function getGlobalConfigDir(runtime: string, explicitDir?: string | null)
|
||||
|
||||
// ── Copilot (VS Code) ────────────────────────────────────────────────────
|
||||
case 'copilot':
|
||||
return env['COPILOT_CONFIG_DIR'] ? expandTilde(env['COPILOT_CONFIG_DIR']) : path.join(home, '.copilot');
|
||||
if (env['COPILOT_CONFIG_DIR']) return expandTilde(env['COPILOT_CONFIG_DIR']);
|
||||
if (env['COPILOT_HOME']) return expandTilde(env['COPILOT_HOME']);
|
||||
return path.join(home, '.copilot');
|
||||
|
||||
// ── Antigravity ──────────────────────────────────────────────────────────
|
||||
case 'antigravity':
|
||||
@@ -202,18 +204,25 @@ export function getGlobalConfigDir(runtime: string, explicitDir?: string | null)
|
||||
* Return the global skills base directory for the given runtime.
|
||||
* Most runtimes: <configDir>/skills
|
||||
* Hermes: <configDir>/skills/gsd (nested category layout — #2841)
|
||||
* Cline: null (rules-based, no skills directory)
|
||||
* Cline ≥ v3.48.0: <configDir>/skills (SKILL.md-based global skills — #782)
|
||||
*/
|
||||
export function getGlobalSkillsBase(runtime: string): string | null {
|
||||
if (runtime === 'cline') return null;
|
||||
if (runtime === 'hermes') {
|
||||
const configDir = getGlobalConfigDir(runtime);
|
||||
return path.join(configDir, 'skills', 'gsd');
|
||||
}
|
||||
// Kilo Code discovers global skills from ~/.kilo/skills/ (HOME-relative),
|
||||
// independent of the XDG-based config dir (~/.config/kilo) used for commands.
|
||||
// See: https://kilo.ai/docs/customize/skills
|
||||
// "Global skills are located in the `.kilo` directory within your Home
|
||||
// directory: ~/.kilo/skills/"
|
||||
if (runtime === 'kilo') return path.join(os.homedir(), '.kilo', 'skills');
|
||||
const configDir = getGlobalConfigDir(runtime);
|
||||
if (runtime === 'hermes') return path.join(configDir, 'skills', 'gsd');
|
||||
return path.join(configDir, 'skills');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the full path to a specific skill's directory for the given runtime.
|
||||
* Returns null for runtimes that don't use a skills directory (cline).
|
||||
*/
|
||||
export function getGlobalSkillDir(runtime: string, skillName: string): string | null {
|
||||
const base = getGlobalSkillsBase(runtime);
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('bug #3126: runtime-homes getGlobalConfigDir — defaults', () => {
|
||||
test(`${runtime} default configDir`, () => {
|
||||
// Clear all env vars for this runtime
|
||||
const envKeys = ['CLAUDE_CONFIG_DIR','CURSOR_CONFIG_DIR','GEMINI_CONFIG_DIR',
|
||||
'CODEX_HOME','COPILOT_CONFIG_DIR','ANTIGRAVITY_CONFIG_DIR','WINDSURF_CONFIG_DIR',
|
||||
'CODEX_HOME','COPILOT_CONFIG_DIR','COPILOT_HOME','ANTIGRAVITY_CONFIG_DIR','WINDSURF_CONFIG_DIR',
|
||||
'AUGMENT_CONFIG_DIR','TRAE_CONFIG_DIR','QWEN_CONFIG_DIR','HERMES_HOME',
|
||||
'CODEBUDDY_CONFIG_DIR','CLINE_CONFIG_DIR','OPENCODE_CONFIG_DIR','OPENCODE_CONFIG',
|
||||
'KILO_CONFIG_DIR','KILO_CONFIG',
|
||||
@@ -164,8 +164,13 @@ describe('bug #3126: runtime-homes getGlobalSkillsBase', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
test('cline: returns null (rules-based, no skills directory)', () => {
|
||||
assert.strictEqual(getGlobalSkillsBase('cline'), null);
|
||||
test('cline: returns ~/.cline/skills (skills-capable since v3.48.0 — #782)', () => {
|
||||
withEnv('CLINE_CONFIG_DIR', undefined, () => {
|
||||
assert.strictEqual(
|
||||
getGlobalSkillsBase('cline'),
|
||||
path.join(os.homedir(), '.cline', 'skills'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -186,8 +191,13 @@ describe('bug #3126: runtime-homes getGlobalSkillDir', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
test('cline: returns null', () => {
|
||||
assert.strictEqual(getGlobalSkillDir('cline', 'gsd-executor'), null);
|
||||
test('cline: returns ~/.cline/skills/gsd-executor (skills-capable since v3.48.0 — #782)', () => {
|
||||
withEnv('CLINE_CONFIG_DIR', undefined, () => {
|
||||
assert.strictEqual(
|
||||
getGlobalSkillDir('cline', 'gsd-executor'),
|
||||
path.join(os.homedir(), '.cline', 'skills', 'gsd-executor'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
647
tests/bug-782-cline-skills-emission.test.cjs
Normal file
647
tests/bug-782-cline-skills-emission.test.cjs
Normal file
@@ -0,0 +1,647 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Regression tests for bug #782 — Cline skills emission.
|
||||
*
|
||||
* gsd now emits skills to ~/.cline/skills/<name>/SKILL.md for Cline >= v3.48.
|
||||
* Skills discovery: https://docs.cline.bot/customization/skills
|
||||
*
|
||||
* (a) Converter unit test: convertClaudeCommandToClineSkill
|
||||
* (b) Integration test: installRuntimeArtifacts for cline writes SKILL.md files
|
||||
* (c) .clinerules/gsd.md still written by the install path (#787 dir form)
|
||||
* (d) Idempotency: running install twice leaves skills + .clinerules/ intact
|
||||
* (e) Full install() global: both skills AND .clinerules/gsd.md are written
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createTempDir, cleanup, captureConsole } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
convertClaudeCommandToClineSkill,
|
||||
convertClaudeToCliineMarkdown,
|
||||
installRuntimeArtifacts,
|
||||
install,
|
||||
_applyRuntimeRewrites,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
const {
|
||||
resolveRuntimeArtifactLayout,
|
||||
} = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
||||
|
||||
const {
|
||||
loadSkillsManifest,
|
||||
resolveProfile,
|
||||
} = require('../gsd-core/bin/lib/install-profiles.cjs');
|
||||
|
||||
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST });
|
||||
|
||||
// ─── (a) Converter unit test ─────────────────────────────────────────────────
|
||||
|
||||
const SAMPLE_COMMAND = `---
|
||||
name: gsd:execute-phase
|
||||
description: Execute all tasks in the current phase using Cline tools.
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Write
|
||||
- Bash
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Run all tasks in the current phase.
|
||||
|
||||
See ~/.claude/skills/gsd-help/SKILL.md for reference.
|
||||
Use \`/gsd-help\` or Claude Code for details.
|
||||
`;
|
||||
|
||||
// A command that exercises all three Claude-specific frontmatter fields that
|
||||
// must NOT leak into the emitted Cline SKILL.md.
|
||||
const RICH_COMMAND = `---
|
||||
name: gsd:validate-phase
|
||||
description: Retroactively audit and fill Nyquist validation gaps for a completed phase
|
||||
argument-hint: "[phase number]"
|
||||
agent: researcher
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Write
|
||||
- Edit
|
||||
- Bash
|
||||
- Glob
|
||||
- Grep
|
||||
- Agent
|
||||
- AskUserQuestion
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Audit Nyquist validation coverage. See ~/.claude/skills/gsd-help/SKILL.md for reference.
|
||||
Use Claude Code for details.
|
||||
`;
|
||||
|
||||
/**
|
||||
* Extract frontmatter block (between --- delimiters) from output.
|
||||
* Returns the raw text between the first --- and the closing ---.
|
||||
* Uses \r?\n to handle both LF and CRLF line endings (Windows parity).
|
||||
*/
|
||||
function parseFrontmatter(text) {
|
||||
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
describe('convertClaudeCommandToClineSkill — unit', () => {
|
||||
test('emits frontmatter with name: gsd-<stem>', () => {
|
||||
const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase');
|
||||
const nameMatch = result.match(/^name:\s*(.+)$/m);
|
||||
assert.ok(nameMatch, 'frontmatter must contain name field');
|
||||
assert.ok(nameMatch[1].includes('gsd-execute-phase'), 'name must start with gsd-execute-phase');
|
||||
});
|
||||
|
||||
test('emits non-empty description in frontmatter', () => {
|
||||
const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase');
|
||||
const descMatch = result.match(/^description:\s*(.+)$/m);
|
||||
assert.ok(descMatch, 'frontmatter must contain description field');
|
||||
assert.ok(descMatch[1].trim().length > 0, 'description must not be empty');
|
||||
});
|
||||
|
||||
test('body uses .cline/ paths not .claude/', () => {
|
||||
const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase');
|
||||
// The body reference to ~/.claude/ should be rewritten to ~/.cline/
|
||||
assert.ok(!result.includes('~/.claude/skills'), 'body must not contain ~/.claude/skills');
|
||||
assert.ok(result.includes('.cline/skills'), 'body must contain .cline/skills');
|
||||
});
|
||||
|
||||
test('body replaces "Claude Code" with "Cline"', () => {
|
||||
const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase');
|
||||
assert.ok(!result.includes('Claude Code'), 'Claude Code must be replaced with Cline');
|
||||
assert.ok(result.includes('Cline'), 'result must contain Cline branding');
|
||||
});
|
||||
|
||||
test('no stray .claude/ paths in frontmatter or body', () => {
|
||||
const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase');
|
||||
// Should not contain .claude/ anywhere (except inside CLAUDE.md→.clinerules rewrites
|
||||
// but those are already handled by convertClaudeToCliineMarkdown)
|
||||
assert.ok(!result.includes('/.claude/'), 'no /.claude/ paths in output');
|
||||
});
|
||||
|
||||
// ── Fix 1 (code-review): frontmatter must be ONLY name + description ──────
|
||||
|
||||
test('frontmatter emits ONLY name and description — no allowed-tools (SAMPLE_COMMAND)', () => {
|
||||
const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase');
|
||||
const fm = parseFrontmatter(result);
|
||||
assert.ok(fm !== null, 'result must have YAML frontmatter');
|
||||
assert.ok(!fm.includes('allowed-tools'), 'frontmatter must NOT contain allowed-tools');
|
||||
assert.ok(!fm.includes('argument-hint'), 'frontmatter must NOT contain argument-hint');
|
||||
assert.ok(!fm.includes('agent:'), 'frontmatter must NOT contain agent:');
|
||||
});
|
||||
|
||||
test('frontmatter emits ONLY name and description — no allowed-tools/argument-hint/agent (RICH_COMMAND)', () => {
|
||||
const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase');
|
||||
const fm = parseFrontmatter(result);
|
||||
assert.ok(fm !== null, 'result must have YAML frontmatter');
|
||||
assert.ok(!fm.includes('allowed-tools'), 'frontmatter must NOT contain allowed-tools');
|
||||
assert.ok(!fm.includes('argument-hint'), 'frontmatter must NOT contain argument-hint');
|
||||
assert.ok(!fm.includes('agent:'), 'frontmatter must NOT contain agent:');
|
||||
});
|
||||
|
||||
test('name == gsd-validate-phase for RICH_COMMAND', () => {
|
||||
const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase');
|
||||
const nameMatch = result.match(/^name:\s*(.+)$/m);
|
||||
assert.ok(nameMatch, 'must have name field');
|
||||
// yamlIdentifier may quote the value; strip surrounding quotes for comparison
|
||||
const nameVal = nameMatch[1].replace(/^['"]|['"]$/g, '').trim();
|
||||
assert.strictEqual(nameVal, 'gsd-validate-phase', `name must be gsd-validate-phase, got: ${nameVal}`);
|
||||
});
|
||||
|
||||
test('description is non-empty and <= 1024 chars for RICH_COMMAND', () => {
|
||||
const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase');
|
||||
const descMatch = result.match(/^description:\s*(.+)$/m);
|
||||
assert.ok(descMatch, 'must have description field');
|
||||
const desc = descMatch[1].replace(/^['"]|['"]$/g, '').trim();
|
||||
assert.ok(desc.length > 0, 'description must be non-empty');
|
||||
assert.ok(desc.length <= 1024, `description must be <= 1024 chars, got ${desc.length}`);
|
||||
});
|
||||
|
||||
test('description truncated to <=1024 chars when source description is very long', () => {
|
||||
const longDesc = 'A'.repeat(2000);
|
||||
const longDescCommand = `---\nname: gsd:test\ndescription: ${longDesc}\n---\n\nBody text.\n`;
|
||||
const result = convertClaudeCommandToClineSkill(longDescCommand, 'gsd-test');
|
||||
const descMatch = result.match(/^description:\s*'?(.*?)'?$/m);
|
||||
assert.ok(descMatch, 'must have description field');
|
||||
// The raw description value (unquoted) should be <=1024 chars
|
||||
// The result string after the --- block will have the quoted form; check raw length
|
||||
// by checking the whole result doesn't have the full 2000-char string
|
||||
assert.ok(!result.includes('A'.repeat(1025)), 'description must be truncated to 1024 chars');
|
||||
});
|
||||
|
||||
test('returns content unchanged when source has no frontmatter', () => {
|
||||
const noFm = 'Just a body, no frontmatter here.\n';
|
||||
const result = convertClaudeCommandToClineSkill(noFm, 'gsd-test');
|
||||
assert.strictEqual(result, noFm, 'content without frontmatter must be returned unchanged');
|
||||
});
|
||||
|
||||
test('RICH_COMMAND body uses .cline/ paths and Cline branding', () => {
|
||||
const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase');
|
||||
assert.ok(!result.includes('~/.claude/'), 'body must not contain ~/.claude/');
|
||||
assert.ok(result.includes('.cline/'), 'body must contain .cline/ paths');
|
||||
assert.ok(!result.includes('Claude Code'), 'body must not contain "Claude Code"');
|
||||
assert.ok(result.includes('Cline'), 'body must reference Cline');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── (b) + (c) + (d) Integration tests ────────────────────────────────────────
|
||||
|
||||
describe('installRuntimeArtifacts — cline skills emission', () => {
|
||||
test('cline global: writes gsd-prefixed skill dirs under skills/', (t) => {
|
||||
const configDir = createTempDir('gsd-cline-skills-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const layout = resolveRuntimeArtifactLayout('cline', configDir, 'global');
|
||||
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
|
||||
assert.ok(skillsKind, 'cline must have a skills kind after #782');
|
||||
|
||||
const skillsDir = path.join(configDir, skillsKind.destSubpath);
|
||||
assert.ok(fs.existsSync(skillsDir), 'skills/ directory must be created');
|
||||
|
||||
const helpSkillDir = path.join(skillsDir, `${skillsKind.prefix}help`);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(helpSkillDir, 'SKILL.md')),
|
||||
`gsd-help/SKILL.md must exist under ${skillsKind.destSubpath}/`
|
||||
);
|
||||
});
|
||||
|
||||
test('cline global: SKILL.md has valid cline frontmatter (name + description)', (t) => {
|
||||
const configDir = createTempDir('gsd-cline-fm-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
const helpSkill = path.join(skillsDir, 'gsd-help', 'SKILL.md');
|
||||
assert.ok(fs.existsSync(helpSkill), 'gsd-help/SKILL.md must exist');
|
||||
|
||||
const content = fs.readFileSync(helpSkill, 'utf8');
|
||||
// Must have YAML frontmatter
|
||||
assert.ok(content.startsWith('---'), 'SKILL.md must start with YAML frontmatter');
|
||||
assert.ok(content.includes('name:'), 'frontmatter must have name field');
|
||||
assert.ok(content.includes('description:'), 'frontmatter must have description field');
|
||||
// name must be gsd-help
|
||||
const nameMatch = content.match(/^name:\s*(.+)$/m);
|
||||
assert.ok(nameMatch, 'must have name field');
|
||||
assert.ok(nameMatch[1].includes('gsd-help'), `name must include gsd-help, got: ${nameMatch[1]}`);
|
||||
});
|
||||
|
||||
test('cline global: SKILL.md uses .cline/ paths not .claude/', (t) => {
|
||||
const configDir = createTempDir('gsd-cline-paths-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
// Check all installed skill files for stray .claude/ references
|
||||
const skills = fs.readdirSync(skillsDir).filter(n => n.startsWith('gsd-'));
|
||||
assert.ok(skills.length > 0, 'at least one gsd- skill must be installed');
|
||||
|
||||
for (const skillName of skills) {
|
||||
const skillFile = path.join(skillsDir, skillName, 'SKILL.md');
|
||||
if (!fs.existsSync(skillFile)) continue;
|
||||
const content = fs.readFileSync(skillFile, 'utf8');
|
||||
assert.ok(
|
||||
!content.includes('~/.claude/'),
|
||||
`${skillName}/SKILL.md must not contain ~/.claude/ — found stray path`
|
||||
);
|
||||
assert.ok(
|
||||
!content.includes('/.claude/'),
|
||||
`${skillName}/SKILL.md must not contain /.claude/ — found stray path`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('cline global: skill count matches resolved profile', (t) => {
|
||||
const configDir = createTempDir('gsd-cline-count-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
const count = fs.readdirSync(skillsDir)
|
||||
.filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory())
|
||||
.length;
|
||||
|
||||
if (RESOLVED_CORE.skills !== '*') {
|
||||
assert.strictEqual(count, RESOLVED_CORE.skills.size,
|
||||
`installed skill count (${count}) must match profile size (${RESOLVED_CORE.skills.size})`);
|
||||
} else {
|
||||
assert.ok(count > 0, 'must install at least 1 skill');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('installRuntimeArtifacts — cline idempotency', () => {
|
||||
test('cline: running install twice leaves skills intact (idempotency)', (t) => {
|
||||
const configDir = createTempDir('gsd-cline-idempotent-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
// First install
|
||||
installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
const countAfterFirst = fs.readdirSync(skillsDir)
|
||||
.filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory())
|
||||
.length;
|
||||
|
||||
// Second install (upgrade over existing)
|
||||
installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const countAfterSecond = fs.readdirSync(skillsDir)
|
||||
.filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory())
|
||||
.length;
|
||||
|
||||
assert.strictEqual(countAfterFirst, countAfterSecond,
|
||||
`skill count must be stable across installs: first=${countAfterFirst} second=${countAfterSecond}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── (e) Full install() global — coexistence regression ───────────────────────
|
||||
//
|
||||
// Issue #782 explicitly requires that a global Cline install writes BOTH:
|
||||
// - skills/<gsd-*>/SKILL.md (skills for Cline >= v3.48)
|
||||
// - .clinerules/gsd.md (rules dir form introduced by #787)
|
||||
//
|
||||
// installRuntimeArtifacts() tests cover skills in isolation; this test exercises
|
||||
// the FULL install() code path to ensure neither artifact is silently dropped.
|
||||
|
||||
describe('install() global cline — coexistence: skills AND .clinerules', () => {
|
||||
let tmpGlobalDir;
|
||||
let originalClineConfigDir;
|
||||
|
||||
beforeEach(() => {
|
||||
originalClineConfigDir = process.env.CLINE_CONFIG_DIR;
|
||||
tmpGlobalDir = createTempDir('gsd-cline-global-');
|
||||
// Redirect CLINE_CONFIG_DIR to the temp dir so install() never touches ~/.cline
|
||||
process.env.CLINE_CONFIG_DIR = tmpGlobalDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalClineConfigDir !== undefined) {
|
||||
process.env.CLINE_CONFIG_DIR = originalClineConfigDir;
|
||||
} else {
|
||||
delete process.env.CLINE_CONFIG_DIR;
|
||||
}
|
||||
cleanup(tmpGlobalDir);
|
||||
});
|
||||
|
||||
test('global cline install writes at least one gsd-* SKILL.md under skills/', () => {
|
||||
captureConsole(() => install(true, 'cline'));
|
||||
|
||||
const skillsDir = path.join(tmpGlobalDir, 'skills');
|
||||
assert.ok(
|
||||
fs.existsSync(skillsDir),
|
||||
`skills/ directory must exist under ${tmpGlobalDir} after global cline install`
|
||||
);
|
||||
|
||||
// gsd-help is present in every profile (core, standard, full)
|
||||
const helpSkillFile = path.join(skillsDir, 'gsd-help', 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(helpSkillFile),
|
||||
`skills/gsd-help/SKILL.md must exist under ${tmpGlobalDir} — skills emission broken for global cline`
|
||||
);
|
||||
});
|
||||
|
||||
test('global cline install writes .clinerules/gsd.md to the global config dir', () => {
|
||||
captureConsole(() => install(true, 'cline'));
|
||||
|
||||
// For a global Cline install, targetDir = getGlobalDir('cline') = CLINE_CONFIG_DIR.
|
||||
// The cline-rules surface (#787) writes the .clinerules/ DIRECTORY form:
|
||||
// .clinerules/gsd.md (rule file)
|
||||
// .clinerules/hooks/PreToolUse (lifecycle hook)
|
||||
const clinerulesMd = path.join(tmpGlobalDir, '.clinerules', 'gsd.md');
|
||||
assert.ok(
|
||||
fs.existsSync(clinerulesMd),
|
||||
`.clinerules/gsd.md must exist at ${clinerulesMd} — coexistence with skills broken for global cline (#782+#787)`
|
||||
);
|
||||
});
|
||||
|
||||
test('global cline .clinerules/gsd.md contains GSD instructions', () => {
|
||||
captureConsole(() => install(true, 'cline'));
|
||||
|
||||
// #787 dir form: rule content lives in .clinerules/gsd.md, not a flat .clinerules file
|
||||
const clinerulesMd = path.join(tmpGlobalDir, '.clinerules', 'gsd.md');
|
||||
assert.ok(fs.existsSync(clinerulesMd), '.clinerules/gsd.md must exist');
|
||||
const content = fs.readFileSync(clinerulesMd, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('GSD') || content.includes('gsd'),
|
||||
'.clinerules/gsd.md must reference GSD'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fix 3 regression: converter rewrites bare ~/.claude and CLAUDE_CONFIG_DIR ──
|
||||
//
|
||||
// convertClaudeToCliineMarkdown must also handle bare ~/.claude (no trailing
|
||||
// slash) and the CLAUDE_CONFIG_DIR env-var name. surface.md contains these;
|
||||
// the emitted Cline SKILL.md must contain no such stale Claude refs.
|
||||
|
||||
describe('convertClaudeToCliineMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR (Fix 3)', () => {
|
||||
const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md');
|
||||
|
||||
test('no bare ~/.claude in converted surface.md', () => {
|
||||
const raw = fs.readFileSync(surfacePath, 'utf8');
|
||||
const result = convertClaudeToCliineMarkdown(raw);
|
||||
// ~/.claude followed by a word-boundary (not a /) must be gone
|
||||
assert.ok(
|
||||
!/~\/\.claude\b/.test(result),
|
||||
'converted surface.md must not contain bare ~/.claude'
|
||||
);
|
||||
});
|
||||
|
||||
test('no CLAUDE_CONFIG_DIR in converted surface.md', () => {
|
||||
const raw = fs.readFileSync(surfacePath, 'utf8');
|
||||
const result = convertClaudeToCliineMarkdown(raw);
|
||||
assert.ok(
|
||||
!result.includes('CLAUDE_CONFIG_DIR'),
|
||||
'converted surface.md must not contain CLAUDE_CONFIG_DIR'
|
||||
);
|
||||
});
|
||||
|
||||
test('CLAUDE_CONFIG_DIR rewritten to CLINE_CONFIG_DIR', () => {
|
||||
const input = 'Use CLAUDE_CONFIG_DIR or $HOME/.claude to configure';
|
||||
const result = convertClaudeToCliineMarkdown(input);
|
||||
assert.ok(result.includes('CLINE_CONFIG_DIR'), 'CLAUDE_CONFIG_DIR must become CLINE_CONFIG_DIR');
|
||||
assert.ok(!result.includes('CLAUDE_CONFIG_DIR'), 'CLAUDE_CONFIG_DIR must be gone');
|
||||
});
|
||||
|
||||
test('bare ~/.claude rewritten to ~/.cline', () => {
|
||||
const input = 'Config dir: (~/.claude), skills at ~/.claude/skills';
|
||||
const result = convertClaudeToCliineMarkdown(input);
|
||||
assert.ok(!result.includes('~/.claude'), 'bare ~/.claude must be rewritten');
|
||||
assert.ok(result.includes('~/.cline'), 'must rewrite to ~/.cline');
|
||||
});
|
||||
|
||||
test('installRuntimeArtifacts cline global: gsd-surface SKILL.md has no bare ~/.claude or CLAUDE_CONFIG_DIR', (t) => {
|
||||
const configDir = createTempDir('gsd-cline-surface-fix3-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const MANIFEST_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').loadSkillsManifest(
|
||||
path.join(__dirname, '..', 'commands', 'gsd')
|
||||
);
|
||||
const RESOLVED_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').resolveProfile({
|
||||
modes: ['full'], manifest: MANIFEST_FULL,
|
||||
});
|
||||
|
||||
installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_FULL);
|
||||
|
||||
const surfaceSkill = path.join(configDir, 'skills', 'gsd-surface', 'SKILL.md');
|
||||
assert.ok(fs.existsSync(surfaceSkill), 'gsd-surface/SKILL.md must exist for full profile');
|
||||
|
||||
const content = fs.readFileSync(surfaceSkill, 'utf8');
|
||||
assert.ok(
|
||||
!/~\/\.claude\b/.test(content),
|
||||
'gsd-surface SKILL.md must not contain bare ~/.claude (Fix 3)'
|
||||
);
|
||||
assert.ok(
|
||||
!content.includes('CLAUDE_CONFIG_DIR'),
|
||||
'gsd-surface SKILL.md must not contain CLAUDE_CONFIG_DIR (Fix 3)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fix 1 regression: custom CLINE_CONFIG_DIR → embedded paths use custom dir ──
|
||||
//
|
||||
// _applyRuntimeRewrites for cline must rewrite ~/.cline/ → pathPrefix.
|
||||
// For default global installs, pathPrefix = "$HOME/.cline/" (unchanged).
|
||||
// For custom installs (CLINE_CONFIG_DIR=/custom), pathPrefix = "/custom/" and
|
||||
// all embedded ~/.cline/ refs in SKILL.md must become /custom/...
|
||||
|
||||
describe('_applyRuntimeRewrites — cline custom-dir embedded path (Fix 1)', () => {
|
||||
test('default pathPrefix ($HOME/.cline/) leaves ~/.cline refs as $HOME/.cline', () => {
|
||||
const content = 'See ~/.cline/skills/gsd-help/SKILL.md for reference.\nBare: ~/.cline\n';
|
||||
const result = _applyRuntimeRewrites(content, 'cline', '$HOME/.cline/');
|
||||
assert.ok(result.includes('$HOME/.cline/'), 'default prefix must map ~/.cline/ to $HOME/.cline/');
|
||||
assert.ok(!result.includes('~/.cline'), 'no tilde form should remain after rewrite');
|
||||
});
|
||||
|
||||
test('custom pathPrefix rewrites ~/.cline/ → custom path in SKILL.md body', () => {
|
||||
const content = 'See ~/.cline/skills/gsd-help/SKILL.md for reference.\nBare: ~/.cline\n';
|
||||
const result = _applyRuntimeRewrites(content, 'cline', '/custom/cline-dir/');
|
||||
assert.ok(result.includes('/custom/cline-dir/'), 'custom prefix must appear in output');
|
||||
assert.ok(!result.includes('~/.cline'), 'no tilde cline form should remain after custom rewrite');
|
||||
});
|
||||
|
||||
test('custom pathPrefix rewrites residual ~/.claude/ safety net', () => {
|
||||
const content = 'Residual: ~/.claude/skills\n';
|
||||
const result = _applyRuntimeRewrites(content, 'cline', '/custom/cline-dir/');
|
||||
assert.ok(result.includes('/custom/cline-dir/'), 'safety-net ~/.claude/ also rewritten to custom prefix');
|
||||
assert.ok(!result.includes('~/.claude/'), 'no ~/.claude/ should remain');
|
||||
});
|
||||
|
||||
test('installRuntimeArtifacts cline with CLINE_CONFIG_DIR custom: SKILL.md embeds custom path', (t) => {
|
||||
const configDir = createTempDir('gsd-cline-custom-dir-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const MANIFEST_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').loadSkillsManifest(
|
||||
path.join(__dirname, '..', 'commands', 'gsd')
|
||||
);
|
||||
const RESOLVED_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').resolveProfile({
|
||||
modes: ['full'], manifest: MANIFEST_FULL,
|
||||
});
|
||||
|
||||
installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_FULL);
|
||||
|
||||
// gsd-surface SKILL.md references config paths; with a custom configDir
|
||||
// (not under $HOME), pathPrefix will be the absolute custom path.
|
||||
const surfaceSkill = path.join(configDir, 'skills', 'gsd-surface', 'SKILL.md');
|
||||
assert.ok(fs.existsSync(surfaceSkill), 'gsd-surface/SKILL.md must exist');
|
||||
|
||||
const content = fs.readFileSync(surfaceSkill, 'utf8');
|
||||
// With a custom dir (path under /tmp, not ~/.cline), the output must NOT
|
||||
// contain ~/.cline/ or $HOME/.cline/ — it must embed the actual configDir path.
|
||||
assert.ok(
|
||||
!content.includes('~/.cline/'),
|
||||
`gsd-surface SKILL.md must not contain ~/.cline/ when configDir=${configDir} (Fix 1)`
|
||||
);
|
||||
// The custom path must appear somewhere in the file
|
||||
// (configDir is a /tmp/... path so pathPrefix = configDir+'/').
|
||||
// Production normalizes backslashes to forward slashes via
|
||||
// path.resolve(configDir).replace(/\\/g, '/'), so compare against that
|
||||
// form — otherwise this assertion fails on Windows where mkdtempSync
|
||||
// returns a backslash path (e.g. C:\Users\...) but the emitted content
|
||||
// already has forward slashes (C:/Users/...).
|
||||
const expectedPath = path.resolve(configDir).replace(/\\/g, '/');
|
||||
assert.ok(
|
||||
content.includes(expectedPath),
|
||||
`gsd-surface SKILL.md must embed custom configDir path ${expectedPath} (Fix 1)`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fix 4 regression: description truncation is code-point-aware ────────────
|
||||
//
|
||||
// Naive UTF-16 slicing (`str.slice(0, 1021)`) can split a surrogate pair when
|
||||
// the cut falls between the high and low surrogate of a multibyte character
|
||||
// (e.g. emoji U+1F600, which is encoded as two UTF-16 code units). The fix
|
||||
// uses Array.from() to split by code point, guaranteeing that the truncated
|
||||
// value never contains a lone surrogate.
|
||||
|
||||
describe('convertClaudeCommandToClineSkill — code-point-aware truncation (Fix 4)', () => {
|
||||
/**
|
||||
* Build a frontmatter+body command string whose description is:
|
||||
* - exactly `prefixLen` ASCII chars
|
||||
* - followed by `emojiCount` repetitions of '😀' (U+1F600, 2 UTF-16 units)
|
||||
* - total UTF-16 length is prefixLen + emojiCount * 2
|
||||
*/
|
||||
function makeEmojiCommand(prefixLen, emojiCount) {
|
||||
const desc = 'A'.repeat(prefixLen) + '😀'.repeat(emojiCount);
|
||||
return `---\nname: gsd:emoji-test\ndescription: ${desc}\n---\n\nBody.\n`;
|
||||
}
|
||||
|
||||
test('emitted description is <= 1024 code points when source overflows', () => {
|
||||
// 1020 ASCII chars + 4 emoji = 1020 + 8 UTF-16 units = 1028 UTF-16 units > 1024.
|
||||
// Code-point count = 1020 + 4 = 1024 — exactly at the boundary BEFORE adding '...'.
|
||||
// After truncation to 1021 code points + '...' → 1024 code points total.
|
||||
const cmd = makeEmojiCommand(1020, 10); // 1030 code points → must truncate
|
||||
const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test');
|
||||
|
||||
// Extract raw description value (strip surrounding YAML quotes if present)
|
||||
const descMatch = result.match(/^description:\s*(.+)$/m);
|
||||
assert.ok(descMatch, 'emitted SKILL.md must have a description field');
|
||||
const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, '');
|
||||
|
||||
const codePoints = Array.from(rawDesc);
|
||||
assert.ok(
|
||||
codePoints.length <= 1024,
|
||||
`emitted description must be <= 1024 code points, got ${codePoints.length}`
|
||||
);
|
||||
});
|
||||
|
||||
test('emitted description ends with "..." when truncated', () => {
|
||||
const cmd = makeEmojiCommand(1020, 10); // 1030 code points → must truncate
|
||||
const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test');
|
||||
|
||||
const descMatch = result.match(/^description:\s*(.+)$/m);
|
||||
assert.ok(descMatch, 'emitted SKILL.md must have a description field');
|
||||
const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, '');
|
||||
|
||||
assert.ok(rawDesc.endsWith('...'), `truncated description must end with "...", got: ${rawDesc.slice(-10)}`);
|
||||
});
|
||||
|
||||
test('emitted description has no lone surrogate (no split emoji)', () => {
|
||||
// Place emojis exactly at positions 1021–1025 (code points) so that a naive
|
||||
// UTF-16 slice at 1021 code units would cut inside the second emoji's surrogate pair.
|
||||
// 1019 ASCII chars + 6 emoji = 1025 code points (>1024, triggers truncation).
|
||||
// UTF-16 length = 1019 + 12 = 1031. Naive slice(0,1021) yields 1019 ASCII +
|
||||
// the HIGH surrogate of emoji[0] — a lone surrogate.
|
||||
const cmd = makeEmojiCommand(1019, 6);
|
||||
const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test');
|
||||
|
||||
const descMatch = result.match(/^description:\s*(.+)$/m);
|
||||
assert.ok(descMatch, 'emitted SKILL.md must have a description field');
|
||||
const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, '');
|
||||
|
||||
// Verify no lone surrogate: every char's code point must be outside [0xD800, 0xDFFF].
|
||||
const hasLoneSurrogate = [...rawDesc].some(c => {
|
||||
const cp = c.codePointAt(0);
|
||||
return cp >= 0xD800 && cp <= 0xDFFF;
|
||||
});
|
||||
assert.ok(!hasLoneSurrogate, 'emitted description must not contain a lone surrogate');
|
||||
|
||||
// Also round-trip through Buffer to confirm the string is valid UTF-8 encodable.
|
||||
assert.doesNotThrow(
|
||||
() => Buffer.from(rawDesc, 'utf8').toString('utf8'),
|
||||
'emitted description must round-trip through Buffer without error'
|
||||
);
|
||||
});
|
||||
|
||||
test('short description (<= 1024 code points) is not truncated', () => {
|
||||
// 10 ASCII + 5 emoji = 15 code points — well under the limit.
|
||||
const cmd = makeEmojiCommand(10, 5);
|
||||
const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test');
|
||||
|
||||
const descMatch = result.match(/^description:\s*(.+)$/m);
|
||||
assert.ok(descMatch, 'emitted SKILL.md must have a description field');
|
||||
const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, '');
|
||||
|
||||
assert.ok(!rawDesc.endsWith('...'), 'short description must NOT be truncated with "..."');
|
||||
// Must contain the original emoji characters intact
|
||||
assert.ok(rawDesc.includes('😀'), 'short description must preserve emoji characters');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fix 2 regression: cline local scope emits no skills ─────────────────────
|
||||
//
|
||||
// resolveRuntimeArtifactLayout('cline', dir, 'local') must return 0 kinds.
|
||||
// installRuntimeArtifacts('cline', dir, 'local') must not write any skills.
|
||||
|
||||
describe('resolveRuntimeArtifactLayout — cline scope-aware (Fix 2)', () => {
|
||||
test('cline local: kinds.length === 0 (no skills for local scope)', () => {
|
||||
const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
||||
const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x', 'local');
|
||||
assert.strictEqual(layout.kinds.length, 0, 'cline local must have 0 kinds');
|
||||
});
|
||||
|
||||
test('cline global: kinds.length === 1 (skills kind)', () => {
|
||||
const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
||||
const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x', 'global');
|
||||
assert.strictEqual(layout.kinds.length, 1, 'cline global must have 1 skills kind');
|
||||
assert.strictEqual(layout.kinds[0].kind, 'skills');
|
||||
});
|
||||
|
||||
test('installRuntimeArtifacts cline local: no skills/ dir created', (t) => {
|
||||
const configDir = createTempDir('gsd-cline-local-noskills-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
assert.doesNotThrow(() => installRuntimeArtifacts('cline', configDir, 'local', RESOLVED_CORE));
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
assert.ok(
|
||||
!fs.existsSync(skillsDir),
|
||||
`skills/ must NOT be created for cline local install (Fix 2), but found ${skillsDir}`
|
||||
);
|
||||
});
|
||||
});
|
||||
105
tests/bug-783-kilo-global-skills-base.test.cjs
Normal file
105
tests/bug-783-kilo-global-skills-base.test.cjs
Normal file
@@ -0,0 +1,105 @@
|
||||
'use strict';
|
||||
// Regression guard for bug #783.
|
||||
//
|
||||
// getGlobalSkillsBase('kilo') was returning ~/.config/kilo/skills (the XDG
|
||||
// config dir) instead of ~/.kilo/skills — where Kilo Code actually discovers
|
||||
// global skills per its docs:
|
||||
// https://kilo.ai/docs/customize/skills
|
||||
// "Global skills are located in the `.kilo` directory within your Home
|
||||
// directory: ~/.kilo/skills/"
|
||||
//
|
||||
// The fix adds a special case in getGlobalSkillsBase() that resolves kilo's
|
||||
// skills dir from HOME (not from the XDG config dir). The config dir at
|
||||
// ~/.config/kilo is still CORRECT for commands (command/) and must stay
|
||||
// unchanged — this test verifies both roles are separate.
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const {
|
||||
getGlobalConfigDir,
|
||||
getGlobalSkillsBase,
|
||||
} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs'));
|
||||
|
||||
// Helper: temporarily override env vars for a test, restoring them afterwards.
|
||||
function withEnv(overrides, fn) {
|
||||
const saved = {};
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
saved[key] = process.env[key];
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
for (const [key] of Object.entries(overrides)) {
|
||||
if (saved[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = saved[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all kilo-relevant env vars so tests are hermetic.
|
||||
const kiloEnvClears = {
|
||||
KILO_CONFIG_DIR: undefined,
|
||||
XDG_CONFIG_HOME: undefined,
|
||||
};
|
||||
|
||||
describe('bug #783: kilo global skills dir is ~/.kilo/skills, not ~/.config/kilo/skills', () => {
|
||||
test('getGlobalSkillsBase("kilo") resolves to ~/.kilo/skills', () => {
|
||||
withEnv(kiloEnvClears, () => {
|
||||
assert.strictEqual(
|
||||
getGlobalSkillsBase('kilo'),
|
||||
path.join(os.homedir(), '.kilo', 'skills'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('getGlobalConfigDir("kilo") still resolves to ~/.config/kilo (config dir unchanged)', () => {
|
||||
withEnv(kiloEnvClears, () => {
|
||||
assert.strictEqual(
|
||||
getGlobalConfigDir('kilo'),
|
||||
path.join(os.homedir(), '.config', 'kilo'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('kilo skills dir and config dir are decoupled (not equal, not nested)', () => {
|
||||
withEnv(kiloEnvClears, () => {
|
||||
const skillsBase = getGlobalSkillsBase('kilo');
|
||||
const configDir = getGlobalConfigDir('kilo');
|
||||
|
||||
assert.notStrictEqual(skillsBase, configDir, 'skills dir must differ from config dir');
|
||||
assert.ok(
|
||||
!skillsBase.startsWith(configDir + path.sep),
|
||||
`skills dir (${skillsBase}) must not be nested under config dir (${configDir})`,
|
||||
);
|
||||
assert.ok(
|
||||
!configDir.startsWith(skillsBase + path.sep),
|
||||
`config dir (${configDir}) must not be nested under skills dir (${skillsBase})`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('getGlobalSkillsBase("kilo") is NOT affected by KILO_CONFIG_DIR override', () => {
|
||||
// Skills always live in ~/.kilo/skills regardless of XDG/config-dir overrides.
|
||||
withEnv({ KILO_CONFIG_DIR: '/tmp/custom-kilo-config', XDG_CONFIG_HOME: undefined }, () => {
|
||||
assert.strictEqual(
|
||||
getGlobalSkillsBase('kilo'),
|
||||
path.join(os.homedir(), '.kilo', 'skills'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('getGlobalSkillsBase("kilo") is NOT affected by XDG_CONFIG_HOME override', () => {
|
||||
withEnv({ KILO_CONFIG_DIR: undefined, XDG_CONFIG_HOME: '/tmp/custom-xdg' }, () => {
|
||||
assert.strictEqual(
|
||||
getGlobalSkillsBase('kilo'),
|
||||
path.join(os.homedir(), '.kilo', 'skills'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -142,17 +142,19 @@ describe('Cline install (local)', () => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('install creates .clinerules file', () => {
|
||||
test('install creates .clinerules directory with gsd.md (#787 directory form)', () => {
|
||||
install(false, 'cline');
|
||||
const clinerules = path.join(tmpDir, '.clinerules');
|
||||
assert.ok(fs.existsSync(clinerules), '.clinerules must exist after cline install');
|
||||
const clinerulesDir = path.join(tmpDir, '.clinerules');
|
||||
assert.ok(fs.existsSync(clinerulesDir), '.clinerules must exist after cline install');
|
||||
assert.ok(fs.statSync(clinerulesDir).isDirectory(), '.clinerules must be a directory (#787)');
|
||||
assert.ok(fs.existsSync(path.join(clinerulesDir, 'gsd.md')), '.clinerules/gsd.md must exist');
|
||||
});
|
||||
|
||||
test('.clinerules contains GSD instructions', () => {
|
||||
test('.clinerules/gsd.md contains GSD instructions', () => {
|
||||
install(false, 'cline');
|
||||
const clinerules = path.join(tmpDir, '.clinerules');
|
||||
const content = fs.readFileSync(clinerules, 'utf8');
|
||||
assert.ok(content.includes('GSD') || content.includes('gsd'), '.clinerules must reference GSD');
|
||||
const ruleFile = path.join(tmpDir, '.clinerules', 'gsd.md');
|
||||
const content = fs.readFileSync(ruleFile, 'utf8');
|
||||
assert.ok(content.includes('GSD') || content.includes('gsd'), '.clinerules/gsd.md must reference GSD');
|
||||
});
|
||||
|
||||
test('install creates gsd-core engine directory', () => {
|
||||
|
||||
@@ -38,6 +38,9 @@ const {
|
||||
GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER,
|
||||
mergeCopilotInstructions,
|
||||
stripGsdFromCopilotInstructions,
|
||||
GSD_COPILOT_HOOK_FILE,
|
||||
buildCopilotHookConfig,
|
||||
writeCopilotHookConfig,
|
||||
writeManifest,
|
||||
reportLocalPatches,
|
||||
installRuntimeArtifacts,
|
||||
@@ -75,9 +78,11 @@ describe('getDirName (Copilot)', () => {
|
||||
|
||||
describe('getGlobalConfigDir (Copilot)', () => {
|
||||
let originalCopilotConfigDir;
|
||||
let originalCopilotHome;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCopilotConfigDir = process.env.COPILOT_CONFIG_DIR;
|
||||
originalCopilotHome = process.env.COPILOT_HOME;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -86,10 +91,16 @@ describe('getGlobalConfigDir (Copilot)', () => {
|
||||
} else {
|
||||
delete process.env.COPILOT_CONFIG_DIR;
|
||||
}
|
||||
if (originalCopilotHome !== undefined) {
|
||||
process.env.COPILOT_HOME = originalCopilotHome;
|
||||
} else {
|
||||
delete process.env.COPILOT_HOME;
|
||||
}
|
||||
});
|
||||
|
||||
test('returns ~/.copilot with no env var or explicit dir', () => {
|
||||
delete process.env.COPILOT_CONFIG_DIR;
|
||||
delete process.env.COPILOT_HOME;
|
||||
const result = getGlobalConfigDir('copilot');
|
||||
assert.strictEqual(result, path.join(os.homedir(), '.copilot'));
|
||||
});
|
||||
@@ -111,6 +122,34 @@ describe('getGlobalConfigDir (Copilot)', () => {
|
||||
assert.strictEqual(result, '/explicit/path');
|
||||
});
|
||||
|
||||
test('respects COPILOT_HOME env var', () => {
|
||||
delete process.env.COPILOT_CONFIG_DIR;
|
||||
process.env.COPILOT_HOME = '/custom/copilot-home';
|
||||
const result = getGlobalConfigDir('copilot');
|
||||
assert.strictEqual(result, '/custom/copilot-home');
|
||||
});
|
||||
|
||||
test('COPILOT_HOME supports tilde expansion', () => {
|
||||
delete process.env.COPILOT_CONFIG_DIR;
|
||||
process.env.COPILOT_HOME = '~/my-copilot';
|
||||
const result = getGlobalConfigDir('copilot');
|
||||
assert.strictEqual(result, path.join(os.homedir(), 'my-copilot'));
|
||||
});
|
||||
|
||||
test('COPILOT_CONFIG_DIR takes priority over COPILOT_HOME', () => {
|
||||
process.env.COPILOT_CONFIG_DIR = '/config-dir-path';
|
||||
process.env.COPILOT_HOME = '/home-path';
|
||||
const result = getGlobalConfigDir('copilot');
|
||||
assert.strictEqual(result, '/config-dir-path');
|
||||
});
|
||||
|
||||
test('explicit dir takes priority over COPILOT_HOME', () => {
|
||||
delete process.env.COPILOT_CONFIG_DIR;
|
||||
process.env.COPILOT_HOME = '/home-path';
|
||||
const result = getGlobalConfigDir('copilot', '/explicit/path');
|
||||
assert.strictEqual(result, '/explicit/path');
|
||||
});
|
||||
|
||||
test('does not break existing runtimes', () => {
|
||||
assert.strictEqual(getGlobalConfigDir('claude'), path.join(os.homedir(), '.claude'));
|
||||
assert.strictEqual(getGlobalConfigDir('codex'), path.join(os.homedir(), '.codex'));
|
||||
@@ -1040,6 +1079,112 @@ describe('Copilot instructions merge/strip', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Copilot lifecycle hooks (#786) ────────────────────────────────────────────
|
||||
|
||||
describe('Copilot lifecycle hook config (#786)', () => {
|
||||
describe('buildCopilotHookConfig', () => {
|
||||
test('emits the documented Copilot hooks-config shape', () => {
|
||||
const cfg = buildCopilotHookConfig();
|
||||
assert.strictEqual(cfg.version, 1, 'version must be 1 per Copilot hooks schema');
|
||||
assert.ok(cfg.hooks && typeof cfg.hooks === 'object', 'has hooks object');
|
||||
assert.ok(Array.isArray(cfg.hooks.sessionStart), 'sessionStart is an array (camelCase event name)');
|
||||
assert.strictEqual(cfg.hooks.sessionStart.length, 1, 'one sessionStart entry');
|
||||
});
|
||||
|
||||
test('sessionStart entry is a self-contained inline command hook', () => {
|
||||
const [entry] = buildCopilotHookConfig().hooks.sessionStart;
|
||||
assert.strictEqual(entry.type, 'command', 'type is command');
|
||||
assert.ok(typeof entry.bash === 'string' && entry.bash.length > 0, 'has inline bash body');
|
||||
assert.ok(typeof entry.powershell === 'string' && entry.powershell.length > 0, 'has inline powershell body');
|
||||
assert.strictEqual(entry.timeoutSec, 10, 'uses timeoutSec (Copilot field), not timeout');
|
||||
});
|
||||
|
||||
test('command bodies emit the Copilot sessionStart JSON envelope (additionalContext)', () => {
|
||||
// Copilot parses command-hook stdout as JSON; sessionStart schema is
|
||||
// { additionalContext?: string }. Bare text would be invalid hook output.
|
||||
const [entry] = buildCopilotHookConfig().hooks.sessionStart;
|
||||
assert.ok(entry.bash.includes('"additionalContext"'), 'bash body emits additionalContext JSON');
|
||||
assert.ok(entry.powershell.includes('"additionalContext"'), 'powershell body emits additionalContext JSON');
|
||||
});
|
||||
|
||||
test('executing the bash hook body produces valid sessionStart JSON', { skip: process.platform === 'win32' }, () => {
|
||||
const { execFileSync } = require('child_process');
|
||||
const [entry] = buildCopilotHookConfig().hooks.sessionStart;
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-hook-exec-'));
|
||||
try {
|
||||
// No .planning/STATE.md → absent branch
|
||||
const outAbsent = execFileSync('bash', ['-c', entry.bash], { cwd: tmp, encoding: 'utf8' });
|
||||
const parsedAbsent = JSON.parse(outAbsent);
|
||||
assert.ok(typeof parsedAbsent.additionalContext === 'string', 'absent branch yields additionalContext string');
|
||||
assert.ok(/gsd-new-project/.test(parsedAbsent.additionalContext), 'absent branch suggests gsd-new-project');
|
||||
|
||||
// With .planning/STATE.md → present branch
|
||||
fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmp, '.planning', 'STATE.md'), '# state\n');
|
||||
const outPresent = execFileSync('bash', ['-c', entry.bash], { cwd: tmp, encoding: 'utf8' });
|
||||
const parsedPresent = JSON.parse(outPresent);
|
||||
assert.ok(/STATE\.md present/.test(parsedPresent.additionalContext), 'present branch references STATE.md');
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('hook command references no external script path (cannot dangle)', () => {
|
||||
const [entry] = buildCopilotHookConfig().hooks.sessionStart;
|
||||
// A dangling hook points at a hook SCRIPT file the installer never wrote.
|
||||
// The GSD Copilot hook is inline, so it must not reference hooks/gsd-*.js|sh.
|
||||
assert.ok(!/hooks\/gsd-[\w-]+\.(js|cjs|sh)/.test(entry.bash), 'bash body references no gsd hook script file');
|
||||
assert.ok(!/hooks\/gsd-[\w-]+\.(js|cjs|sh)/.test(entry.powershell), 'powershell body references no gsd hook script file');
|
||||
});
|
||||
|
||||
test('produces valid JSON', () => {
|
||||
const json = JSON.stringify(buildCopilotHookConfig());
|
||||
assert.doesNotThrow(() => JSON.parse(json), 'config round-trips through JSON');
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeCopilotHookConfig', () => {
|
||||
let tmpHookDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHookDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-copilot-hook-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpHookDir);
|
||||
});
|
||||
|
||||
test('writes hooks/gsd-session.json under the config dir', () => {
|
||||
const written = writeCopilotHookConfig(tmpHookDir);
|
||||
const expected = path.join(tmpHookDir, 'hooks', GSD_COPILOT_HOOK_FILE);
|
||||
assert.strictEqual(written, expected, 'returns the written path');
|
||||
assert.ok(fs.existsSync(expected), 'hook config file exists');
|
||||
const parsed = JSON.parse(fs.readFileSync(expected, 'utf8'));
|
||||
assert.strictEqual(parsed.version, 1, 'written file has version 1');
|
||||
assert.ok(Array.isArray(parsed.hooks.sessionStart), 'written file has sessionStart array');
|
||||
});
|
||||
|
||||
test('is idempotent and overwrites the managed file in place', () => {
|
||||
writeCopilotHookConfig(tmpHookDir);
|
||||
const hookPath = path.join(tmpHookDir, 'hooks', GSD_COPILOT_HOOK_FILE);
|
||||
fs.writeFileSync(hookPath, '{"stale":true}\n');
|
||||
writeCopilotHookConfig(tmpHookDir);
|
||||
const parsed = JSON.parse(fs.readFileSync(hookPath, 'utf8'));
|
||||
assert.strictEqual(parsed.stale, undefined, 'stale content replaced');
|
||||
assert.strictEqual(parsed.version, 1, 'managed content restored');
|
||||
});
|
||||
|
||||
test('preserves sibling user-authored hook files', () => {
|
||||
const hooksDir = path.join(tmpHookDir, 'hooks');
|
||||
fs.mkdirSync(hooksDir, { recursive: true });
|
||||
const userHook = path.join(hooksDir, 'my-hook.json');
|
||||
fs.writeFileSync(userHook, '{"version":1,"hooks":{}}\n');
|
||||
writeCopilotHookConfig(tmpHookDir);
|
||||
assert.ok(fs.existsSync(userHook), 'user hook file untouched');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Copilot uninstall skill removal ───────────────────────────────────────────
|
||||
|
||||
describe('Copilot uninstall skill removal', () => {
|
||||
@@ -1321,6 +1466,23 @@ describe('E2E: Copilot full install verification', () => {
|
||||
'Should contain GSD Configuration close marker');
|
||||
});
|
||||
|
||||
test('emits AGENTS.md at the repo root with GSD markers (#786)', () => {
|
||||
const agentsMdPath = path.join(tmpDir, 'AGENTS.md');
|
||||
assert.ok(fs.existsSync(agentsMdPath), 'AGENTS.md should exist at repo root for local install');
|
||||
const content = fs.readFileSync(agentsMdPath, 'utf-8');
|
||||
assert.ok(content.includes('<!-- GSD Configuration'), 'AGENTS.md has GSD open marker');
|
||||
assert.ok(content.includes('<!-- /GSD Configuration -->'), 'AGENTS.md has GSD close marker');
|
||||
});
|
||||
|
||||
test('emits a Copilot lifecycle hook config (#786)', () => {
|
||||
const hookPath = path.join(tmpDir, '.github', 'hooks', 'gsd-session.json');
|
||||
assert.ok(fs.existsSync(hookPath), '.github/hooks/gsd-session.json should exist');
|
||||
const cfg = JSON.parse(fs.readFileSync(hookPath, 'utf-8'));
|
||||
assert.strictEqual(cfg.version, 1, 'hook config has version 1');
|
||||
assert.ok(Array.isArray(cfg.hooks.sessionStart), 'hook config has sessionStart array');
|
||||
assert.strictEqual(cfg.hooks.sessionStart[0].type, 'command', 'sessionStart is a command hook');
|
||||
});
|
||||
|
||||
test('creates manifest with correct structure', () => {
|
||||
const manifestPath = path.join(tmpDir, '.github', 'gsd-file-manifest.json');
|
||||
assert.ok(fs.existsSync(manifestPath), 'gsd-file-manifest.json should exist');
|
||||
@@ -1429,6 +1591,16 @@ describe('E2E: Copilot uninstall verification', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('removes the Copilot lifecycle hook config (#786)', () => {
|
||||
const hookPath = path.join(tmpDir, '.github', 'hooks', 'gsd-session.json');
|
||||
assert.ok(!fs.existsSync(hookPath), 'gsd-session.json should not exist after uninstall');
|
||||
});
|
||||
|
||||
test('removes GSD-only AGENTS.md (#786)', () => {
|
||||
const agentsMdPath = path.join(tmpDir, 'AGENTS.md');
|
||||
assert.ok(!fs.existsSync(agentsMdPath), 'GSD-only AGENTS.md should be removed after uninstall');
|
||||
});
|
||||
|
||||
describe('preserves non-GSD content', () => {
|
||||
let td;
|
||||
|
||||
@@ -1463,6 +1635,90 @@ describe('E2E: Copilot uninstall verification', () => {
|
||||
assert.ok(fs.existsSync(customAgentPath),
|
||||
'Non-GSD agent file should be preserved after uninstall');
|
||||
});
|
||||
|
||||
test('preserves user-authored content in AGENTS.md on uninstall (#786)', () => {
|
||||
// After install, AGENTS.md exists with the GSD block. Prepend user content.
|
||||
const agentsMdPath = path.join(td, 'AGENTS.md');
|
||||
assert.ok(fs.existsSync(agentsMdPath), 'AGENTS.md created by install');
|
||||
const gsdBlock = fs.readFileSync(agentsMdPath, 'utf-8');
|
||||
fs.writeFileSync(agentsMdPath, '# My Project Notes\n\nKeep these.\n\n' + gsdBlock);
|
||||
// Uninstall strips only the GSD section
|
||||
runCopilotUninstall(td);
|
||||
assert.ok(fs.existsSync(agentsMdPath), 'AGENTS.md preserved (had user content)');
|
||||
const after = fs.readFileSync(agentsMdPath, 'utf-8');
|
||||
assert.ok(after.includes('# My Project Notes'), 'user content preserved');
|
||||
assert.ok(!after.includes('<!-- GSD Configuration'), 'GSD section stripped');
|
||||
});
|
||||
|
||||
test('preserves a user-authored sibling hook file on uninstall (#786)', () => {
|
||||
const userHook = path.join(td, '.github', 'hooks', 'user-hook.json');
|
||||
fs.writeFileSync(userHook, '{"version":1,"hooks":{}}\n');
|
||||
runCopilotUninstall(td);
|
||||
assert.ok(fs.existsSync(userHook), 'user-authored hook file preserved');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── E2E: Copilot global scope (#786) ──────────────────────────────────────────
|
||||
|
||||
function runCopilotInstallGlobal(cwd, configDir) {
|
||||
const env = { ...process.env };
|
||||
delete env.GSD_TEST_MODE;
|
||||
return execFileSync(process.execPath,
|
||||
[INSTALL_PATH, '--copilot', '--global', '--config-dir', configDir, '--no-sdk'], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
function runCopilotUninstallGlobal(cwd, configDir) {
|
||||
const env = { ...process.env };
|
||||
delete env.GSD_TEST_MODE;
|
||||
return execFileSync(process.execPath,
|
||||
[INSTALL_PATH, '--copilot', '--global', '--config-dir', configDir, '--uninstall', '--no-sdk'], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
describe('E2E: Copilot global install (#786)', () => {
|
||||
let projectDir;
|
||||
let configDir;
|
||||
|
||||
beforeEach(() => {
|
||||
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-e2e-gproj-'));
|
||||
configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-e2e-gcfg-'));
|
||||
runCopilotInstallGlobal(projectDir, configDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(projectDir);
|
||||
cleanup(configDir);
|
||||
});
|
||||
|
||||
test('writes the lifecycle hook config under the global config dir', () => {
|
||||
const hookPath = path.join(configDir, 'hooks', 'gsd-session.json');
|
||||
assert.ok(fs.existsSync(hookPath), 'global hook config should exist under config dir');
|
||||
const cfg = JSON.parse(fs.readFileSync(hookPath, 'utf-8'));
|
||||
assert.strictEqual(cfg.version, 1, 'hook config version is 1');
|
||||
assert.ok(Array.isArray(cfg.hooks.sessionStart), 'has sessionStart array');
|
||||
});
|
||||
|
||||
test('does NOT emit AGENTS.md for global scope (no repo-root home)', () => {
|
||||
assert.ok(!fs.existsSync(path.join(projectDir, 'AGENTS.md')),
|
||||
'global install must not write AGENTS.md into the working directory');
|
||||
assert.ok(!fs.existsSync(path.join(configDir, 'AGENTS.md')),
|
||||
'global install must not write AGENTS.md into the config directory');
|
||||
});
|
||||
|
||||
test('global uninstall removes the lifecycle hook config', () => {
|
||||
runCopilotUninstallGlobal(projectDir, configDir);
|
||||
const hookPath = path.join(configDir, 'hooks', 'gsd-session.json');
|
||||
assert.ok(!fs.existsSync(hookPath), 'global hook config removed after uninstall');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
* Ensures Cursor frontmatter names are emitted as plain identifiers
|
||||
* (without surrounding quotes), so Cursor does not treat quotes as
|
||||
* literal parts of skill/subagent names.
|
||||
*
|
||||
* Also covers convertClaudeCommandToCursorCommand (#785 — Cursor 1.6
|
||||
* slash commands via .cursor/commands/).
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
@@ -14,6 +17,7 @@ const assert = require('node:assert/strict');
|
||||
const {
|
||||
convertClaudeCommandToCursorSkill,
|
||||
convertClaudeAgentToCursorAgent,
|
||||
convertClaudeCommandToCursorCommand,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
describe('convertClaudeCommandToCursorSkill', () => {
|
||||
@@ -79,3 +83,71 @@ Planner body
|
||||
assert.ok(!result.includes('name: "gsd-planner"'), 'quoted agent name is not emitted');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── convertClaudeCommandToCursorCommand (#785) ───────────────────────────────
|
||||
|
||||
describe('convertClaudeCommandToCursorCommand (#785 — Cursor 1.6 .cursor/commands/)', () => {
|
||||
test('strips YAML frontmatter — output is plain markdown', () => {
|
||||
const input = `---
|
||||
name: help
|
||||
description: Show help for GSD commands
|
||||
---
|
||||
|
||||
# GSD Help
|
||||
|
||||
Use \`/gsd-help\` to see available commands.
|
||||
`;
|
||||
|
||||
const result = convertClaudeCommandToCursorCommand(input);
|
||||
assert.ok(!result.startsWith('---'), 'cursor commands must not have YAML frontmatter');
|
||||
assert.ok(!result.includes('name: help'), 'name field must be stripped');
|
||||
assert.ok(!result.includes('description:'), 'description field must be stripped');
|
||||
assert.ok(result.includes('GSD Help'), 'body content must be preserved');
|
||||
});
|
||||
|
||||
test('applies convertClaudeToCursorMarkdown transforms (Bash → Shell, Claude Code → Cursor)', () => {
|
||||
const input = `---
|
||||
name: quick
|
||||
description: Quick task
|
||||
---
|
||||
|
||||
Use Bash( to run commands.
|
||||
This runs in Claude Code.
|
||||
`;
|
||||
|
||||
const result = convertClaudeCommandToCursorCommand(input);
|
||||
assert.ok(result.includes('Shell('), 'Bash( should be renamed to Shell(');
|
||||
assert.ok(!result.includes('Claude Code'), 'Claude Code brand reference should be replaced');
|
||||
assert.ok(result.includes('Cursor'), 'should reference Cursor instead');
|
||||
});
|
||||
|
||||
test('normalizes gsd: colon slash commands to gsd- hyphen form', () => {
|
||||
const input = `---
|
||||
name: plan-phase
|
||||
description: Plan a phase
|
||||
---
|
||||
|
||||
Next step: /gsd:execute-phase 17
|
||||
`;
|
||||
|
||||
const result = convertClaudeCommandToCursorCommand(input);
|
||||
assert.ok(result.includes('/gsd-execute-phase 17'), 'colon form should become hyphen form');
|
||||
assert.ok(!result.includes('/gsd:execute-phase'), 'colon form should be removed');
|
||||
});
|
||||
|
||||
test('handles input with no frontmatter gracefully', () => {
|
||||
const input = `# No Frontmatter Command
|
||||
|
||||
Some body content.
|
||||
`;
|
||||
|
||||
const result = convertClaudeCommandToCursorCommand(input);
|
||||
assert.ok(!result.startsWith('---'), 'output must not start with ---');
|
||||
assert.ok(result.includes('No Frontmatter Command'), 'body should be preserved');
|
||||
});
|
||||
|
||||
test('is exported from install.js', () => {
|
||||
assert.strictEqual(typeof convertClaudeCommandToCursorCommand, 'function',
|
||||
'convertClaudeCommandToCursorCommand must be exported from install.js');
|
||||
});
|
||||
});
|
||||
|
||||
268
tests/enh-788-qwen-hook-events.test.cjs
Normal file
268
tests/enh-788-qwen-hook-events.test.cjs
Normal file
@@ -0,0 +1,268 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Enhancement #788: Expand Qwen Code hook-event coverage.
|
||||
*
|
||||
* Qwen Code supports 15 hook events; gsd previously registered only
|
||||
* SessionStart and PostToolUse. This suite asserts that a Qwen install
|
||||
* registers the 3 new high-value events:
|
||||
* - SubagentStop — subagent lifecycle finalisation (context tracking)
|
||||
* - Stop — model stop / final-response hook (context tracking)
|
||||
* - PreCompact — pre-compaction awareness (context tracking)
|
||||
*
|
||||
* All three are wired to gsd-context-monitor.js — the same hook used for
|
||||
* PostToolUse — so context headroom warnings surface at these moments too.
|
||||
*
|
||||
* Note: UserPromptSubmit is NOT wired — gsd-prompt-guard exits unless
|
||||
* tool_name is Write|Edit (PreToolUse shape), so it would be a no-op for
|
||||
* the UserPromptSubmit payload. Deferred to a follow-on issue.
|
||||
*
|
||||
* Also asserts the inverse: Claude Code installs do NOT gain these events
|
||||
* (strict isQwen scope guard).
|
||||
*
|
||||
* Source: https://qwenlm.github.io/qwen-code-docs/en/users/features/hooks/
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { install, uninstall, validateHookFields } = require('../bin/install.js');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Extract all hook commands registered under `eventName` from settings. */
|
||||
function hooksForEvent(settings, eventName) {
|
||||
if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return [];
|
||||
return settings.hooks[eventName].flatMap(entry =>
|
||||
(entry && Array.isArray(entry.hooks) ? entry.hooks : [])
|
||||
.map(h => h && h.command)
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
// Stub JS hook files that the installer checks with fs.existsSync() so hook
|
||||
// registration guards pass even when hooks/dist/ isn't built.
|
||||
const HOOKS_SRC = path.join(__dirname, '..', 'hooks');
|
||||
const STUB_HOOKS = [
|
||||
'gsd-context-monitor.js',
|
||||
'gsd-prompt-guard.js',
|
||||
'gsd-check-update.js',
|
||||
];
|
||||
|
||||
function stubHooksIntoTarget(targetDir) {
|
||||
const hooksDest = path.join(targetDir, 'hooks');
|
||||
fs.mkdirSync(hooksDest, { recursive: true });
|
||||
for (const hookFile of STUB_HOOKS) {
|
||||
const src = path.join(HOOKS_SRC, hookFile);
|
||||
const dest = path.join(hooksDest, hookFile);
|
||||
if (fs.existsSync(src)) {
|
||||
fs.copyFileSync(src, dest);
|
||||
} else {
|
||||
// Minimal stub so existsSync passes
|
||||
fs.writeFileSync(dest, '#!/usr/bin/env node\n// stub\n');
|
||||
}
|
||||
try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist in-memory settings to disk, simulating what finishInstall() does
|
||||
* (finishInstall is not exported). Required for tests that call install()
|
||||
* twice and need the second call to read the first call's hook registrations.
|
||||
*/
|
||||
function persistSettings(settingsPath, settings) {
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(validateHookFields(settings), null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
// ─── Suite 1: Qwen — new events are registered ───────────────────────────────
|
||||
|
||||
describe('enh-788: Qwen install registers 3 new hook events', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
let settings;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-788-qwen-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const targetDir = path.join(tmpDir, '.qwen');
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
// Pre-populate hook files so installer registration guards (fs.existsSync)
|
||||
// pass and hooks are actually registered in settings.json.
|
||||
stubHooksIntoTarget(targetDir);
|
||||
|
||||
const result = install(false, 'qwen');
|
||||
settings = result.settings;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('install returns a settings object (not null)', () => {
|
||||
assert.ok(settings !== null && typeof settings === 'object',
|
||||
'Qwen install must return a non-null settings object');
|
||||
});
|
||||
|
||||
test('SubagentStop event is registered with at least one hook', () => {
|
||||
const cmds = hooksForEvent(settings, 'SubagentStop');
|
||||
assert.ok(cmds.length > 0,
|
||||
`Expected SubagentStop hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`);
|
||||
});
|
||||
|
||||
test('Stop event is registered with at least one hook', () => {
|
||||
const cmds = hooksForEvent(settings, 'Stop');
|
||||
assert.ok(cmds.length > 0,
|
||||
`Expected Stop hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`);
|
||||
});
|
||||
|
||||
test('PreCompact event is registered with at least one hook', () => {
|
||||
const cmds = hooksForEvent(settings, 'PreCompact');
|
||||
assert.ok(cmds.length > 0,
|
||||
`Expected PreCompact hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`);
|
||||
});
|
||||
|
||||
test('UserPromptSubmit is NOT registered (handler not yet implemented for that payload shape)', () => {
|
||||
// gsd-prompt-guard exits unless tool_name is Write|Edit — it is a no-op
|
||||
// for UserPromptSubmit payloads. Registration is deferred until a
|
||||
// dedicated hook can process the user-prompt payload shape.
|
||||
const cmds = hooksForEvent(settings, 'UserPromptSubmit');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`UserPromptSubmit should NOT be registered yet; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('SubagentStop / Stop / PreCompact all use gsd-context-monitor', () => {
|
||||
for (const event of ['SubagentStop', 'Stop', 'PreCompact']) {
|
||||
const cmds = hooksForEvent(settings, event);
|
||||
assert.ok(
|
||||
cmds.some(c => c.includes('gsd-context-monitor')),
|
||||
`Event ${event} should use gsd-context-monitor; got commands: ${JSON.stringify(cmds)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 2: Claude install does NOT get the new events ─────────────────────
|
||||
|
||||
describe('enh-788: Claude install does NOT register Qwen-only hook events', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
let settings;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-788-claude-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const result = install(false, 'claude');
|
||||
settings = result && result.settings;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('Claude install does not register SubagentStop', () => {
|
||||
const cmds = hooksForEvent(settings, 'SubagentStop');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`Claude should NOT have SubagentStop; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('Claude install does not register Stop', () => {
|
||||
const cmds = hooksForEvent(settings, 'Stop');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`Claude should NOT have Stop; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('Claude install does not register PreCompact', () => {
|
||||
const cmds = hooksForEvent(settings, 'PreCompact');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`Claude should NOT have PreCompact; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 3: Idempotency — persisted reinstall does not duplicate hooks ──────
|
||||
|
||||
describe('enh-788: Qwen install is idempotent across persisted reinstalls', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-788-idem-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const targetDir = path.join(tmpDir, '.qwen');
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
stubHooksIntoTarget(targetDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('re-running after persisted first install does not duplicate hook entries', () => {
|
||||
// First install: get settings and persist to disk (simulating finishInstall)
|
||||
const result1 = install(false, 'qwen');
|
||||
persistSettings(result1.settingsPath, result1.settings);
|
||||
|
||||
// Second install: reads the persisted settings.json — dedup guards apply
|
||||
process.chdir(tmpDir);
|
||||
const result2 = install(false, 'qwen');
|
||||
const s2 = result2.settings;
|
||||
|
||||
for (const event of ['SubagentStop', 'Stop', 'PreCompact']) {
|
||||
const cmds = hooksForEvent(s2, event);
|
||||
assert.strictEqual(cmds.length, 1,
|
||||
`Event ${event} should have exactly 1 hook command after idempotent reinstall; got ${cmds.length}: ${JSON.stringify(cmds)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 4: Uninstall removes the new event registrations ──────────────────
|
||||
|
||||
describe('enh-788: Qwen uninstall removes new hook event entries', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-788-uninstall-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const targetDir = path.join(tmpDir, '.qwen');
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
stubHooksIntoTarget(targetDir);
|
||||
|
||||
// Install and persist to disk so uninstall has a settings.json to clean
|
||||
const result = install(false, 'qwen');
|
||||
persistSettings(result.settingsPath, result.settings);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('settings.json hook entries are removed on uninstall', () => {
|
||||
uninstall(false, 'qwen');
|
||||
const settingsPath = path.join(tmpDir, '.qwen', 'settings.json');
|
||||
if (!fs.existsSync(settingsPath)) return; // file removed entirely is fine
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
for (const event of ['SubagentStop', 'Stop', 'PreCompact']) {
|
||||
const cmds = hooksForEvent(settings, event);
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`After uninstall, ${event} should have 0 hooks; got: ${JSON.stringify(cmds)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
177
tests/enh-790-augment-commands.test.cjs
Normal file
177
tests/enh-790-augment-commands.test.cjs
Normal file
@@ -0,0 +1,177 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Regression guard — enh(#790): Augment commands/ emitted alongside skills/.
|
||||
*
|
||||
* Verifies that a global Augment install writes:
|
||||
* - commands/gsd-<stem>.md (slash command definitions)
|
||||
* - skills/gsd-<stem>/SKILL.md (existing skill definitions)
|
||||
*
|
||||
* mcpServers in settings.json is explicitly excluded: gsd ships no MCP server
|
||||
* and registering third-party servers is out of scope for the installer.
|
||||
*
|
||||
* Ref: https://docs.augmentcode.com/cli/reference — ~/.augment/commands/<name>.md
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const { installRuntimeArtifacts } = require('../bin/install.js');
|
||||
const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
||||
const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs');
|
||||
|
||||
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST });
|
||||
|
||||
// ─── Layout contract ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('enh-790 — augment layout has commands + skills kinds', () => {
|
||||
test('resolveRuntimeArtifactLayout augment returns 2 kinds', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('augment', '/tmp/fake-augment-dir');
|
||||
assert.strictEqual(layout.kinds.length, 2, 'augment must have exactly 2 artifact kinds');
|
||||
const kindNames = layout.kinds.map(k => k.kind).sort();
|
||||
assert.deepStrictEqual(kindNames, ['commands', 'skills']);
|
||||
});
|
||||
|
||||
test('augment commands kind targets commands/ with gsd- prefix', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('augment', '/tmp/fake-augment-dir');
|
||||
const commandsKind = layout.kinds.find(k => k.kind === 'commands');
|
||||
assert.ok(commandsKind, 'must have commands kind');
|
||||
assert.strictEqual(commandsKind.destSubpath, 'commands');
|
||||
assert.strictEqual(commandsKind.prefix, 'gsd-');
|
||||
});
|
||||
|
||||
test('augment skills kind targets skills/ with gsd- prefix', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('augment', '/tmp/fake-augment-dir');
|
||||
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
|
||||
assert.ok(skillsKind, 'must have skills kind');
|
||||
assert.strictEqual(skillsKind.destSubpath, 'skills');
|
||||
assert.strictEqual(skillsKind.prefix, 'gsd-');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Install contract ────────────────────────────────────────────────────────
|
||||
|
||||
describe('enh-790 — installRuntimeArtifacts augment emits both commands and skills', () => {
|
||||
test('global augment install: commands/gsd-help.md and skills/gsd-help/SKILL.md exist', (t) => {
|
||||
const configDir = createTempDir('gsd-enh790-augment-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
// Commands dir
|
||||
const commandsDir = path.join(configDir, 'commands');
|
||||
assert.ok(fs.existsSync(commandsDir), 'commands/ dir must exist');
|
||||
const cmdFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
|
||||
assert.ok(cmdFiles.length > 0, 'at least one gsd-*.md command file must be installed');
|
||||
assert.ok(fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'commands/gsd-help.md must exist');
|
||||
|
||||
// Skills dir (pre-existing behavior preserved)
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
assert.ok(fs.existsSync(skillsDir), 'skills/ dir must exist');
|
||||
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-help', 'SKILL.md')), 'skills/gsd-help/SKILL.md must exist');
|
||||
});
|
||||
|
||||
test('commands/gsd-help.md has Augment-compatible content (no raw ~/.claude/ refs)', (t) => {
|
||||
const configDir = createTempDir('gsd-enh790-content-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const helpCmd = path.join(configDir, 'commands', 'gsd-help.md');
|
||||
assert.ok(fs.existsSync(helpCmd), 'gsd-help.md must exist');
|
||||
const content = fs.readFileSync(helpCmd, 'utf8');
|
||||
// Should not have raw ~/.claude/ references after path rewrite
|
||||
assert.ok(!content.includes('~/.claude/'), 'commands must not contain raw ~/.claude/ refs');
|
||||
});
|
||||
|
||||
test('command count matches skill count (profile parity)', (t) => {
|
||||
const configDir = createTempDir('gsd-enh790-parity-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const commandsDir = path.join(configDir, 'commands');
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
const cmdCount = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length;
|
||||
const skillCount = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length;
|
||||
assert.strictEqual(cmdCount, skillCount, 'command count must equal skill count for same profile');
|
||||
});
|
||||
|
||||
test('full profile install does NOT mutate source commands/gsd/ files', (t) => {
|
||||
// Regression guard: stageSkillsForProfile returns the real source dir on full profile
|
||||
// (skills === '*'). applyRuntimeContentRewritesForCommandsInPlace must copy to temp
|
||||
// before rewriting — it must NEVER write back to the source tree.
|
||||
const { resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs');
|
||||
const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST });
|
||||
assert.strictEqual(RESOLVED_FULL.skills, '*', 'full profile must have skills === "*"');
|
||||
|
||||
const configDir = createTempDir('gsd-enh790-full-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
// Record source file content before install
|
||||
const srcHelpPath = path.join(__dirname, '..', 'commands', 'gsd', 'help.md');
|
||||
const srcContentBefore = fs.readFileSync(srcHelpPath, 'utf8');
|
||||
|
||||
installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_FULL);
|
||||
|
||||
// Source file must be identical after install
|
||||
const srcContentAfter = fs.readFileSync(srcHelpPath, 'utf8');
|
||||
assert.strictEqual(srcContentBefore, srcContentAfter,
|
||||
'source commands/gsd/help.md must not be mutated by the install');
|
||||
|
||||
// Installed command file must have rewrites applied (Augment path substitution)
|
||||
const installedHelp = path.join(configDir, 'commands', 'gsd-help.md');
|
||||
assert.ok(fs.existsSync(installedHelp), 'installed gsd-help.md must exist');
|
||||
const installedContent = fs.readFileSync(installedHelp, 'utf8');
|
||||
assert.ok(!installedContent.includes('~/.claude/'), 'installed command must not have raw ~/.claude/ refs');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Uninstall contract ──────────────────────────────────────────────────────
|
||||
|
||||
describe('enh-790 — uninstallRuntimeArtifacts removes augment commands', () => {
|
||||
test('uninstall removes gsd-* commands but preserves user commands', (t) => {
|
||||
const configDir = createTempDir('gsd-enh790-uninstall-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const { uninstallRuntimeArtifacts } = require('../bin/install.js');
|
||||
|
||||
// Pre-create: a GSD command + a user-owned command
|
||||
const commandsDir = path.join(configDir, 'commands');
|
||||
fs.mkdirSync(commandsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(commandsDir, 'gsd-help.md'), '# help\n');
|
||||
fs.writeFileSync(path.join(commandsDir, 'user-custom.md'), '# user\n');
|
||||
|
||||
uninstallRuntimeArtifacts('augment', configDir, 'global');
|
||||
|
||||
assert.ok(!fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'gsd-help.md must be removed');
|
||||
assert.ok(fs.existsSync(path.join(commandsDir, 'user-custom.md')), 'user-custom.md must be preserved');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── mcpServers exclusion ────────────────────────────────────────────────────
|
||||
|
||||
describe('enh-790 — mcpServers excluded (gsd ships no MCP server)', () => {
|
||||
test('augment install does not write settings.json mcpServers', (t) => {
|
||||
const configDir = createTempDir('gsd-enh790-mcp-excluded-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
// No settings.json with mcpServers should be written by the layout
|
||||
const settingsPath = path.join(configDir, 'settings.json');
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
assert.ok(!settings.mcpServers, 'settings.json must not contain mcpServers (gsd ships no MCP server)');
|
||||
}
|
||||
// If no settings.json at all, that is also correct
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
installRuntimeArtifacts,
|
||||
installOpencodeFamilySkills,
|
||||
parseRuntimeInput,
|
||||
allRuntimes,
|
||||
} = require('../bin/install.js');
|
||||
@@ -165,14 +166,44 @@ describe('installRuntimeArtifacts — gemini commands layout', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('installRuntimeArtifacts — cline no-op', () => {
|
||||
test('cline: no kinds — call succeeds, no dirs created', (t) => {
|
||||
describe('installRuntimeArtifacts — cursor commands layout (#785)', () => {
|
||||
test('cursor: skills/ AND commands/ both created; commands/gsd-help.md is plain markdown', (t) => {
|
||||
const configDir = createTempDir('gsd-ial-cursor-cmds-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
installRuntimeArtifacts('cursor', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
// Existing skills kind still present
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
assert.ok(fs.existsSync(skillsDir), 'skills/ must exist');
|
||||
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-help', 'SKILL.md')),
|
||||
'skills/gsd-help/SKILL.md must exist');
|
||||
|
||||
// New commands kind (#785)
|
||||
const commandsDir = path.join(configDir, 'commands');
|
||||
assert.ok(fs.existsSync(commandsDir), 'commands/ must exist (#785)');
|
||||
assert.ok(fs.existsSync(path.join(commandsDir, 'gsd-help.md')),
|
||||
'commands/gsd-help.md must exist (#785)');
|
||||
|
||||
// Cursor commands are plain markdown — no YAML frontmatter
|
||||
const helpContent = fs.readFileSync(path.join(commandsDir, 'gsd-help.md'), 'utf8');
|
||||
assert.ok(!helpContent.startsWith('---'), 'cursor commands must not start with YAML frontmatter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('installRuntimeArtifacts — cline skills (#782)', () => {
|
||||
test('cline: global install writes gsd-prefixed skill dirs under skills/', (t) => {
|
||||
const configDir = createTempDir('gsd-ial-cline-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
assert.doesNotThrow(() => installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE));
|
||||
assert.ok(!fs.existsSync(path.join(configDir, 'skills')));
|
||||
assert.ok(!fs.existsSync(path.join(configDir, 'commands')));
|
||||
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
assert.ok(fs.existsSync(skillsDir), 'skills/ must be created for global cline install');
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(skillsDir, 'gsd-help', 'SKILL.md')),
|
||||
'gsd-help/SKILL.md must exist'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,6 +222,83 @@ describe('installRuntimeArtifacts — opencode / kilo flat commands', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─── #784: installOpencodeFamilySkills — skills + path rewrite + preservation ─
|
||||
|
||||
// Stage the raw command set the way the installer's _stageSkills() does, so the
|
||||
// skills writer receives the same input as the flattened-command writer.
|
||||
function stageRawCommands(runtime, configDir) {
|
||||
const layout = resolveRuntimeArtifactLayout(runtime, configDir, 'global');
|
||||
const commandsKind = layout.kinds.find((k) => k.kind === 'commands');
|
||||
return commandsKind.stage(RESOLVED_CORE);
|
||||
}
|
||||
|
||||
describe('installOpencodeFamilySkills — emits skills/<name>/SKILL.md (#784)', () => {
|
||||
for (const runtime of ['opencode', 'kilo']) {
|
||||
test(`${runtime}: writes gsd-help/SKILL.md with name + description`, (t) => {
|
||||
const configDir = createTempDir(`gsd-ocs-${runtime}-`);
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const raw = stageRawCommands(runtime, configDir);
|
||||
const count = installOpencodeFamilySkills(runtime, configDir, raw, `${configDir}/`);
|
||||
assert.ok(count >= 1, 'should report installed skills');
|
||||
|
||||
const skillMd = path.join(configDir, 'skills', 'gsd-help', 'SKILL.md');
|
||||
assert.ok(fs.existsSync(skillMd), 'gsd-help/SKILL.md must exist');
|
||||
const content = fs.readFileSync(skillMd, 'utf8');
|
||||
assert.match(content, /^name: gsd-help$/m, 'name matches dir');
|
||||
assert.match(content, /^description: /m, 'description present');
|
||||
assert.ok(!/\/gsd:/.test(content), 'no /gsd: colon refs in body');
|
||||
});
|
||||
|
||||
test(`${runtime}: rewrites body paths to the actual install target (#784 path fix)`, (t) => {
|
||||
const configDir = createTempDir(`gsd-ocp-${runtime}-`);
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
// Simulate a custom/local install: pathPrefix points at configDir, NOT the
|
||||
// runtime's default global config dir. Body refs must use pathPrefix.
|
||||
const pathPrefix = `${configDir}/`;
|
||||
installOpencodeFamilySkills(runtime, configDir, stageRawCommands(runtime, configDir), pathPrefix);
|
||||
|
||||
const defaultBase = runtime === 'kilo' ? '.config/kilo' : '.config/opencode';
|
||||
const help = fs.readFileSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md'), 'utf8');
|
||||
// gsd-help references gsd-core workflow files via @<configDir>/gsd-core/...
|
||||
assert.ok(
|
||||
help.includes(`${configDir}/gsd-core/`),
|
||||
'gsd-help body must reference the actual install target via pathPrefix',
|
||||
);
|
||||
for (const skillName of fs.readdirSync(path.join(configDir, 'skills'))) {
|
||||
const body = fs.readFileSync(path.join(configDir, 'skills', skillName, 'SKILL.md'), 'utf8');
|
||||
assert.ok(
|
||||
!body.includes(`~/${defaultBase}/`),
|
||||
`${skillName}: must not leak hardcoded ~/${defaultBase}/ — should use install target`,
|
||||
);
|
||||
// Regression guard for the prefix-overlap double-rewrite (e.g. kilo-alt-alt).
|
||||
assert.ok(
|
||||
!new RegExp(`${defaultBase.replace(/[\\.*+?^${}()|[\]]/g, '\\$&')}-[^/\\s]*-`).test(body),
|
||||
`${skillName}: must not contain a doubled config-dir suffix`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${runtime}: preserves user-owned gsd-dev-preferences across reinstall (#784)`, (t) => {
|
||||
const configDir = createTempDir(`gsd-ocd-${runtime}-`);
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const userSkill = path.join(configDir, 'skills', 'gsd-dev-preferences');
|
||||
fs.mkdirSync(userSkill, { recursive: true });
|
||||
const marker = '---\nname: gsd-dev-preferences\ndescription: mine\n---\nKEEP ME\n';
|
||||
fs.writeFileSync(path.join(userSkill, 'SKILL.md'), marker);
|
||||
|
||||
installOpencodeFamilySkills(runtime, configDir, stageRawCommands(runtime, configDir), `${configDir}/`);
|
||||
|
||||
const after = fs.readFileSync(path.join(userSkill, 'SKILL.md'), 'utf8');
|
||||
assert.ok(after.includes('KEEP ME'), 'user-owned dev-preferences must survive reinstall');
|
||||
// GSD-managed skills should also be present.
|
||||
assert.ok(fs.existsSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md')));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Section 7: uninstallRuntimeArtifacts — all runtimes ─────────────────────
|
||||
|
||||
describe('uninstallRuntimeArtifacts — removes gsd-owned entries, preserves foreign', () => {
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('getGlobalConfigDir — all runtimes default paths', () => {
|
||||
// Test the default (no env var, no explicit dir) for each runtime
|
||||
const ENV_KEYS = [
|
||||
'CLAUDE_CONFIG_DIR', 'CURSOR_CONFIG_DIR', 'GEMINI_CONFIG_DIR', 'CODEX_HOME',
|
||||
'GROK_AGENTS_HOME', 'COPILOT_CONFIG_DIR', 'WINDSURF_CONFIG_DIR', 'AUGMENT_CONFIG_DIR',
|
||||
'GROK_AGENTS_HOME', 'COPILOT_CONFIG_DIR', 'COPILOT_HOME', 'WINDSURF_CONFIG_DIR', 'AUGMENT_CONFIG_DIR',
|
||||
'TRAE_CONFIG_DIR', 'QWEN_CONFIG_DIR', 'HERMES_HOME', 'CODEBUDDY_CONFIG_DIR',
|
||||
'CLINE_CONFIG_DIR', 'OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG', 'KILO_CONFIG_DIR',
|
||||
'KILO_CONFIG', 'ANTIGRAVITY_CONFIG_DIR', 'XDG_CONFIG_HOME',
|
||||
|
||||
@@ -255,10 +255,11 @@ function assertFreshInstallContract(runtime, targetDir) {
|
||||
'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(
|
||||
fs.readFileSync(path.join(targetDir, '.clinerules'), 'utf8'),
|
||||
fs.readFileSync(path.join(targetDir, '.clinerules', 'gsd.md'), 'utf8'),
|
||||
/GSD workflows live in `gsd-core\/workflows\/`/,
|
||||
'Cline should install root .clinerules guidance'
|
||||
'Cline should install .clinerules/gsd.md guidance'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
313
tests/issue-787-cline-hooks-agents.test.cjs
Normal file
313
tests/issue-787-cline-hooks-agents.test.cjs
Normal file
@@ -0,0 +1,313 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// The Cline rules markdown, the PreToolUse hook script, and the AGENTS.md block
|
||||
// ARE the deployed contract that the Cline runtime loads/executes — testing their
|
||||
// text/behavior tests the shipped artifact. Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Issue #787 — elevate Cline: write hooks (.clinerules/hooks/) + AGENTS.md.
|
||||
*
|
||||
* Verifies the installer now emits the Cline directory-form rules, a
|
||||
* PreToolUse lifecycle hook (Cline JSON stdin → {cancel,errorMessage,
|
||||
* contextModification} protocol), and a global ~/.agents/AGENTS.md instruction
|
||||
* target. Self-contained: does NOT depend on the #782 Cline skills work.
|
||||
*
|
||||
* Primary sources adjudicated:
|
||||
* - https://cline.bot/blog/cline-v3-36-hooks
|
||||
* hooks live at .clinerules/hooks/<EventName> (project) and
|
||||
* ~/Documents/Cline/Rules/Hooks/ (global); executable scripts named
|
||||
* exactly after the event with no extension; JSON stdin → JSON stdout
|
||||
* with cancel / errorMessage / contextModification.
|
||||
* - https://docs.cline.bot/customization/cline-rules
|
||||
* Cline processes all .md/.txt files inside a .clinerules/ directory and
|
||||
* reads cross-tool global instructions from ~/.agents/AGENTS.md.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
|
||||
const {
|
||||
install,
|
||||
uninstall,
|
||||
buildClineRulesBody,
|
||||
buildClinePreToolUseHook,
|
||||
buildClineAgentsMdBody,
|
||||
mergeGsdAgentsMd,
|
||||
stripGsdFromAgentsMd,
|
||||
GSD_AGENTS_MD_MARKER,
|
||||
GSD_AGENTS_MD_CLOSE_MARKER,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('#787 Cline pure helpers', () => {
|
||||
test('buildClineRulesBody returns GSD directory-form rules markdown', () => {
|
||||
const body = buildClineRulesBody();
|
||||
assert.equal(typeof body, 'string');
|
||||
assert.match(body, /GSD workflows live in `gsd-core\/workflows\/`/);
|
||||
assert.ok(body.endsWith('\n'), 'rules body should end with a trailing newline');
|
||||
});
|
||||
|
||||
test('buildClinePreToolUseHook returns a syntactically valid Node script', () => {
|
||||
const script = buildClinePreToolUseHook();
|
||||
assert.match(script, /^#!\/usr\/bin\/env node/, 'must carry a node shebang');
|
||||
// Cline protocol fields must be present in the emitted decision surface.
|
||||
assert.match(script, /cancel/);
|
||||
assert.match(script, /errorMessage/);
|
||||
const tmp = createTempDir('gsd-787-hookcheck-');
|
||||
try {
|
||||
const p = path.join(tmp, 'PreToolUse');
|
||||
fs.writeFileSync(p, script);
|
||||
const res = spawnSync(process.execPath, ['--check', p], { encoding: 'utf8' });
|
||||
assert.equal(res.status, 0, `node --check failed: ${res.stderr}`);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('PreToolUse hook allows a normal tool call (cancel:false)', () => {
|
||||
const tmp = createTempDir('gsd-787-hookrun-');
|
||||
try {
|
||||
const p = path.join(tmp, 'PreToolUse');
|
||||
fs.writeFileSync(p, buildClinePreToolUseHook());
|
||||
const res = spawnSync(process.execPath, [p], {
|
||||
input: JSON.stringify({ toolName: 'read_file', toolInput: { path: 'src/index.ts' } }),
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(res.status, 0);
|
||||
const out = JSON.parse(res.stdout);
|
||||
assert.equal(out.cancel, false);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('PreToolUse hook cancels a write into .planning/ with an errorMessage', () => {
|
||||
const tmp = createTempDir('gsd-787-hookguard-');
|
||||
try {
|
||||
const p = path.join(tmp, 'PreToolUse');
|
||||
fs.writeFileSync(p, buildClinePreToolUseHook());
|
||||
const res = spawnSync(process.execPath, [p], {
|
||||
input: JSON.stringify({ toolName: 'write_to_file', toolInput: { path: '.planning/ROADMAP.md', content: 'x' } }),
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(res.status, 0);
|
||||
const out = JSON.parse(res.stdout);
|
||||
assert.equal(out.cancel, true);
|
||||
assert.match(out.errorMessage, /\.planning/);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('PreToolUse hook does NOT cancel a write to a non-planning path whose CONTENT mentions .planning/', () => {
|
||||
const tmp = createTempDir('gsd-787-hookfp-');
|
||||
try {
|
||||
const p = path.join(tmp, 'PreToolUse');
|
||||
fs.writeFileSync(p, buildClinePreToolUseHook());
|
||||
const res = spawnSync(process.execPath, [p], {
|
||||
input: JSON.stringify({
|
||||
toolName: 'write_to_file',
|
||||
toolInput: { path: 'docs/guide.md', content: 'Edit your .planning/ROADMAP.md via /gsd commands.' },
|
||||
}),
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(res.status, 0);
|
||||
assert.equal(JSON.parse(res.stdout).cancel, false, 'content mentioning .planning must not trigger a cancel');
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('PreToolUse hook fails open on malformed stdin', () => {
|
||||
const tmp = createTempDir('gsd-787-hookbad-');
|
||||
try {
|
||||
const p = path.join(tmp, 'PreToolUse');
|
||||
fs.writeFileSync(p, buildClinePreToolUseHook());
|
||||
const res = spawnSync(process.execPath, [p], { input: 'not json{', encoding: 'utf8' });
|
||||
assert.equal(res.status, 0);
|
||||
assert.equal(JSON.parse(res.stdout).cancel, false);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('mergeGsdAgentsMd creates a marker-delimited block when no file exists', () => {
|
||||
const tmp = createTempDir('gsd-787-agents-new-');
|
||||
try {
|
||||
const p = path.join(tmp, 'AGENTS.md');
|
||||
mergeGsdAgentsMd(p, buildClineAgentsMdBody());
|
||||
const content = fs.readFileSync(p, 'utf8');
|
||||
assert.ok(content.includes(GSD_AGENTS_MD_MARKER));
|
||||
assert.ok(content.includes(GSD_AGENTS_MD_CLOSE_MARKER));
|
||||
assert.match(content, /GSD/);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('mergeGsdAgentsMd preserves pre-existing user content', () => {
|
||||
const tmp = createTempDir('gsd-787-agents-merge-');
|
||||
try {
|
||||
const p = path.join(tmp, 'AGENTS.md');
|
||||
fs.writeFileSync(p, '# My rules\n\nKeep me.\n');
|
||||
mergeGsdAgentsMd(p, buildClineAgentsMdBody());
|
||||
const content = fs.readFileSync(p, 'utf8');
|
||||
assert.match(content, /Keep me\./);
|
||||
assert.ok(content.includes(GSD_AGENTS_MD_MARKER));
|
||||
// Idempotent: second merge does not duplicate the block.
|
||||
mergeGsdAgentsMd(p, buildClineAgentsMdBody());
|
||||
const twice = fs.readFileSync(p, 'utf8');
|
||||
const occurrences = twice.split(GSD_AGENTS_MD_MARKER).length - 1;
|
||||
assert.equal(occurrences, 1, 'GSD block must not duplicate on re-merge');
|
||||
assert.match(twice, /Keep me\./);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('stripGsdFromAgentsMd returns null when file was GSD-only, else cleaned content', () => {
|
||||
const onlyGsd = `${GSD_AGENTS_MD_MARKER}\nhi\n${GSD_AGENTS_MD_CLOSE_MARKER}\n`;
|
||||
assert.equal(stripGsdFromAgentsMd(onlyGsd), null);
|
||||
const mixed = `# Keep\n\n${GSD_AGENTS_MD_MARKER}\nhi\n${GSD_AGENTS_MD_CLOSE_MARKER}\n`;
|
||||
const cleaned = stripGsdFromAgentsMd(mixed);
|
||||
assert.match(cleaned, /# Keep/);
|
||||
assert.ok(!cleaned.includes(GSD_AGENTS_MD_MARKER));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Local install: directory form + hook ───────────────────────────────────────
|
||||
|
||||
describe('#787 Cline local install — directory form + PreToolUse hook', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-787-cline-local-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('writes .clinerules/ as a directory containing gsd.md', () => {
|
||||
install(false, 'cline');
|
||||
const dir = path.join(tmpDir, '.clinerules');
|
||||
assert.ok(fs.statSync(dir).isDirectory(), '.clinerules must be a directory');
|
||||
const ruleFile = path.join(dir, 'gsd.md');
|
||||
assert.ok(fs.existsSync(ruleFile), '.clinerules/gsd.md must exist');
|
||||
assert.match(fs.readFileSync(ruleFile, 'utf8'), /gsd-core\/workflows\//);
|
||||
});
|
||||
|
||||
test('writes an executable PreToolUse hook with no extension', () => {
|
||||
install(false, 'cline');
|
||||
const hook = path.join(tmpDir, '.clinerules', 'hooks', 'PreToolUse');
|
||||
assert.ok(fs.existsSync(hook), '.clinerules/hooks/PreToolUse must exist');
|
||||
if (process.platform !== 'win32') {
|
||||
const mode = fs.statSync(hook).mode;
|
||||
assert.ok((mode & 0o111) !== 0, 'PreToolUse must be executable');
|
||||
}
|
||||
});
|
||||
|
||||
test('migrates a legacy single-file .clinerules into the directory form', () => {
|
||||
// Simulate a pre-#787 install that wrote a .clinerules FILE.
|
||||
fs.writeFileSync(path.join(tmpDir, '.clinerules'), '# legacy file\n');
|
||||
install(false, 'cline');
|
||||
const dir = path.join(tmpDir, '.clinerules');
|
||||
assert.ok(fs.statSync(dir).isDirectory(), 'legacy file must be replaced by a directory');
|
||||
assert.ok(fs.existsSync(path.join(dir, 'gsd.md')));
|
||||
});
|
||||
|
||||
test('does not follow a symlinked .clinerules (writes the real directory in place)', () => {
|
||||
if (process.platform === 'win32') return; // symlink perms differ on Windows
|
||||
// Point .clinerules at an external directory via symlink; install must NOT
|
||||
// write GSD files through the link.
|
||||
const external = path.join(tmpDir, 'external-target');
|
||||
fs.mkdirSync(external);
|
||||
fs.symlinkSync(external, path.join(tmpDir, '.clinerules'));
|
||||
install(false, 'cline');
|
||||
const dir = path.join(tmpDir, '.clinerules');
|
||||
assert.ok(fs.lstatSync(dir).isDirectory() && !fs.lstatSync(dir).isSymbolicLink(),
|
||||
'.clinerules must be a real directory, not the symlink');
|
||||
assert.ok(!fs.existsSync(path.join(external, 'gsd.md')), 'must not write through the symlink target');
|
||||
assert.ok(fs.existsSync(path.join(dir, 'gsd.md')));
|
||||
});
|
||||
|
||||
test('manifest tracks the new directory-form artifacts', () => {
|
||||
install(false, 'cline');
|
||||
const manifestPath = path.join(tmpDir, 'gsd-file-manifest.json');
|
||||
assert.ok(fs.existsSync(manifestPath));
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
assert.ok(manifest.files['.clinerules/gsd.md'], 'manifest should track .clinerules/gsd.md');
|
||||
assert.ok(manifest.files['.clinerules/hooks/PreToolUse'], 'manifest should track the hook');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Global install: ~/.agents/AGENTS.md (subprocess, HOME-isolated) ─────────────
|
||||
|
||||
describe('#787 Cline global install — ~/.agents/AGENTS.md', () => {
|
||||
function runGlobalClineInstall() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-787-cline-global-'));
|
||||
const env = { ...process.env, HOME: root, USERPROFILE: root };
|
||||
delete env.GSD_TEST_MODE;
|
||||
const res = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL_SCRIPT, '--cline', '--global', '--config-dir', path.join(root, '.cline')],
|
||||
{ cwd: root, encoding: 'utf8', env },
|
||||
);
|
||||
return { root, res };
|
||||
}
|
||||
|
||||
test('writes ~/.agents/AGENTS.md with a GSD marker block', () => {
|
||||
const { root, res } = runGlobalClineInstall();
|
||||
try {
|
||||
assert.equal(res.status, 0, `installer failed: ${res.stderr}`);
|
||||
const agents = path.join(root, '.agents', 'AGENTS.md');
|
||||
assert.ok(fs.existsSync(agents), '~/.agents/AGENTS.md must exist after a global Cline install');
|
||||
const content = fs.readFileSync(agents, 'utf8');
|
||||
assert.ok(content.includes(GSD_AGENTS_MD_MARKER));
|
||||
assert.match(content, /GSD/);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Uninstall symmetry ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('#787 Cline uninstall removes managed artifacts', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-787-cline-uninstall-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('local uninstall removes .clinerules/gsd.md and the hook', () => {
|
||||
install(false, 'cline');
|
||||
assert.ok(fs.existsSync(path.join(tmpDir, '.clinerules', 'gsd.md')));
|
||||
uninstall(false, 'cline');
|
||||
assert.ok(!fs.existsSync(path.join(tmpDir, '.clinerules', 'gsd.md')), 'gsd.md should be removed');
|
||||
assert.ok(!fs.existsSync(path.join(tmpDir, '.clinerules', 'hooks', 'PreToolUse')), 'hook should be removed');
|
||||
});
|
||||
});
|
||||
@@ -269,7 +269,8 @@ describe('generate-dev-preferences command', () => {
|
||||
assert.strictEqual(out.command_path, path.join(codexHome, 'skills', 'gsd-dev-preferences', 'SKILL.md'));
|
||||
});
|
||||
|
||||
test('errors for cline unless --output is supplied', () => {
|
||||
test('uses runtime-aware skills dir for cline by default (#782)', () => {
|
||||
// Cline >= v3.48.0 is skills-capable: ~/.cline/skills/<name>/SKILL.md
|
||||
const analysis = {
|
||||
profile_version: '1.0',
|
||||
dimensions: {
|
||||
@@ -277,14 +278,16 @@ describe('generate-dev-preferences command', () => {
|
||||
},
|
||||
};
|
||||
const analysisPath = path.join(tmpDir, 'analysis.json');
|
||||
const clineHome = path.join(tmpDir, 'cline-home');
|
||||
fs.writeFileSync(analysisPath, JSON.stringify(analysis));
|
||||
|
||||
const result = runGsdTools(
|
||||
['generate-dev-preferences', '--analysis', analysisPath, '--raw'],
|
||||
tmpDir,
|
||||
{ GSD_RUNTIME: 'cline' }
|
||||
{ CLINE_CONFIG_DIR: clineHome, GSD_RUNTIME: 'cline' }
|
||||
);
|
||||
assert.ok(!result.success, 'cline should require explicit --output');
|
||||
assert.ok(result.error.includes('does not use a skills directory'), 'should explain unsupported runtime');
|
||||
assert.ok(result.success, `cline skills output should succeed: ${result.error}`);
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.command_path, path.join(clineHome, 'skills', 'gsd-dev-preferences', 'SKILL.md'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,15 +60,23 @@ describe('resolveRuntimeArtifactLayout — claude global', () => {
|
||||
});
|
||||
|
||||
describe('resolveRuntimeArtifactLayout — cursor', () => {
|
||||
test('returns correct layout for cursor', () => {
|
||||
test('returns correct layout for cursor — skills + commands kinds (#785)', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('cursor', FAKE_DIR);
|
||||
assert.strictEqual(layout.runtime, 'cursor');
|
||||
assert.strictEqual(layout.configDir, FAKE_DIR);
|
||||
assert.strictEqual(layout.kinds.length, 1);
|
||||
assert.strictEqual(layout.kinds[0].kind, 'skills');
|
||||
assert.strictEqual(layout.kinds[0].destSubpath, 'skills');
|
||||
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
|
||||
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
||||
assert.strictEqual(layout.kinds.length, 2);
|
||||
|
||||
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
|
||||
assert.ok(skillsKind, 'must have a skills kind');
|
||||
assert.strictEqual(skillsKind.destSubpath, 'skills');
|
||||
assert.strictEqual(skillsKind.prefix, 'gsd-');
|
||||
assert.strictEqual(typeof skillsKind.stage, 'function');
|
||||
|
||||
const commandsKind = layout.kinds.find(k => k.kind === 'commands');
|
||||
assert.ok(commandsKind, 'must have a commands kind (#785 Cursor 1.6 slash commands)');
|
||||
assert.strictEqual(commandsKind.destSubpath, 'commands');
|
||||
assert.strictEqual(commandsKind.prefix, 'gsd-');
|
||||
assert.strictEqual(typeof commandsKind.stage, 'function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,15 +146,21 @@ describe('resolveRuntimeArtifactLayout — windsurf', () => {
|
||||
});
|
||||
|
||||
describe('resolveRuntimeArtifactLayout — augment', () => {
|
||||
test('returns correct layout for augment', () => {
|
||||
test('returns correct layout for augment (commands + skills)', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('augment', FAKE_DIR);
|
||||
assert.strictEqual(layout.runtime, 'augment');
|
||||
assert.strictEqual(layout.configDir, FAKE_DIR);
|
||||
assert.strictEqual(layout.kinds.length, 1);
|
||||
assert.strictEqual(layout.kinds[0].kind, 'skills');
|
||||
assert.strictEqual(layout.kinds[0].destSubpath, 'skills');
|
||||
assert.strictEqual(layout.kinds.length, 2);
|
||||
// commands kind first
|
||||
assert.strictEqual(layout.kinds[0].kind, 'commands');
|
||||
assert.strictEqual(layout.kinds[0].destSubpath, 'commands');
|
||||
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
|
||||
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
||||
// skills kind second
|
||||
assert.strictEqual(layout.kinds[1].kind, 'skills');
|
||||
assert.strictEqual(layout.kinds[1].destSubpath, 'skills');
|
||||
assert.strictEqual(layout.kinds[1].prefix, 'gsd-');
|
||||
assert.strictEqual(typeof layout.kinds[1].stage, 'function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -225,8 +239,19 @@ describe('resolveRuntimeArtifactLayout — codebuddy', () => {
|
||||
});
|
||||
|
||||
describe('resolveRuntimeArtifactLayout — cline', () => {
|
||||
test('returns correct layout for cline', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('cline', FAKE_DIR);
|
||||
test('returns correct layout for cline global (skills-capable since v3.48.0 — #782)', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('cline', FAKE_DIR, 'global');
|
||||
assert.strictEqual(layout.runtime, 'cline');
|
||||
assert.strictEqual(layout.configDir, FAKE_DIR);
|
||||
assert.strictEqual(layout.kinds.length, 1);
|
||||
assert.strictEqual(layout.kinds[0].kind, 'skills');
|
||||
assert.strictEqual(layout.kinds[0].destSubpath, 'skills');
|
||||
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
|
||||
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
||||
});
|
||||
|
||||
test('cline local: no skills kinds (global-only, #782)', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('cline', FAKE_DIR, 'local');
|
||||
assert.strictEqual(layout.runtime, 'cline');
|
||||
assert.strictEqual(layout.configDir, FAKE_DIR);
|
||||
assert.strictEqual(layout.kinds.length, 0);
|
||||
@@ -234,28 +259,44 @@ describe('resolveRuntimeArtifactLayout — cline', () => {
|
||||
});
|
||||
|
||||
describe('resolveRuntimeArtifactLayout — opencode', () => {
|
||||
test('returns correct layout for opencode', () => {
|
||||
test('returns commands + skills layout for opencode (#784)', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('opencode', FAKE_DIR);
|
||||
assert.strictEqual(layout.runtime, 'opencode');
|
||||
assert.strictEqual(layout.configDir, FAKE_DIR);
|
||||
assert.strictEqual(layout.kinds.length, 1);
|
||||
assert.strictEqual(layout.kinds[0].kind, 'commands');
|
||||
assert.strictEqual(layout.kinds[0].destSubpath, 'command');
|
||||
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
|
||||
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
||||
assert.strictEqual(layout.kinds.length, 2);
|
||||
|
||||
const commands = layout.kinds.find((k) => k.kind === 'commands');
|
||||
assert.ok(commands, 'should have a commands kind');
|
||||
assert.strictEqual(commands.destSubpath, 'command');
|
||||
assert.strictEqual(commands.prefix, 'gsd-');
|
||||
assert.strictEqual(typeof commands.stage, 'function');
|
||||
|
||||
const skills = layout.kinds.find((k) => k.kind === 'skills');
|
||||
assert.ok(skills, 'should have a skills kind');
|
||||
assert.strictEqual(skills.destSubpath, 'skills');
|
||||
assert.strictEqual(skills.prefix, 'gsd-');
|
||||
assert.strictEqual(typeof skills.stage, 'function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRuntimeArtifactLayout — kilo', () => {
|
||||
test('returns correct layout for kilo', () => {
|
||||
test('returns commands + skills layout for kilo (#784)', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('kilo', FAKE_DIR);
|
||||
assert.strictEqual(layout.runtime, 'kilo');
|
||||
assert.strictEqual(layout.configDir, FAKE_DIR);
|
||||
assert.strictEqual(layout.kinds.length, 1);
|
||||
assert.strictEqual(layout.kinds[0].kind, 'commands');
|
||||
assert.strictEqual(layout.kinds[0].destSubpath, 'command');
|
||||
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
|
||||
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
||||
assert.strictEqual(layout.kinds.length, 2);
|
||||
|
||||
const commands = layout.kinds.find((k) => k.kind === 'commands');
|
||||
assert.ok(commands, 'should have a commands kind');
|
||||
assert.strictEqual(commands.destSubpath, 'command');
|
||||
assert.strictEqual(commands.prefix, 'gsd-');
|
||||
assert.strictEqual(typeof commands.stage, 'function');
|
||||
|
||||
const skills = layout.kinds.find((k) => k.kind === 'skills');
|
||||
assert.ok(skills, 'should have a skills kind');
|
||||
assert.strictEqual(skills.destSubpath, 'skills');
|
||||
assert.strictEqual(skills.prefix, 'gsd-');
|
||||
assert.strictEqual(typeof skills.stage, 'function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -268,9 +309,10 @@ describe('resolveRuntimeArtifactLayout edge-cases', () => {
|
||||
assert.strictEqual(layout.kinds[0].prefix, '');
|
||||
});
|
||||
|
||||
test('cline has no kinds', () => {
|
||||
test('cline has one skills kind (#782)', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x');
|
||||
assert.strictEqual(layout.kinds.length, 0);
|
||||
assert.strictEqual(layout.kinds.length, 1);
|
||||
assert.strictEqual(layout.kinds[0].kind, 'skills');
|
||||
});
|
||||
|
||||
test('gemini has one commands kind', () => {
|
||||
@@ -286,6 +328,13 @@ describe('resolveRuntimeArtifactLayout edge-cases', () => {
|
||||
assert.ok(kindNames.includes('agents'), 'should have agents kind');
|
||||
});
|
||||
|
||||
test('cursor has both skills and commands kinds (#785)', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('cursor', '/tmp/x');
|
||||
const kindNames = layout.kinds.map(k => k.kind);
|
||||
assert.ok(kindNames.includes('skills'), 'cursor must have skills kind');
|
||||
assert.ok(kindNames.includes('commands'), 'cursor must have commands kind (#785 Cursor 1.6)');
|
||||
});
|
||||
|
||||
test('claude global has only skills kind', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('claude', '/tmp/x', 'global');
|
||||
assert.strictEqual(layout.kinds.length, 1);
|
||||
@@ -498,3 +547,66 @@ describe('stage — opencode commands kind', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('stage — opencode/kilo skills kind (#784)', () => {
|
||||
for (const runtime of ['opencode', 'kilo']) {
|
||||
test(`${runtime} skills stage writes gsd-<stem>/SKILL.md with name + description`, () => {
|
||||
const layout = resolveRuntimeArtifactLayout(runtime, FAKE_STAGE_DIR);
|
||||
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 entries = fs.readdirSync(stagedDir);
|
||||
assert.ok(entries.length >= 1, 'at least one skill dir should be staged');
|
||||
for (const entry of entries) {
|
||||
assert.ok(entry.startsWith('gsd-'), `entry should start with gsd-: ${entry}`);
|
||||
const skillMd = path.join(stagedDir, entry, 'SKILL.md');
|
||||
assert.ok(fs.existsSync(skillMd), `SKILL.md must exist in ${entry}`);
|
||||
const content = fs.readFileSync(skillMd, 'utf8');
|
||||
// OpenCode skill spec: name must match the dir, description required.
|
||||
assert.ok(content.startsWith('---\n'), 'SKILL.md must open with frontmatter');
|
||||
assert.match(content, new RegExp(`^name: ${entry}$`, 'm'), `name must equal dir ${entry}`);
|
||||
assert.match(content, /^description: /m, 'description frontmatter required');
|
||||
// No colon-namespace command leaks in the converted body.
|
||||
assert.ok(!/\/gsd:/.test(content), 'body must not contain /gsd: colon refs');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('stage — cursor commands kind (#785)', () => {
|
||||
test('cursor commands kind stage returns directory with converted .md files', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('cursor', FAKE_STAGE_DIR);
|
||||
const commandsKind = layout.kinds.find(k => k.kind === 'commands');
|
||||
assert.ok(commandsKind, 'cursor should have a commands kind (#785)');
|
||||
|
||||
const stagedDir = commandsKind.stage(PROFILE_CORE);
|
||||
assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist');
|
||||
|
||||
const entries = fs.readdirSync(stagedDir).filter(f => f.endsWith('.md'));
|
||||
assert.ok(entries.length >= 1, 'at least one command file should be staged');
|
||||
|
||||
// Cursor commands are plain markdown — no YAML frontmatter
|
||||
for (const entry of entries) {
|
||||
const content = fs.readFileSync(path.join(stagedDir, entry), 'utf8');
|
||||
assert.ok(!content.startsWith('---'), `${entry}: cursor commands must not start with YAML frontmatter`);
|
||||
}
|
||||
});
|
||||
|
||||
test('cursor commands stage applies Cursor-specific content transforms', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('cursor', FAKE_STAGE_DIR);
|
||||
const commandsKind = layout.kinds.find(k => k.kind === 'commands');
|
||||
assert.ok(commandsKind, 'cursor should have a commands kind (#785)');
|
||||
|
||||
const stagedDir = commandsKind.stage(PROFILE_FULL);
|
||||
assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist');
|
||||
|
||||
// Verify all staged files are .md only (no subdirectory SKILL.md layout)
|
||||
const entries = fs.readdirSync(stagedDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
assert.ok(entry.isFile(), `${entry.name}: cursor commands dir must contain only flat files`);
|
||||
assert.ok(entry.name.endsWith('.md'), `${entry.name}: must be .md file`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,8 @@ const {
|
||||
convertClaudeToOpencodeFrontmatter,
|
||||
convertClaudeToKiloFrontmatter,
|
||||
convertClaudeToGeminiAgent,
|
||||
convertClaudeCommandToOpencodeSkill,
|
||||
convertClaudeCommandToKiloSkill,
|
||||
neutralizeAgentReferences,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
@@ -344,3 +346,70 @@ describe('neutralizeAgentReferences', () => {
|
||||
assert.ok(result.includes('claude-code'), 'claude-code preserved');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── OpenCode-family skill converters (SKILL.md) — #784 ──────────────────────
|
||||
|
||||
const SKILL_SAMPLE_COMMAND = `---
|
||||
description: Show available GSD commands and usage guide
|
||||
argument-hint: "[topic]"
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Bash
|
||||
---
|
||||
|
||||
Run \`/gsd:help\` to see the guide. AskUserQuestion when unsure.
|
||||
`;
|
||||
|
||||
const SKILL_BETA_COMMAND = `---
|
||||
description: "[BETA] Offload plan phase to the cloud and import back."
|
||||
---
|
||||
|
||||
Body for /gsd:ultraplan-phase.
|
||||
`;
|
||||
|
||||
describe('convertClaudeCommandToOpencodeSkill / convertClaudeCommandToKiloSkill (#784)', () => {
|
||||
const cases = [
|
||||
{ label: 'opencode', convert: convertClaudeCommandToOpencodeSkill },
|
||||
{ label: 'kilo', convert: convertClaudeCommandToKiloSkill },
|
||||
];
|
||||
|
||||
for (const { label, convert } of cases) {
|
||||
describe(`${label} skill conversion`, () => {
|
||||
test('emits SKILL.md frontmatter with name matching the skill dir', () => {
|
||||
const out = convert(SKILL_SAMPLE_COMMAND, 'gsd-help');
|
||||
assert.ok(out.startsWith('---\n'), 'opens with frontmatter');
|
||||
assert.match(out, /^name: gsd-help$/m, 'name equals the skill name');
|
||||
});
|
||||
|
||||
test('preserves the description from the source command', () => {
|
||||
const out = convert(SKILL_SAMPLE_COMMAND, 'gsd-help');
|
||||
assert.match(out, /^description: "Show available GSD commands and usage guide"$/m);
|
||||
});
|
||||
|
||||
test('drops the command tools/permission block (skills inherit perms)', () => {
|
||||
const out = convert(SKILL_SAMPLE_COMMAND, 'gsd-help');
|
||||
const fmEnd = out.indexOf('\n---', 4);
|
||||
const fm = out.slice(0, fmEnd);
|
||||
assert.ok(!/tools:/.test(fm), 'no tools block in skill frontmatter');
|
||||
assert.ok(!/permission:/.test(fm), 'no permission block in skill frontmatter');
|
||||
});
|
||||
|
||||
test('rewrites /gsd: colon refs to hyphen form in the body', () => {
|
||||
const out = convert(SKILL_SAMPLE_COMMAND, 'gsd-help');
|
||||
assert.ok(!/\/gsd:/.test(out), 'no /gsd: colon refs remain');
|
||||
assert.match(out, /\/gsd-help/, 'colon ref rewritten to hyphen form');
|
||||
});
|
||||
|
||||
test('quotes descriptions with leading YAML flow indicators ([BETA])', () => {
|
||||
const out = convert(SKILL_BETA_COMMAND, 'gsd-ultraplan-phase');
|
||||
assert.match(out, /^description: "\[BETA\] /m, 'leading [BETA] safely quoted');
|
||||
});
|
||||
|
||||
test('falls back to a synthetic description when none present', () => {
|
||||
const out = convert('Body only, no frontmatter.', 'gsd-mystery');
|
||||
assert.match(out, /^name: gsd-mystery$/m);
|
||||
assert.match(out, /^description: "Run GSD workflow gsd-mystery\."$/m);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user