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>
652 lines
29 KiB
JavaScript
652 lines
29 KiB
JavaScript
'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}`
|
||
);
|
||
});
|
||
});
|