enhancement(#782): emit gsd skills to ~/.cline/skills for Cline >= v3.48 (#809)

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:
Tom Boucher
2026-06-07 15:48:40 -04:00
committed by GitHub
parent 5618505f9a
commit 5e4e7de1ff
10 changed files with 787 additions and 36 deletions

View 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

View File

@@ -205,13 +205,13 @@ WINDSURF_CONFIG_DIR=~/.codeium/windsurf-alt npx @opengsd/gsd-core@latest --winds
### 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
# 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
```
@@ -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;
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
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.
**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

View File

@@ -343,7 +343,7 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope:
break;
case 'cline':
kinds = [];
kinds = scope === 'global' ? [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClineSkill', 'cline', configDir)] : [];
break;
case 'opencode':

View File

@@ -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).
*/
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.
* 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');
@@ -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.
* 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);

View File

@@ -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'),
);
});
});
});

View 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}`
);
});
});

View File

@@ -156,14 +156,19 @@ describe('installRuntimeArtifacts — cursor commands layout (#785)', () => {
});
});
describe('installRuntimeArtifacts — cline no-op', () => {
test('cline: no kinds — call succeeds, no dirs created', (t) => {
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'
);
});
});

View File

@@ -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'));
});
});

View File

@@ -210,8 +210,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);
@@ -253,9 +264,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', () => {