* Deepen SDK package seam and converge runtime skills policy * fix(sdk): unified install-root resolution for workflows and agents (CR finding 1) Use the already-resolved gsdInstallDir constant instead of calling resolveLegacyInstallDir() again when computing agentsDir, ensuring workflowsDir and agentsDir share the same install root. * fix(sdk): tilde shortening requires path-boundary match (CR finding 2) Both renderGlobalSkillsBaseDisplayPath and renderGlobalSkillDisplayPath used startsWith(home) which could incorrectly shorten unrelated paths sharing the same prefix. Now checks for home === base or base.startsWith(home + sep) to ensure a real directory boundary. * fix(sdk): validate loadConfig export before invocation (CR finding 3) After requiring core.cjs, check typeof mod.loadConfig === 'function' before calling it. Throws a classified GSDError with the module path if the export is missing, rather than a generic TypeError. * fix(test): guard root lookup before .path dereference (CR finding 4) Added assert.ok() guards for claudeRoot and codexRoot after the .find() calls so that a missing root produces an explicit assertion failure rather than a TypeError on .path dereference. * fix(ci): fail-safe on transient API errors in approval dismissal (CR finding 6) resolveRole() returns 'unknown' for non-404 errors (rate limits, 5xx, network blips). shouldDismissReviewer() now treats 'unknown' as unresolvable and skips dismissal, preventing legitimate approvals from being dismissed due to a transient API failure. Only 'none' (true 404) is treated as a confirmed non-collaborator. * changeset: pr=3238 SDK package seam and runtime skills convergence * fix(sdk): harden resolveGlobalSkillDir against path traversal (CR finding 1) Use resolve+relative to validate that skillName cannot escape the global skills base directory. Values like "../../foo" or absolute paths now return null instead of joining directly. All imports (resolve, relative, isAbsolute) were already present in helpers.ts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sdk): split skill-dir-resolution and skill-not-found warnings (CR finding 2) After resolveGlobalSkillDir's hardening can return null for traversal attempts, the old single-branch warning "Global skill not found at ..." was misleading. Split into two distinct cases: - skillDir === null → "Could not resolve global skill directory for ..." - skillMd missing → "Global skill not found at ..." Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: lock skill path-traversal rejection in resolveGlobalSkillDir Regression test verifying that traversal segments (../../foo, ../escape), empty string, and absolute paths are all rejected (return null), while a legitimate skill name resolves correctly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(sdk): align display-path contract + traversal coverage for resolveGlobalSkillMarkdownPath (CR nitpicks) - renderGlobalSkillsBaseDisplayPath now returns a non-null string for unsupported runtimes (e.g. cline → "(cline does not use a skills directory)") matching the existing renderGlobalSkillDisplayPath contract; callers of both helpers no longer need null-checks for unsupported runtimes. - Remove now-redundant ! non-null assertion on renderGlobalSkillsBaseDisplayPath calls in skill-manifest.ts (return type is string, not string | null). - Extend the path-traversal test block to assert resolveGlobalSkillMarkdownPath also propagates null for ../../foo, ../escape, empty, and /abs/path inputs, locking the null-propagation contract against future refactors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
148 lines
5.6 KiB
JavaScript
148 lines
5.6 KiB
JavaScript
/**
|
|
* Tests for skill-manifest command
|
|
*/
|
|
|
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
|
|
function writeSkill(rootDir, name, description, body = '') {
|
|
const skillDir = path.join(rootDir, name);
|
|
fs.mkdirSync(skillDir, { recursive: true });
|
|
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
|
|
'---',
|
|
`name: ${name}`,
|
|
`description: ${description}`,
|
|
'---',
|
|
'',
|
|
body || `# ${name}`,
|
|
].join('\n'));
|
|
}
|
|
|
|
describe('skill-manifest', () => {
|
|
let tmpDir;
|
|
let homeDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
homeDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'gsd-skill-manifest-home-'));
|
|
|
|
writeSkill(path.join(tmpDir, '.claude', 'skills'), 'project-claude', 'Project Claude skill');
|
|
writeSkill(path.join(tmpDir, '.claude', 'skills'), 'gsd-help', 'Installed GSD skill');
|
|
writeSkill(path.join(tmpDir, '.agents', 'skills'), 'project-agents', 'Project agent skill');
|
|
writeSkill(path.join(tmpDir, '.codex', 'skills'), 'project-codex', 'Project Codex skill');
|
|
|
|
writeSkill(path.join(homeDir, '.claude', 'skills'), 'global-claude', 'Global Claude skill');
|
|
writeSkill(path.join(homeDir, '.codex', 'skills'), 'global-codex', 'Global Codex skill');
|
|
writeSkill(
|
|
path.join(homeDir, '.claude', 'get-shit-done', 'skills'),
|
|
'legacy-import',
|
|
'Deprecated import-only skill'
|
|
);
|
|
|
|
fs.mkdirSync(path.join(homeDir, '.claude', 'commands', 'gsd'), { recursive: true });
|
|
fs.writeFileSync(path.join(homeDir, '.claude', 'commands', 'gsd', 'help.md'), '# legacy');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
cleanup(homeDir);
|
|
});
|
|
|
|
test('returns normalized inventory across canonical roots', () => {
|
|
const result = runGsdTools(['skill-manifest'], tmpDir, { HOME: homeDir });
|
|
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
|
|
|
|
const manifest = JSON.parse(result.output);
|
|
assert.ok(Array.isArray(manifest.skills), 'skills should be an array');
|
|
assert.ok(Array.isArray(manifest.roots), 'roots should be an array');
|
|
assert.ok(manifest.installation && typeof manifest.installation === 'object', 'installation summary present');
|
|
assert.ok(manifest.counts && typeof manifest.counts === 'object', 'counts summary present');
|
|
|
|
const skillNames = manifest.skills.map((skill) => skill.name).sort();
|
|
assert.deepStrictEqual(skillNames, [
|
|
'global-claude',
|
|
'global-codex',
|
|
'gsd-help',
|
|
'legacy-import',
|
|
'project-agents',
|
|
'project-claude',
|
|
'project-codex',
|
|
]);
|
|
|
|
const codexSkill = manifest.skills.find((skill) => skill.name === 'project-codex');
|
|
assert.deepStrictEqual(
|
|
{
|
|
root: codexSkill.root,
|
|
scope: codexSkill.scope,
|
|
installed: codexSkill.installed,
|
|
deprecated: codexSkill.deprecated,
|
|
},
|
|
{
|
|
root: '.codex/skills',
|
|
scope: 'project',
|
|
installed: true,
|
|
deprecated: false,
|
|
}
|
|
);
|
|
|
|
const importedSkill = manifest.skills.find((skill) => skill.name === 'legacy-import');
|
|
assert.deepStrictEqual(
|
|
{
|
|
root: importedSkill.root,
|
|
scope: importedSkill.scope,
|
|
installed: importedSkill.installed,
|
|
deprecated: importedSkill.deprecated,
|
|
},
|
|
{
|
|
root: '.claude/get-shit-done/skills',
|
|
scope: 'import-only',
|
|
installed: false,
|
|
deprecated: true,
|
|
}
|
|
);
|
|
|
|
const gsdSkill = manifest.skills.find((skill) => skill.name === 'gsd-help');
|
|
assert.strictEqual(gsdSkill.installed, true);
|
|
|
|
const legacyRoot = manifest.roots.find((root) => root.scope === 'legacy-commands');
|
|
assert.ok(legacyRoot, 'legacy commands root should be reported');
|
|
assert.strictEqual(legacyRoot.present, true);
|
|
|
|
assert.strictEqual(manifest.installation.gsd_skills_installed, true);
|
|
assert.strictEqual(manifest.installation.legacy_claude_commands_installed, true);
|
|
assert.strictEqual(manifest.counts.skills, 7);
|
|
});
|
|
|
|
test('writes manifest to .planning/skill-manifest.json when --write flag is used', () => {
|
|
const result = runGsdTools(['skill-manifest', '--write'], tmpDir, { HOME: homeDir });
|
|
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
|
|
|
|
const manifestPath = path.join(tmpDir, '.planning', 'skill-manifest.json');
|
|
assert.ok(fs.existsSync(manifestPath), 'skill-manifest.json should be written to .planning/');
|
|
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
assert.ok(Array.isArray(manifest.skills));
|
|
assert.ok(manifest.installation);
|
|
});
|
|
|
|
test('global roots honor runtime-home env overrides instead of hardcoded home paths', () => {
|
|
const result = runGsdTools(['skill-manifest'], tmpDir, {
|
|
HOME: homeDir,
|
|
CLAUDE_CONFIG_DIR: path.join(homeDir, 'claude-custom'),
|
|
CODEX_HOME: path.join(homeDir, 'codex-custom'),
|
|
});
|
|
assert.ok(result.success, `Command should succeed: ${result.error || result.output}`);
|
|
|
|
const manifest = JSON.parse(result.output);
|
|
const claudeRoot = manifest.roots.find((root) => root.root === '~/.claude/skills');
|
|
const codexRoot = manifest.roots.find((root) => root.root === '~/.codex/skills');
|
|
assert.ok(claudeRoot, 'Expected ~/.claude/skills root to be present');
|
|
assert.ok(codexRoot, 'Expected ~/.codex/skills root to be present');
|
|
assert.strictEqual(claudeRoot.path, path.join(homeDir, 'claude-custom', 'skills'));
|
|
assert.strictEqual(codexRoot.path, path.join(homeDir, 'codex-custom', 'skills'));
|
|
});
|
|
});
|