Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions hosts/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const codex: HostConfig = {
{ from: '.claude/skills/gstack', to: '.agents/skills/gstack' },
{ from: '.claude/skills/review', to: '.agents/skills/gstack/review' },
{ from: '.claude/skills', to: '.agents/skills' },
{ from: '${CLAUDE_SKILL_DIR}/../qa-only/SKILL.md', to: '$GSTACK_ROOT/../gstack-qa-only/SKILL.md' },
],

suppressedResolvers: [
Expand Down
1 change: 1 addition & 0 deletions hosts/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const hermes: HostConfig = {
mode: 'allowlist',
keepFields: ['name', 'description'],
descriptionLimit: null,
nameTransform: 'external-skill-name',
},

generation: {
Expand Down
11 changes: 6 additions & 5 deletions scripts/gen-skill-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,7 @@ policy:
* Codex: keeps name + description only, enforces 1024-char limit.
* Factory: keeps name + description + user-invocable, conditionally adds disable-model-invocation.
*/
function transformFrontmatter(content: string, host: Host): string {
function transformFrontmatter(content: string, host: Host, externalName?: string): string {
const hostConfig = getHostConfig(host);
const fm = hostConfig.frontmatter;

Expand All @@ -531,26 +531,27 @@ function transformFrontmatter(content: string, host: Host): string {
const frontmatter = content.slice(fmStart + 4, fmEnd);
const body = content.slice(fmEnd + 4);
const { name, description } = extractNameAndDescription(content);
const emittedName = fm.nameTransform === 'external-skill-name' && externalName ? externalName : name;

// Description limit enforcement
if (fm.descriptionLimit) {
const behavior = fm.descriptionLimitBehavior || 'error';
if (description.length > fm.descriptionLimit) {
if (behavior === 'error') {
throw new Error(
`${hostConfig.displayName} description for "${name}" is ${description.length} chars (max ${fm.descriptionLimit}). ` +
`${hostConfig.displayName} description for "${emittedName}" is ${description.length} chars (max ${fm.descriptionLimit}). ` +
`Compress the description in the .tmpl file.`
);
} else if (behavior === 'warn') {
console.warn(`WARNING: ${hostConfig.displayName} description for "${name}" exceeds ${fm.descriptionLimit} chars`);
console.warn(`WARNING: ${hostConfig.displayName} description for "${emittedName}" exceeds ${fm.descriptionLimit} chars`);
}
// 'truncate' — silently proceed
}
}

// Build frontmatter with allowed fields
const indentedDesc = description.split('\n').map(l => ` ${l}`).join('\n');
let newFm = `---\nname: ${name}\ndescription: |\n${indentedDesc}\n`;
let newFm = `---\nname: ${emittedName}\ndescription: |\n${indentedDesc}\n`;

// Add extra fields (host-wide)
if (fm.extraFields) {
Expand Down Expand Up @@ -772,7 +773,7 @@ function processExternalHost(
const safetyProse = extractHookSafetyProse(tmplContent);

// Transform frontmatter (host-aware)
let result = transformFrontmatter(content, host);
let result = transformFrontmatter(content, host, name);

// Insert safety advisory at the top of the body (after frontmatter)
if (safetyProse) {
Expand Down
2 changes: 2 additions & 0 deletions scripts/host-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export interface HostConfig {
descriptionLimit?: number | null;
/** What to do when description exceeds limit. Default: 'error'. */
descriptionLimitBehavior?: 'error' | 'truncate' | 'warn';
/** Use the generated external directory name as frontmatter name. */
nameTransform?: 'external-skill-name';
/** Additional frontmatter fields to inject (host-wide). */
extraFields?: Record<string, unknown>;
/** Rename fields from template (e.g., { 'voice-triggers': 'triggers' }). */
Expand Down
7 changes: 7 additions & 0 deletions scripts/skill-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import { validateSkill } from '../test/helpers/skill-parser';
import { discoverTemplates, discoverSkillFiles } from './discover-skills';
import { claude as PRIMARY_HOST } from '../hosts/index';
import * as fs from 'fs';
import * as path from 'path';
import { execSync } from 'child_process';
Expand Down Expand Up @@ -64,15 +65,21 @@ for (const file of SKILL_FILES) {

console.log('\n Templates:');
const TEMPLATES = discoverTemplates(ROOT);
const PRIMARY_SKIP_SKILLS = new Set(PRIMARY_HOST.generation.skipSkills ?? []);

for (const { tmpl, output } of TEMPLATES) {
const tmplPath = path.join(ROOT, tmpl);
const outPath = path.join(ROOT, output);
const skillDir = output.split('/')[0];
if (!fs.existsSync(tmplPath)) {
console.log(` \u26a0\ufe0f ${output.padEnd(30)} — no template`);
continue;
}
if (!fs.existsSync(outPath)) {
if (PRIMARY_SKIP_SKILLS.has(skillDir)) {
console.log(` - ${output.padEnd(30)} — skipped per ${PRIMARY_HOST.name} host config`);
continue;
}
hasErrors = true;
console.log(` \u274c ${output.padEnd(30)} — generated file missing! Run: bun run gen:skill-docs`);
continue;
Expand Down
2 changes: 1 addition & 1 deletion test/fixtures/golden/codex-ship-SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -1826,7 +1826,7 @@ curl -s -o /dev/null -w '%{http_code}' http://localhost:4000 2>/dev/null || echo
Read the `/qa-only` skill from disk:

```bash
cat ${CLAUDE_SKILL_DIR}/../qa-only/SKILL.md
cat $GSTACK_ROOT/../gstack-qa-only/SKILL.md
```

**If unreadable:** Skip with "Could not load /qa-only — skipping plan verification."
Expand Down
9 changes: 9 additions & 0 deletions test/gen-skill-docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2195,6 +2195,15 @@ describe('Parameterized host smoke tests', () => {
}
});

if (hostConfig.name === 'hermes') {
test('frontmatter names match namespaced Hermes directories', () => {
for (const skill of ['gstack', 'gstack-review', 'gstack-qa', 'gstack-ship']) {
const content = fs.readFileSync(path.join(hostDir, skill, 'SKILL.md'), 'utf-8');
expect(content).toMatch(new RegExp(`^name: ${skill}$`, 'm'));
}
});
}

test('generates Claude outside-voice skill for external hosts', () => {
const skillMd = path.join(hostDir, 'gstack-claude', 'SKILL.md');
expect(fs.existsSync(skillMd)).toBe(true);
Expand Down
Loading