* 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>
217 lines
6.9 KiB
TypeScript
217 lines
6.9 KiB
TypeScript
/**
|
||
* 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 };
|
||
};
|