Files
msd-core/sdk/src/query/skill-manifest.ts
Tom Boucher 397c34142a Deepen SDK package seam and converge runtime skills policy (#3238)
* 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>
2026-05-08 09:06:43 -04:00

217 lines
6.9 KiB
TypeScript
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.
/**
* Skill manifest — multi-root skill discovery scan.
*
* Full port of `buildSkillManifest` / `cmdSkillManifest` from
* `get-shit-done/bin/lib/init.cjs` (lines 1640–1847).
* Uses {@link extractFrontmatterLeading} — same as CJS `frontmatter.cjs` `extractFrontmatter`
* (first `---` block only; skills with later `---` rules must not use TS `extractFrontmatter`'s last-block rule).
*/
import { existsSync, readdirSync, readFileSync, writeFileSync, type Dirent } from 'node:fs';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import { extractFrontmatterLeading } from './frontmatter.js';
import { resolveGlobalSkillsBase, renderGlobalSkillsBaseDisplayPath } from './helpers.js';
import type { QueryHandler } from './utils.js';
import { resolveLegacySkillsDir } from '../sdk-package-compatibility.js';
export interface SkillManifestSkill {
name: string;
description: string;
triggers: string[];
path: string;
file_path: string;
root: string;
scope: string;
installed: boolean;
deprecated: boolean;
}
export interface SkillManifestRoot {
root: string;
path: string;
scope: string;
present: boolean;
deprecated?: boolean;
skill_count?: number;
command_count?: number;
}
export interface SkillManifestJson {
skills: SkillManifestSkill[];
roots: SkillManifestRoot[];
installation: {
gsd_skills_installed: boolean;
legacy_claude_commands_installed: boolean;
};
counts: { skills: number; roots: number };
}
/**
* Scan canonical skill roots and build manifest JSON (same shape as gsd-tools.cjs).
*/
export function buildSkillManifest(cwd: string, skillsDir: string | null = null): SkillManifestJson {
const canonicalRoots = skillsDir
? [{
root: resolve(skillsDir),
path: resolve(skillsDir),
scope: 'custom',
present: existsSync(skillsDir),
kind: 'skills' as const,
}]
: [
{ root: '.claude/skills', path: join(cwd, '.claude', 'skills'), scope: 'project', kind: 'skills' as const },
{ root: '.agents/skills', path: join(cwd, '.agents', 'skills'), scope: 'project', kind: 'skills' as const },
{ root: '.cursor/skills', path: join(cwd, '.cursor', 'skills'), scope: 'project', kind: 'skills' as const },
{ root: '.github/skills', path: join(cwd, '.github', 'skills'), scope: 'project', kind: 'skills' as const },
{ root: '.codex/skills', path: join(cwd, '.codex', 'skills'), scope: 'project', kind: 'skills' as const },
{ root: renderGlobalSkillsBaseDisplayPath('claude'), path: resolveGlobalSkillsBase('claude')!, scope: 'global', kind: 'skills' as const },
{ root: renderGlobalSkillsBaseDisplayPath('codex'), path: resolveGlobalSkillsBase('codex')!, scope: 'global', kind: 'skills' as const },
{
root: '.claude/get-shit-done/skills',
path: resolveLegacySkillsDir(),
scope: 'import-only',
kind: 'skills' as const,
deprecated: true,
},
{
root: '.claude/commands/gsd',
path: join(homedir(), '.claude', 'commands', 'gsd'),
scope: 'legacy-commands',
kind: 'commands' as const,
deprecated: true,
},
];
const skills: SkillManifestSkill[] = [];
const roots: SkillManifestRoot[] = [];
let legacyClaudeCommandsInstalled = false;
for (const rootInfo of canonicalRoots) {
const rootPath = rootInfo.path;
const rootSummary: SkillManifestRoot = {
root: rootInfo.root,
path: rootPath,
scope: rootInfo.scope,
present: existsSync(rootPath),
deprecated: 'deprecated' in rootInfo ? !!rootInfo.deprecated : false,
};
if (!rootSummary.present) {
roots.push(rootSummary);
continue;
}
if (rootInfo.kind === 'commands') {
let entries: Dirent[];
try {
entries = readdirSync(rootPath, { withFileTypes: true });
} catch {
roots.push(rootSummary);
continue;
}
const commandFiles = entries.filter(e => e.isFile() && e.name.endsWith('.md'));
rootSummary.command_count = commandFiles.length;
if (rootSummary.command_count > 0) legacyClaudeCommandsInstalled = true;
roots.push(rootSummary);
continue;
}
let entries: Dirent[];
try {
entries = readdirSync(rootPath, { withFileTypes: true });
} catch {
roots.push(rootSummary);
continue;
}
let skillCount = 0;
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const entryName = entry.name.toString();
const skillMdPath = join(rootPath, entryName, 'SKILL.md');
if (!existsSync(skillMdPath)) continue;
let content: string;
try {
content = readFileSync(skillMdPath, 'utf-8');
} catch {
continue;
}
const frontmatter = extractFrontmatterLeading(content);
const name = (frontmatter.name as string) || entryName;
const description = (frontmatter.description as string) || '';
const triggers: string[] = [];
const bodyMatch = content.match(/^---[\s\S]*?---\s*\n([\s\S]*)$/);
if (bodyMatch) {
const body = bodyMatch[1];
const triggerLines = body.match(/^TRIGGER\s+when:\s*(.+)$/gim);
if (triggerLines) {
for (const line of triggerLines) {
const m = line.match(/^TRIGGER\s+when:\s*(.+)$/i);
if (m) triggers.push(m[1].trim());
}
}
}
skills.push({
name,
description,
triggers,
path: entryName,
file_path: `${entryName}/SKILL.md`,
root: rootInfo.root,
scope: rootInfo.scope,
installed: rootInfo.scope !== 'import-only',
deprecated: !!('deprecated' in rootInfo && rootInfo.deprecated),
});
skillCount++;
}
rootSummary.skill_count = skillCount;
roots.push(rootSummary);
}
skills.sort((a, b) => {
const rootCmp = a.root.localeCompare(b.root);
return rootCmp !== 0 ? rootCmp : a.name.localeCompare(b.name);
});
const gsdSkillsInstalled = skills.some(skill => skill.name.startsWith('gsd-'));
return {
skills,
roots,
installation: {
gsd_skills_installed: gsdSkillsInstalled,
legacy_claude_commands_installed: legacyClaudeCommandsInstalled,
},
counts: {
skills: skills.length,
roots: roots.length,
},
};
}
/**
* `skill-manifest` — same flags as gsd-tools: `--skills-dir`, `--write`.
*/
export const skillManifest: QueryHandler = async (args, projectDir) => {
const skillsDirIdx = args.indexOf('--skills-dir');
const skillsDir = skillsDirIdx >= 0 && args[skillsDirIdx + 1] ? args[skillsDirIdx + 1] : null;
const manifest = buildSkillManifest(projectDir, skillsDir);
if (args.includes('--write')) {
const planningDir = join(projectDir, '.planning');
if (existsSync(planningDir)) {
const manifestPath = join(planningDir, 'skill-manifest.json');
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8');
}
}
return { data: manifest };
};