Files
msd-core/tests/bug-782-cline-skills-emission.test.cjs
Behruz Nassre Esfahani 2b6107d46f refactor(#1173): defer agents-kind descriptor declarations (option a)
Adopt maintainer-recommended option (a): keep the convertedAgentsKind /
stageAgentsForRuntimeWithConverter scope-threading plumbing, but DEFER the
8 runtimes' capability.json `agents`-kind declarations to a follow-up that
first ships the ADR-1235 §0 byte-for-byte parity harness.

The declarations were a live regression: the second `layout.kinds` consumer,
applySurface / `/gsd:surface` / `--materialize` (src/surface.cts), does not
mirror the legacy agent pipeline (copilot `.agent.md` rename, path-prefix
rewrite + attribution, stale cleanup), so a `/gsd:surface` toggle deleted
installed copilot `gsd-*.agent.md` and path-unrewrote the other 7 runtimes.
trek-e + davesienkowski both flagged this.

- Revert the agents-kind entries from the 8 capability.json files and
  regenerate capability-registry.cjs (now matches next; 0 converted agents
  kinds declared).
- Revert the declaration-driven kind-count test bumps
  (runtime-artifact-layout, descriptor-drive, bug-782, enh-789, enh-790).
- Keep the synthetic-descriptor seam tests for convertedAgentsKind dispatch;
  add a synthetic scope-threading test so the kept isGlobal plumbing stays
  covered without depending on real declarations.
- Update the convertedAgentsKind doc comment to state declarations are
  deferred pending the ADR-1235 §0 parity harness.
- Move ADR-1235 to Accepted.
- Reword the changeset to plumbing-only (docs-exempt now honest: no runtime
  declares the kind, legacy loop authoritative, installed output unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:29:50 -07:00

652 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 { nestedSkillPath } = require('./helpers/nested-layout.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`
);
// full profile: gsd-help is nested under gsd-ns-manage/skills/help/SKILL.md
const helpSkillFile = nestedSkillPath(skillsDir, 'gsd-', 'help');
assert.ok(
fs.existsSync(helpSkillFile),
`${path.relative(tmpGlobalDir, helpSkillFile)} 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);
// full profile: surface is nested under gsd-ns-manage/skills/surface/SKILL.md
const surfaceSkill = nestedSkillPath(path.join(configDir, 'skills'), 'gsd-', 'surface');
assert.ok(fs.existsSync(surfaceSkill), `${path.relative(configDir, surfaceSkill)} 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.
// full profile: surface is nested under gsd-ns-manage/skills/surface/SKILL.md
const surfaceSkill = nestedSkillPath(path.join(configDir, 'skills'), 'gsd-', 'surface');
assert.ok(fs.existsSync(surfaceSkill), `${path.relative(configDir, surfaceSkill)} 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}`
);
});
});