Cline added a global skills system (~/.cline/skills/<name>/SKILL.md) in
v3.48.0, but gsd treated Cline as rules-only and emitted zero skills
(getGlobalSkillsBase('cline')=null, empty artifact kinds). This makes gsd
emit skills for Cline at global scope, alongside the existing .clinerules.
- runtime-homes: getGlobalSkillsBase('cline') -> ~/.cline/skills (was null)
- runtime-artifact-layout: cline emits a skills kind for GLOBAL scope only
(local stays .clinerules-only), mirroring claude's scope dispatch
- install.js: convertClaudeCommandToClineSkill emits name+description-only
SKILL.md frontmatter (Cline/agentskills.io spec; no Claude-specific
allowed-tools/argument-hint/agent), hyphen-normalized + .cline/-rewritten
body; global cline routed through the skills path while .clinerules is
still written; _applyRuntimeRewrites cline case handles custom
CLINE_CONFIG_DIR; convertClaudeToCliineMarkdown also rewrites bare
~/.claude and CLAUDE_CONFIG_DIR
- docs: install-on-your-runtime.md documents Cline global skills vs local rules
- tests: converter (name+description-only), global emission, skills+.clinerules
coexistence, scope-aware layout, custom-dir paths, idempotency
Closes #782
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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.
|
||||||
File diff suppressed because one or more lines are too long
@@ -205,13 +205,13 @@ WINDSURF_CONFIG_DIR=~/.codeium/windsurf-alt npx @opengsd/gsd-core@latest --winds
|
|||||||
|
|
||||||
### Cline
|
### Cline
|
||||||
|
|
||||||
Cline uses a rules-based integration — GSD installs as Cline rules rather than slash commands.
|
GSD gives Cline both skills (≥ v3.48.0) and the `.clinerules/` directory integration — no custom slash commands are registered.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Global install (all projects)
|
# Global install (all projects — skills + rules directory)
|
||||||
npx @opengsd/gsd-core@latest --cline --global
|
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
|
npx @opengsd/gsd-core@latest --cline --local
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -225,10 +225,16 @@ GSD writes the [`.clinerules/` directory form](https://docs.cline.bot/customizat
|
|||||||
GSD hook guards `.planning/` artifacts from direct edits and otherwise allows the operation;
|
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.
|
it fails open, so a hook error never blocks you. Cline runs hooks on macOS and Linux only.
|
||||||
|
|
||||||
Global installs additionally merge GSD instructions into **`~/.agents/AGENTS.md`**, the
|
**Global install additionally:**
|
||||||
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
|
- Emits each GSD command as **`~/.cline/skills/<name>/SKILL.md`**. Cline ≥ v3.48.0 loads
|
||||||
GSD block.
|
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
|
> 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
|
> installer — project-scope hooks (`.clinerules/hooks/`) and the global `AGENTS.md` instruction
|
||||||
|
|||||||
@@ -343,7 +343,7 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope:
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'cline':
|
case 'cline':
|
||||||
kinds = [];
|
kinds = scope === 'global' ? [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClineSkill', 'cline', configDir)] : [];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'opencode':
|
case 'opencode':
|
||||||
|
|||||||
@@ -11,9 +11,9 @@
|
|||||||
* Runtime-specific notes:
|
* Runtime-specific notes:
|
||||||
* hermes — GSD skills nest under skills/gsd/<skillName>/ (not the flat
|
* hermes — GSD skills nest under skills/gsd/<skillName>/ (not the flat
|
||||||
* skills/<skillName>/ layout used by all other runtimes).
|
* skills/<skillName>/ layout used by all other runtimes).
|
||||||
* cline — Rules-based; commands are embedded in .clinerules. Cline does
|
* cline — Skills-capable since v3.48.0 (#782). SKILL.md files live at
|
||||||
* not use a skills/ directory. getGlobalSkillDir() returns null
|
* ~/.cline/skills/<skillName>/SKILL.md (same flat layout as cursor/codex).
|
||||||
* for cline so the caller can emit an appropriate warning.
|
* .clinerules is also emitted (rules-based compatibility layer).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
@@ -160,10 +160,9 @@ export function getGlobalConfigDir(runtime: string, explicitDir?: string | null)
|
|||||||
* Return the global skills base directory for the given runtime.
|
* Return the global skills base directory for the given runtime.
|
||||||
* Most runtimes: <configDir>/skills
|
* Most runtimes: <configDir>/skills
|
||||||
* Hermes: <configDir>/skills/gsd (nested category layout — #2841)
|
* 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 {
|
export function getGlobalSkillsBase(runtime: string): string | null {
|
||||||
if (runtime === 'cline') return null;
|
|
||||||
if (runtime === 'hermes') {
|
if (runtime === 'hermes') {
|
||||||
const configDir = getGlobalConfigDir(runtime);
|
const configDir = getGlobalConfigDir(runtime);
|
||||||
return path.join(configDir, 'skills', 'gsd');
|
return path.join(configDir, 'skills', 'gsd');
|
||||||
@@ -180,7 +179,6 @@ export function getGlobalSkillsBase(runtime: string): string | null {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the full path to a specific skill's directory for the given runtime.
|
* 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 {
|
export function getGlobalSkillDir(runtime: string, skillName: string): string | null {
|
||||||
const base = getGlobalSkillsBase(runtime);
|
const base = getGlobalSkillsBase(runtime);
|
||||||
|
|||||||
@@ -164,8 +164,13 @@ describe('bug #3126: runtime-homes getGlobalSkillsBase', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
test('cline: returns null (rules-based, no skills directory)', () => {
|
test('cline: returns ~/.cline/skills (skills-capable since v3.48.0 — #782)', () => {
|
||||||
assert.strictEqual(getGlobalSkillsBase('cline'), null);
|
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', () => {
|
test('cline: returns ~/.cline/skills/gsd-executor (skills-capable since v3.48.0 — #782)', () => {
|
||||||
assert.strictEqual(getGlobalSkillDir('cline', 'gsd-executor'), null);
|
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}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -156,14 +156,19 @@ describe('installRuntimeArtifacts — cursor commands layout (#785)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('installRuntimeArtifacts — cline no-op', () => {
|
describe('installRuntimeArtifacts — cline skills (#782)', () => {
|
||||||
test('cline: no kinds — call succeeds, no dirs created', (t) => {
|
test('cline: global install writes gsd-prefixed skill dirs under skills/', (t) => {
|
||||||
const configDir = createTempDir('gsd-ial-cline-');
|
const configDir = createTempDir('gsd-ial-cline-');
|
||||||
t.after(() => cleanup(configDir));
|
t.after(() => cleanup(configDir));
|
||||||
|
|
||||||
assert.doesNotThrow(() => installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE));
|
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'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -269,7 +269,8 @@ describe('generate-dev-preferences command', () => {
|
|||||||
assert.strictEqual(out.command_path, path.join(codexHome, 'skills', 'gsd-dev-preferences', 'SKILL.md'));
|
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 = {
|
const analysis = {
|
||||||
profile_version: '1.0',
|
profile_version: '1.0',
|
||||||
dimensions: {
|
dimensions: {
|
||||||
@@ -277,14 +278,16 @@ describe('generate-dev-preferences command', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const analysisPath = path.join(tmpDir, 'analysis.json');
|
const analysisPath = path.join(tmpDir, 'analysis.json');
|
||||||
|
const clineHome = path.join(tmpDir, 'cline-home');
|
||||||
fs.writeFileSync(analysisPath, JSON.stringify(analysis));
|
fs.writeFileSync(analysisPath, JSON.stringify(analysis));
|
||||||
|
|
||||||
const result = runGsdTools(
|
const result = runGsdTools(
|
||||||
['generate-dev-preferences', '--analysis', analysisPath, '--raw'],
|
['generate-dev-preferences', '--analysis', analysisPath, '--raw'],
|
||||||
tmpDir,
|
tmpDir,
|
||||||
{ GSD_RUNTIME: 'cline' }
|
{ CLINE_CONFIG_DIR: clineHome, GSD_RUNTIME: 'cline' }
|
||||||
);
|
);
|
||||||
assert.ok(!result.success, 'cline should require explicit --output');
|
assert.ok(result.success, `cline skills output should succeed: ${result.error}`);
|
||||||
assert.ok(result.error.includes('does not use a skills directory'), 'should explain unsupported runtime');
|
const out = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(out.command_path, path.join(clineHome, 'skills', 'gsd-dev-preferences', 'SKILL.md'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -210,8 +210,19 @@ describe('resolveRuntimeArtifactLayout — codebuddy', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('resolveRuntimeArtifactLayout — cline', () => {
|
describe('resolveRuntimeArtifactLayout — cline', () => {
|
||||||
test('returns correct layout for cline', () => {
|
test('returns correct layout for cline global (skills-capable since v3.48.0 — #782)', () => {
|
||||||
const layout = resolveRuntimeArtifactLayout('cline', FAKE_DIR);
|
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.runtime, 'cline');
|
||||||
assert.strictEqual(layout.configDir, FAKE_DIR);
|
assert.strictEqual(layout.configDir, FAKE_DIR);
|
||||||
assert.strictEqual(layout.kinds.length, 0);
|
assert.strictEqual(layout.kinds.length, 0);
|
||||||
@@ -253,9 +264,10 @@ describe('resolveRuntimeArtifactLayout edge-cases', () => {
|
|||||||
assert.strictEqual(layout.kinds[0].prefix, '');
|
assert.strictEqual(layout.kinds[0].prefix, '');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cline has no kinds', () => {
|
test('cline has one skills kind (#782)', () => {
|
||||||
const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x');
|
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', () => {
|
test('gemini has one commands kind', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user