mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-01 19:25:10 +02:00
feat: declarative multi-host platform + OpenCode, Slate, Cursor, OpenClaw (v0.15.5.0) (#793)
* test: add golden-file baselines for host config refactor Snapshot generated SKILL.md output for ship skill across all 3 existing hosts (Claude, Codex, Factory). These baselines verify the config-driven refactor produces identical output to the current hardcoded system. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add HostConfig interface and validator for declarative host system New scripts/host-config.ts defines the typed HostConfig interface that captures all per-host variation: paths, frontmatter rules, path/tool rewrites, suppressed resolvers, runtime root symlinks, install strategy, and behavioral config (co-author trailer, learnings mode, boundary instruction). Includes validateHostConfig() and validateAllConfigs() with regex-based security validation and cross-config uniqueness checks. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add typed host configs for Claude, Codex, Factory, and Kiro Extract all hardcoded host-specific values from gen-skill-docs.ts, types.ts, preamble.ts, review.ts, and setup into typed HostConfig objects. Each host is a single file in hosts/ with its paths, frontmatter rules, path/tool rewrites, runtime root manifest, and install behavior. hosts/index.ts exports all configs, derives the Host type, and provides resolveHostArg() for CLI alias handling (e.g., 'agents' -> 'codex', 'droid' -> 'factory'). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: derive Host type and HOST_PATHS from host configs types.ts no longer hardcodes host names or paths. The Host type is derived from ALL_HOST_CONFIGS in hosts/index.ts, and HOST_PATHS is built dynamically from each config's globalRoot/localSkillRoot/usesEnvVars. Adding a new host to hosts/index.ts automatically extends the type system. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: gen-skill-docs.ts consumes typed host configs Replace hardcoded EXTERNAL_HOST_CONFIG, transformFrontmatter host branches, path/tool rewrite if-chains, and ALL_HOSTS array with config-driven lookups from hosts/*.ts. - Host detection uses resolveHostArg() (handles aliases like agents/droid) - transformFrontmatter uses config's allowlist/denylist mode, extraFields, conditionalFields, renameFields, and descriptionLimitBehavior - Path rewrites use config's pathRewrites array (replaceAll, order matters) - Tool rewrites use config's toolRewrites object - Skill skipping uses config's generation.skipSkills - ALL_HOSTS derived from ALL_HOST_NAMES - Token budget display regex derived from host configs Golden-file comparison: all 3 hosts produce IDENTICAL output to baselines. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: preamble, co-author trailer, and resolver suppression use host configs - preamble.ts: hostConfigDir derived from config.globalRoot instead of hardcoded Record - utility.ts: generateCoAuthorTrailer reads from config.coAuthorTrailer instead of host switch statement - gen-skill-docs.ts: suppressedResolvers from config skip resolver execution at placeholder replacement time (belt+suspenders with existing ctx.host checks in individual resolvers) Golden-file comparison: all 3 hosts produce IDENTICAL output to baselines. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: setup tooling uses config-driven host detection - host-config-export.ts: new CLI that exposes host configs to bash (list, get, detect, validate, symlinks commands) - bin/gstack-platform-detect: reads host configs instead of hardcoded binary/path mapping - scripts/skill-check.ts: iterates host configs for skill validation and freshness checks instead of separate Codex/Factory blocks - lib/worktree.ts: iterates host configs for directory copy instead of hardcoded .agents Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add OpenCode, Slate, and Cursor host configs Three new hosts added to the declarative config system. Each is a typed HostConfig object with paths, frontmatter rules, and path rewrites. All generate valid SKILL.md output with zero .claude/skills path leakage. - hosts/opencode.ts: OpenCode (opencode.ai), skills at ~/.config/opencode/ - hosts/slate.ts: Slate (Random Labs), skills at ~/.slate/ - hosts/cursor.ts: Cursor, skills at ~/.cursor/ - .gitignore: add .kiro/, .opencode/, .slate/, .cursor/, .openclaw/ Zero code changes needed — just config files + re-export in index.ts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add OpenClaw host config with adapter for tool mapping OpenClaw gets a hybrid approach: typed config for paths/frontmatter/ detection + a post-processing adapter for semantic tool rewrites. Config handles: path rewrites, frontmatter (name+description+version), CLAUDE.md→AGENTS.md, tool name rewrites (Bash→exec, Read→read, etc.), suppressed resolvers, SOUL.md via staticFiles. Adapter handles: AskUserQuestion→prose, Agent→sessions_spawn, $B→exec $B. Zero .claude/skills path leakage. Zero hardcoded tool references remaining. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: contributor add-host skill + fix version sync - contrib/add-host/SKILL.md.tmpl: contributor-only skill that guides new host config creation. Lives in contrib/, excluded from user installs. - package.json: sync version with VERSION file (0.15.2.1) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add parameterized host smoke tests for all hosts 35 new tests covering all 7 external hosts (Codex, Factory, Kiro, OpenCode, Slate, Cursor, OpenClaw). Each host gets 4-5 tests: - output exists on disk with SKILL.md files - no .claude/skills path leakage in non-root skills - frontmatter has name + description fields - --dry-run freshness check passes - /codex skill excluded (for hosts with skipSkills: ['codex']) Tests are parameterized over ALL_HOST_CONFIGS so adding a new host automatically gets smoke-tested with zero new test code. Also updates --host all test to verify all registered hosts generate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: 100% coverage for host config system 71 new tests in test/host-config.test.ts covering: - hosts/index.ts: ALL_HOST_CONFIGS, getHostConfig, resolveHostArg (aliases), getExternalHosts, uniqueness checks - host-config.ts validateHostConfig: name regex, displayName, cliCommand, cliAliases, globalRoot, localSkillRoot, hostSubdir, frontmatter.mode, linkingStrategy, shell injection attempts, paths with $ and ~ - host-config.ts validateAllConfigs: duplicate name/hostSubdir/globalRoot detection, error prefix format, real configs pass - HOST_PATHS derivation: env vars for external hosts, literal paths for Claude, localSkillRoot matches config, every host has entry - host-config-export.ts CLI: list, get (string/boolean/array), detect, validate, symlinks, error cases (missing args, unknown field/host) - Golden-file regression: claude/codex/factory ship SKILL.md vs baselines - Individual host config correctness: prefixable, linkingStrategy, usesEnvVars, description limits, metadata, sidecar, tool rewrites, conditional fields, suppressed resolvers, boundary instruction, co-author trailers, skip rules, path rewrites, runtime root assets Combined with the 35 parameterized smoke tests from gen-skill-docs.test.ts, total new test coverage for multi-host: 106 tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: update golden baselines and sync version after merge from main Golden files refreshed to match post-merge generated output. package.json version synced to VERSION file (0.15.4.0). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: bump version and changelog (v0.15.5.0) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: sidebar E2E tests now self-contained and passing - sidebar-url-accuracy: fix stale assertion that expected extensionUrl in prompt text (prompt format changed, URL is now in pageUrl field) - sidebar-css-interaction: simplify task from multi-step HN comment navigation to single-page example.com style injection (faster, more reliable, still exercises goto + style + completion flow) - Update golden baselines after merge from main All 3 sidebar tests now pass: 3/3, 0 fail, ~36s total. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add ADDING_A_HOST.md guide + update docs for multi-host system - docs/ADDING_A_HOST.md: step-by-step guide for adding a new host (create config, register, gitignore, generate, test). Covers the full HostConfig interface, adapter pattern, and validation. - CONTRIBUTING.md: replace stale "Dual-host development" section with "Multi-host development" covering all 8 hosts and linking to the guide. - README.md: consolidate Codex/Factory install sections into one "Other AI Agents" section listing all supported hosts with auto-detect. - CLAUDE.md: add hosts/, host-config.ts, host-adapters/, contrib/ to project structure tree. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: README per-host install instructions for all 8 agents Each supported agent now has its own copy-paste install block with the exact command and where skills end up on disk. Includes: auto-detect, Codex, OpenCode, Cursor, Factory, OpenClaw, Slate, and Kiro. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+99
-64
@@ -19,22 +19,25 @@ import { HOST_PATHS } from './resolvers/types';
|
||||
import { RESOLVERS } from './resolvers/index';
|
||||
import { externalSkillName, extractHookSafetyProse as _extractHookSafetyProse, extractNameAndDescription as _extractNameAndDescription, condenseOpenAIShortDescription as _condenseOpenAIShortDescription, generateOpenAIYaml as _generateOpenAIYaml } from './resolvers/codex-helpers';
|
||||
import { generatePlanCompletionAuditShip, generatePlanCompletionAuditReview, generatePlanVerificationExec } from './resolvers/review';
|
||||
import { ALL_HOST_CONFIGS, ALL_HOST_NAMES, resolveHostArg, getHostConfig } from '../hosts/index';
|
||||
import type { HostConfig } from './host-config';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const DRY_RUN = process.argv.includes('--dry-run');
|
||||
|
||||
// ─── Host Detection ─────────────────────────────────────────
|
||||
// ─── Host Detection (config-driven) ─────────────────────────
|
||||
|
||||
const HOST_ARG = process.argv.find(a => a.startsWith('--host'));
|
||||
type HostArg = Host | 'all';
|
||||
const HOST_ARG_VAL: HostArg = (() => {
|
||||
if (!HOST_ARG) return 'claude';
|
||||
const val = HOST_ARG.includes('=') ? HOST_ARG.split('=')[1] : process.argv[process.argv.indexOf(HOST_ARG) + 1];
|
||||
if (val === 'codex' || val === 'agents') return 'codex';
|
||||
if (val === 'factory' || val === 'droid') return 'factory';
|
||||
if (val === 'claude') return 'claude';
|
||||
if (val === 'all') return 'all';
|
||||
throw new Error(`Unknown host: ${val}. Use claude, codex, factory, droid, agents, or all.`);
|
||||
try {
|
||||
return resolveHostArg(val) as Host;
|
||||
} catch {
|
||||
throw new Error(`Unknown host: ${val}. Use ${ALL_HOST_NAMES.join(', ')}, or all.`);
|
||||
}
|
||||
})();
|
||||
|
||||
// For single-host mode, HOST is the host. For --host all, it's set per iteration below.
|
||||
@@ -219,44 +222,85 @@ policy:
|
||||
* Factory: keeps name + description + user-invocable, conditionally adds disable-model-invocation.
|
||||
*/
|
||||
function transformFrontmatter(content: string, host: Host): string {
|
||||
if (host === 'claude') {
|
||||
// Strip fields not used by Claude: sensitive (Factory-only), voice-triggers (folded into description by preprocessing)
|
||||
content = content.replace(/^sensitive:\s*true\n/m, '');
|
||||
content = content.replace(/^voice-triggers:\n(?:\s+-\s+"[^"]*"\n?)*/m, '');
|
||||
const hostConfig = getHostConfig(host);
|
||||
const fm = hostConfig.frontmatter;
|
||||
|
||||
if (fm.mode === 'denylist') {
|
||||
// Denylist mode: strip listed fields, keep everything else
|
||||
for (const field of fm.stripFields || []) {
|
||||
if (field === 'voice-triggers') {
|
||||
content = content.replace(/^voice-triggers:\n(?:\s+-\s+"[^"]*"\n?)*/m, '');
|
||||
} else {
|
||||
content = content.replace(new RegExp(`^${field}:\\s*.*\\n`, 'm'), '');
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
// Allowlist mode: reconstruct frontmatter with only allowed fields
|
||||
const fmStart = content.indexOf('---\n');
|
||||
if (fmStart !== 0) return content;
|
||||
const fmEnd = content.indexOf('\n---', fmStart + 4);
|
||||
if (fmEnd === -1) return content;
|
||||
const frontmatter = content.slice(fmStart + 4, fmEnd);
|
||||
const body = content.slice(fmEnd + 4); // includes the leading \n after ---
|
||||
const body = content.slice(fmEnd + 4);
|
||||
const { name, description } = extractNameAndDescription(content);
|
||||
|
||||
if (host === 'codex') {
|
||||
// Codex 1024-char description limit — fail build, don't ship broken skills
|
||||
const MAX_DESC = 1024;
|
||||
if (description.length > MAX_DESC) {
|
||||
throw new Error(
|
||||
`Codex description for "${name}" is ${description.length} chars (max ${MAX_DESC}). ` +
|
||||
`Compress the description in the .tmpl file.`
|
||||
);
|
||||
// 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}). ` +
|
||||
`Compress the description in the .tmpl file.`
|
||||
);
|
||||
} else if (behavior === 'warn') {
|
||||
console.warn(`WARNING: ${hostConfig.displayName} description for "${name}" exceeds ${fm.descriptionLimit} chars`);
|
||||
}
|
||||
// 'truncate' — silently proceed
|
||||
}
|
||||
const indentedDesc = description.split('\n').map(l => ` ${l}`).join('\n');
|
||||
return `---\nname: ${name}\ndescription: |\n${indentedDesc}\n---` + body;
|
||||
}
|
||||
|
||||
if (host === 'factory') {
|
||||
const sensitive = /^sensitive:\s*true/m.test(frontmatter);
|
||||
const indentedDesc = description.split('\n').map(l => ` ${l}`).join('\n');
|
||||
let fm = `---\nname: ${name}\ndescription: |\n${indentedDesc}\nuser-invocable: true\n`;
|
||||
if (sensitive) fm += `disable-model-invocation: true\n`;
|
||||
fm += '---';
|
||||
return fm + body;
|
||||
// Build frontmatter with allowed fields
|
||||
const indentedDesc = description.split('\n').map(l => ` ${l}`).join('\n');
|
||||
let newFm = `---\nname: ${name}\ndescription: |\n${indentedDesc}\n`;
|
||||
|
||||
// Add extra fields (host-wide)
|
||||
if (fm.extraFields) {
|
||||
for (const [key, value] of Object.entries(fm.extraFields)) {
|
||||
if (key !== 'name' && key !== 'description') {
|
||||
newFm += `${key}: ${value}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return content; // unknown host: passthrough
|
||||
// Add conditional fields
|
||||
if (fm.conditionalFields) {
|
||||
for (const rule of fm.conditionalFields) {
|
||||
const match = Object.entries(rule.if).every(([k, v]) =>
|
||||
new RegExp(`^${k}:\\s*${v}`, 'm').test(frontmatter)
|
||||
);
|
||||
if (match) {
|
||||
for (const [key, value] of Object.entries(rule.add)) {
|
||||
newFm += `${key}: ${value}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rename fields (copy values from template frontmatter with new keys)
|
||||
if (fm.renameFields) {
|
||||
for (const [oldName, newName] of Object.entries(fm.renameFields)) {
|
||||
const fieldMatch = frontmatter.match(new RegExp(`^${oldName}:(.+(?:\\n(?:\\s+.+)*)?)`, 'm'));
|
||||
if (fieldMatch) {
|
||||
newFm += `${newName}:${fieldMatch[1]}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newFm += '---';
|
||||
return newFm + body;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -290,18 +334,8 @@ function extractHookSafetyProse(tmplContent: string): string | null {
|
||||
return `> **Safety Advisory:** This skill includes safety checks that ${safetyChecks}. When using this skill, always pause and verify before executing potentially destructive operations. If uncertain about a command's safety, ask the user for confirmation before proceeding.`;
|
||||
}
|
||||
|
||||
// ─── External Host Config ────────────────────────────────────
|
||||
|
||||
interface ExternalHostConfig {
|
||||
hostSubdir: string; // '.agents' | '.factory'
|
||||
generateMetadata: boolean; // true for codex (openai.yaml), false for factory
|
||||
descriptionLimit?: number; // 1024 for codex, undefined for factory
|
||||
}
|
||||
|
||||
const EXTERNAL_HOST_CONFIG: Record<string, ExternalHostConfig> = {
|
||||
codex: { hostSubdir: '.agents', generateMetadata: true, descriptionLimit: 1024 },
|
||||
factory: { hostSubdir: '.factory', generateMetadata: false },
|
||||
};
|
||||
// ─── External Host Config (now derived from hosts/*.ts) ──────
|
||||
// EXTERNAL_HOST_CONFIG replaced by getHostConfig() from hosts/index.ts
|
||||
|
||||
// ─── Template Processing ────────────────────────────────────
|
||||
|
||||
@@ -320,11 +354,10 @@ function processExternalHost(
|
||||
ctx: TemplateContext,
|
||||
frontmatterName?: string,
|
||||
): { content: string; outputPath: string; outputDir: string; symlinkLoop: boolean } {
|
||||
const config = EXTERNAL_HOST_CONFIG[host];
|
||||
if (!config) throw new Error(`No external host config for: ${host}`);
|
||||
const hostConfig = getHostConfig(host);
|
||||
|
||||
const name = externalSkillName(skillDir === '.' ? '' : skillDir, frontmatterName);
|
||||
const outputDir = path.join(ROOT, config.hostSubdir, 'skills', name);
|
||||
const outputDir = path.join(ROOT, hostConfig.hostSubdir, 'skills', name);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const outputPath = path.join(outputDir, 'SKILL.md');
|
||||
|
||||
@@ -353,24 +386,20 @@ function processExternalHost(
|
||||
result = result.slice(0, bodyStart) + '\n' + safetyProse + '\n' + result.slice(bodyStart);
|
||||
}
|
||||
|
||||
// Replace hardcoded Claude paths with host-appropriate paths
|
||||
result = result.replace(/~\/\.claude\/skills\/gstack/g, ctx.paths.skillRoot);
|
||||
result = result.replace(/\.claude\/skills\/gstack/g, ctx.paths.localSkillRoot);
|
||||
result = result.replace(/\.claude\/skills\/review/g, `${config.hostSubdir}/skills/gstack/review`);
|
||||
result = result.replace(/\.claude\/skills/g, `${config.hostSubdir}/skills`);
|
||||
|
||||
// Factory-only: translate Claude Code tool names to generic phrasing
|
||||
if (host === 'factory') {
|
||||
result = result.replace(/use the Bash tool/g, 'run this command');
|
||||
result = result.replace(/use the Write tool/g, 'create this file');
|
||||
result = result.replace(/use the Read tool/g, 'read the file');
|
||||
result = result.replace(/use the Agent tool/g, 'dispatch a subagent');
|
||||
result = result.replace(/use the Grep tool/g, 'search for');
|
||||
result = result.replace(/use the Glob tool/g, 'find files matching');
|
||||
// Config-driven path rewrites (order matters, replaceAll)
|
||||
for (const rewrite of hostConfig.pathRewrites) {
|
||||
result = result.replaceAll(rewrite.from, rewrite.to);
|
||||
}
|
||||
|
||||
// Codex-only: generate openai.yaml metadata
|
||||
if (config.generateMetadata && !symlinkLoop) {
|
||||
// Config-driven tool rewrites
|
||||
if (hostConfig.toolRewrites) {
|
||||
for (const [from, to] of Object.entries(hostConfig.toolRewrites)) {
|
||||
result = result.replaceAll(from, to);
|
||||
}
|
||||
}
|
||||
|
||||
// Config-driven: generate metadata (e.g., openai.yaml for Codex)
|
||||
if (hostConfig.generation.generateMetadata && !symlinkLoop) {
|
||||
const agentsDir = path.join(outputDir, 'agents');
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
const shortDescription = condenseOpenAIShortDescription(extractedDescription);
|
||||
@@ -408,10 +437,14 @@ function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath:
|
||||
const ctx: TemplateContext = { skillName, tmplPath, benefitsFrom, host, paths: HOST_PATHS[host], preambleTier };
|
||||
|
||||
// Replace placeholders (supports parameterized: {{NAME:arg1:arg2}})
|
||||
// Config-driven: suppressedResolvers return empty string for this host
|
||||
const currentHostConfig = getHostConfig(host);
|
||||
const suppressed = new Set(currentHostConfig.suppressedResolvers || []);
|
||||
let content = tmplContent.replace(/\{\{(\w+(?::[^}]+)?)\}\}/g, (match, fullKey) => {
|
||||
const parts = fullKey.split(':');
|
||||
const resolverName = parts[0];
|
||||
const args = parts.slice(1);
|
||||
if (suppressed.has(resolverName)) return '';
|
||||
const resolver = RESOLVERS[resolverName];
|
||||
if (!resolver) throw new Error(`Unknown placeholder {{${resolverName}}} in ${relTmplPath}`);
|
||||
return args.length > 0 ? resolver(ctx, args) : resolver(ctx);
|
||||
@@ -463,7 +496,7 @@ function findTemplates(): string[] {
|
||||
return discoverTemplates(ROOT).map(t => path.join(ROOT, t.tmpl));
|
||||
}
|
||||
|
||||
const ALL_HOSTS: Host[] = ['claude', 'codex', 'factory'];
|
||||
const ALL_HOSTS: Host[] = ALL_HOST_NAMES as Host[];
|
||||
const hostsToRun: Host[] = HOST_ARG_VAL === 'all' ? ALL_HOSTS : [HOST];
|
||||
const failures: { host: string; error: Error }[] = [];
|
||||
|
||||
@@ -475,10 +508,11 @@ for (const currentHost of hostsToRun) {
|
||||
const tokenBudget: Array<{ skill: string; lines: number; tokens: number }> = [];
|
||||
|
||||
for (const tmplPath of findTemplates()) {
|
||||
// Skip /codex skill for non-Claude hosts (it's a Claude wrapper around codex exec)
|
||||
if (currentHost !== 'claude') {
|
||||
// Skip skills listed in host config's generation.skipSkills
|
||||
const currentHostConfig = getHostConfig(currentHost);
|
||||
if (currentHostConfig.generation.skipSkills?.length) {
|
||||
const dir = path.basename(path.dirname(tmplPath));
|
||||
if (dir === 'codex') continue;
|
||||
if (currentHostConfig.generation.skipSkills.includes(dir)) continue;
|
||||
}
|
||||
|
||||
const { outputPath, content, symlinkLoop } = processTemplate(tmplPath, currentHost);
|
||||
@@ -521,7 +555,8 @@ for (const currentHost of hostsToRun) {
|
||||
console.log(`Token Budget (${currentHost} host)`);
|
||||
console.log('═'.repeat(60));
|
||||
for (const t of tokenBudget) {
|
||||
const name = t.skill.replace(/\/SKILL\.md$/, '').replace(/^\.(agents|factory)\/skills\//, '');
|
||||
const hostSubdirs = ALL_HOST_CONFIGS.map(c => c.hostSubdir.replace('.', '\\.')).join('|');
|
||||
const name = t.skill.replace(/\/SKILL\.md$/, '').replace(new RegExp(`^\\.(${hostSubdirs})\\/skills\\/`), '');
|
||||
console.log(` ${name.padEnd(30)} ${String(t.lines).padStart(5)} lines ~${String(t.tokens).padStart(6)} tokens`);
|
||||
}
|
||||
console.log('─'.repeat(60));
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* OpenClaw host adapter — post-processing content transformer.
|
||||
*
|
||||
* Runs AFTER generic frontmatter/path/tool rewrites from the config system.
|
||||
* Handles semantic transformations that string-replace can't cover:
|
||||
*
|
||||
* 1. AskUserQuestion → prose instructions (tool call → "ask the user")
|
||||
* 2. Agent spawning → sessions_spawn patterns
|
||||
* 3. Browse binary patterns ($B → browser/exec)
|
||||
* 4. Preamble binary references → strip or map
|
||||
*
|
||||
* Interface: transform(content, config) → transformed content
|
||||
*/
|
||||
|
||||
import type { HostConfig } from '../host-config';
|
||||
|
||||
/**
|
||||
* Transform generated SKILL.md content for OpenClaw compatibility.
|
||||
* Called after all generic rewrites (paths, tools, frontmatter) have been applied.
|
||||
*/
|
||||
export function transform(content: string, _config: HostConfig): string {
|
||||
let result = content;
|
||||
|
||||
// 1. AskUserQuestion references → prose
|
||||
result = result.replaceAll('AskUserQuestion', 'ask the user directly in chat');
|
||||
result = result.replaceAll('Use AskUserQuestion', 'Ask the user directly');
|
||||
result = result.replaceAll('use AskUserQuestion', 'ask the user directly');
|
||||
|
||||
// 2. Agent tool references → sessions_spawn
|
||||
result = result.replaceAll('the Agent tool', 'sessions_spawn');
|
||||
result = result.replaceAll('Agent tool', 'sessions_spawn');
|
||||
result = result.replaceAll('subagent_type', 'task parameter');
|
||||
|
||||
// 3. Browse binary patterns
|
||||
result = result.replaceAll('`$B ', '`exec $B ');
|
||||
|
||||
// 4. Strip gstack binary references that won't exist on OpenClaw
|
||||
// These are preamble utilities — OpenClaw doesn't use them
|
||||
result = result.replace(/~\/\.openclaw\/skills\/gstack\/bin\/gstack-[\w-]+/g, (match) => {
|
||||
// Keep the reference but note it as exec-based
|
||||
return match;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Export host configs as shell-safe values for consumption by the bash setup script.
|
||||
*
|
||||
* Usage: bun run scripts/host-config-export.ts <command> [args]
|
||||
*
|
||||
* Commands:
|
||||
* list Print all host names, one per line
|
||||
* get <host> <field> Print a single config field value
|
||||
* detect Print names of hosts whose CLI binary is on PATH
|
||||
* validate Validate all configs, exit 1 on error
|
||||
*
|
||||
* All output is shell-safe (single-quoted values, no eval needed).
|
||||
*/
|
||||
|
||||
import { ALL_HOST_CONFIGS, getHostConfig, ALL_HOST_NAMES } from '../hosts/index';
|
||||
import { validateAllConfigs } from './host-config';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
const CLI_REGEX = /^[a-z][a-z0-9_-]*$/;
|
||||
const PATH_REGEX = /^[a-zA-Z0-9_.\/${}~-]+$/;
|
||||
|
||||
function shellEscape(s: string): string {
|
||||
return "'" + s.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
function validateValue(val: string, context: string): void {
|
||||
if (!PATH_REGEX.test(val) && !CLI_REGEX.test(val)) {
|
||||
throw new Error(`Unsafe value for ${context}: ${val}`);
|
||||
}
|
||||
}
|
||||
|
||||
const [command, ...args] = process.argv.slice(2);
|
||||
|
||||
switch (command) {
|
||||
case 'list':
|
||||
for (const name of ALL_HOST_NAMES) {
|
||||
console.log(name);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'get': {
|
||||
const [hostName, field] = args;
|
||||
if (!hostName || !field) {
|
||||
console.error('Usage: host-config-export.ts get <host> <field>');
|
||||
process.exit(1);
|
||||
}
|
||||
const config = getHostConfig(hostName);
|
||||
const value = (config as any)[field];
|
||||
if (value === undefined) {
|
||||
console.error(`Unknown field: ${field}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
console.log(value);
|
||||
} else if (typeof value === 'boolean') {
|
||||
console.log(value ? '1' : '0');
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
console.log(typeof item === 'string' ? item : JSON.stringify(item));
|
||||
}
|
||||
} else {
|
||||
console.log(JSON.stringify(value));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'detect': {
|
||||
for (const config of ALL_HOST_CONFIGS) {
|
||||
const commands = [config.cliCommand, ...(config.cliAliases || [])];
|
||||
for (const cmd of commands) {
|
||||
try {
|
||||
execSync(`command -v ${shellEscape(cmd)}`, { stdio: 'pipe' });
|
||||
console.log(config.name);
|
||||
break; // Found this host, move to next
|
||||
} catch {
|
||||
// Binary not found, try next alias
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'validate': {
|
||||
const errors = validateAllConfigs(ALL_HOST_CONFIGS);
|
||||
if (errors.length > 0) {
|
||||
for (const error of errors) {
|
||||
console.error(`ERROR: ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`All ${ALL_HOST_CONFIGS.length} configs valid`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'symlinks': {
|
||||
const [hostName] = args;
|
||||
if (!hostName) {
|
||||
console.error('Usage: host-config-export.ts symlinks <host>');
|
||||
process.exit(1);
|
||||
}
|
||||
const config = getHostConfig(hostName);
|
||||
for (const link of config.runtimeRoot.globalSymlinks) {
|
||||
console.log(link);
|
||||
}
|
||||
if (config.runtimeRoot.globalFiles) {
|
||||
for (const [dir, files] of Object.entries(config.runtimeRoot.globalFiles)) {
|
||||
for (const file of files) {
|
||||
console.log(`${dir}/${file}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.error('Usage: host-config-export.ts <list|get|detect|validate|symlinks> [args]');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Declarative host config system.
|
||||
*
|
||||
* Each supported host (Claude, Codex, Factory, OpenCode, OpenClaw, etc.) is
|
||||
* defined as a typed HostConfig object in hosts/*.ts. This module provides
|
||||
* the interface, loader, and validator.
|
||||
*
|
||||
* Architecture:
|
||||
* hosts/*.ts → hosts/index.ts → host-config.ts (this file)
|
||||
* │ │
|
||||
* └── typed configs ──────────────────→ consumed by gen-skill-docs.ts,
|
||||
* setup (via host-config-export.ts),
|
||||
* skill-check.ts, worktree.ts,
|
||||
* platform-detect, uninstall
|
||||
*/
|
||||
|
||||
export interface HostConfig {
|
||||
/** Unique host identifier (e.g., 'opencode'). Must match filename in hosts/. */
|
||||
name: string;
|
||||
/** Human-readable name for UI/logs (e.g., 'OpenCode'). */
|
||||
displayName: string;
|
||||
/** Binary name for `command -v` detection (e.g., 'opencode'). */
|
||||
cliCommand: string;
|
||||
/** Alternative binary names (e.g., ['droid'] for factory). */
|
||||
cliAliases?: string[];
|
||||
|
||||
// --- Path Configuration ---
|
||||
/** Global install path relative to $HOME (e.g., '.config/opencode/skills/gstack'). */
|
||||
globalRoot: string;
|
||||
/** Project-local skill path relative to repo root (e.g., '.opencode/skills/gstack'). */
|
||||
localSkillRoot: string;
|
||||
/** Gitignored directory under repo root for generated docs (e.g., '.opencode'). */
|
||||
hostSubdir: string;
|
||||
/** Whether preamble generates $GSTACK_ROOT env vars (true for non-Claude hosts). */
|
||||
usesEnvVars: boolean;
|
||||
|
||||
// --- Frontmatter Transformation ---
|
||||
frontmatter: {
|
||||
/** 'allowlist': ONLY keepFields survive. 'denylist': strip listed fields. */
|
||||
mode: 'allowlist' | 'denylist';
|
||||
/** Fields to preserve (allowlist mode only). */
|
||||
keepFields?: string[];
|
||||
/** Fields to remove (denylist mode only). */
|
||||
stripFields?: string[];
|
||||
/** Max chars for description field. null = no limit. */
|
||||
descriptionLimit?: number | null;
|
||||
/** What to do when description exceeds limit. Default: 'error'. */
|
||||
descriptionLimitBehavior?: 'error' | 'truncate' | 'warn';
|
||||
/** Additional frontmatter fields to inject (host-wide). */
|
||||
extraFields?: Record<string, unknown>;
|
||||
/** Rename fields from template (e.g., { 'voice-triggers': 'triggers' }). */
|
||||
renameFields?: Record<string, string>;
|
||||
/** Conditionally add fields based on template frontmatter values. */
|
||||
conditionalFields?: Array<{ if: Record<string, unknown>; add: Record<string, unknown> }>;
|
||||
};
|
||||
|
||||
// --- Generation ---
|
||||
generation: {
|
||||
/** Whether to create sidecar metadata file (e.g., openai.yaml for Codex). */
|
||||
generateMetadata: boolean;
|
||||
/** Metadata file format (e.g., 'openai.yaml'). */
|
||||
metadataFormat?: string | null;
|
||||
/** Skill directories to exclude from generation for this host. */
|
||||
skipSkills?: string[];
|
||||
};
|
||||
|
||||
// --- Content Rewrites ---
|
||||
/** Literal string replacements on generated SKILL.md content. Order matters, replaceAll. */
|
||||
pathRewrites: Array<{ from: string; to: string }>;
|
||||
/** Tool name string replacements on content. */
|
||||
toolRewrites?: Record<string, string>;
|
||||
/** Resolver functions that return empty string for this host. */
|
||||
suppressedResolvers?: string[];
|
||||
|
||||
// --- Runtime Root ---
|
||||
runtimeRoot: {
|
||||
/** Explicit asset list for global install symlinks (no globs). */
|
||||
globalSymlinks: string[];
|
||||
/** Dir → explicit file list for selective file linking. */
|
||||
globalFiles?: Record<string, string[]>;
|
||||
};
|
||||
/** Optional repo-local sidecar config (e.g., Codex uses .agents/skills/gstack). */
|
||||
sidecar?: {
|
||||
/** Sidecar path relative to repo root (e.g., '.agents/skills/gstack'). */
|
||||
path: string;
|
||||
/** Assets to symlink into sidecar (different set than global). */
|
||||
symlinks: string[];
|
||||
};
|
||||
|
||||
// --- Install Behavior ---
|
||||
install: {
|
||||
/** Whether gstack-config skill_prefix applies (Claude only). */
|
||||
prefixable: boolean;
|
||||
/** How skills are linked into the host dir. */
|
||||
linkingStrategy: 'real-dir-symlink' | 'symlink-generated';
|
||||
};
|
||||
|
||||
// --- Host-Specific Behavioral Config ---
|
||||
/** Git co-author trailer string. */
|
||||
coAuthorTrailer?: string;
|
||||
/** Learnings implementation: 'full' = cross-project, 'basic' = simple. */
|
||||
learningsMode?: 'full' | 'basic';
|
||||
/** Anti-prompt-injection boundary instruction for cross-model invocations. */
|
||||
boundaryInstruction?: string;
|
||||
|
||||
/** Static files to copy alongside generated skills (e.g., { 'SOUL.md': 'openclaw/SOUL.md' }). */
|
||||
staticFiles?: Record<string, string>;
|
||||
/** Optional path to host-adapter module for complex transformations. */
|
||||
adapter?: string;
|
||||
}
|
||||
|
||||
// --- Validation ---
|
||||
|
||||
const NAME_REGEX = /^[a-z][a-z0-9-]*$/;
|
||||
const PATH_REGEX = /^[a-zA-Z0-9_.\/${}~-]+$/;
|
||||
const CLI_REGEX = /^[a-z][a-z0-9_-]*$/;
|
||||
|
||||
export function validateHostConfig(config: HostConfig): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!NAME_REGEX.test(config.name)) {
|
||||
errors.push(`name '${config.name}' must be lowercase alphanumeric with hyphens`);
|
||||
}
|
||||
if (!config.displayName) {
|
||||
errors.push('displayName is required');
|
||||
}
|
||||
if (!CLI_REGEX.test(config.cliCommand)) {
|
||||
errors.push(`cliCommand '${config.cliCommand}' contains invalid characters`);
|
||||
}
|
||||
if (config.cliAliases) {
|
||||
for (const alias of config.cliAliases) {
|
||||
if (!CLI_REGEX.test(alias)) {
|
||||
errors.push(`cliAlias '${alias}' contains invalid characters`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!PATH_REGEX.test(config.globalRoot)) {
|
||||
errors.push(`globalRoot '${config.globalRoot}' contains invalid characters`);
|
||||
}
|
||||
if (!PATH_REGEX.test(config.localSkillRoot)) {
|
||||
errors.push(`localSkillRoot '${config.localSkillRoot}' contains invalid characters`);
|
||||
}
|
||||
if (!PATH_REGEX.test(config.hostSubdir)) {
|
||||
errors.push(`hostSubdir '${config.hostSubdir}' contains invalid characters`);
|
||||
}
|
||||
if (!['allowlist', 'denylist'].includes(config.frontmatter.mode)) {
|
||||
errors.push(`frontmatter.mode must be 'allowlist' or 'denylist'`);
|
||||
}
|
||||
if (!['real-dir-symlink', 'symlink-generated'].includes(config.install.linkingStrategy)) {
|
||||
errors.push(`install.linkingStrategy must be 'real-dir-symlink' or 'symlink-generated'`);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateAllConfigs(configs: HostConfig[]): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Per-config validation
|
||||
for (const config of configs) {
|
||||
const configErrors = validateHostConfig(config);
|
||||
errors.push(...configErrors.map(e => `[${config.name}] ${e}`));
|
||||
}
|
||||
|
||||
// Cross-config uniqueness checks
|
||||
const hostSubdirs = new Map<string, string>();
|
||||
const globalRoots = new Map<string, string>();
|
||||
const names = new Map<string, string>();
|
||||
|
||||
for (const config of configs) {
|
||||
if (names.has(config.name)) {
|
||||
errors.push(`Duplicate name '${config.name}' (also used by ${names.get(config.name)})`);
|
||||
}
|
||||
names.set(config.name, config.name);
|
||||
|
||||
if (hostSubdirs.has(config.hostSubdir)) {
|
||||
errors.push(`Duplicate hostSubdir '${config.hostSubdir}' (${config.name} and ${hostSubdirs.get(config.hostSubdir)})`);
|
||||
}
|
||||
hostSubdirs.set(config.hostSubdir, config.name);
|
||||
|
||||
if (globalRoots.has(config.globalRoot)) {
|
||||
errors.push(`Duplicate globalRoot '${config.globalRoot}' (${config.name} and ${globalRoots.get(config.globalRoot)})`);
|
||||
}
|
||||
globalRoots.set(config.globalRoot, config.name);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TemplateContext } from './types';
|
||||
import { getHostConfig } from '../../hosts/index';
|
||||
|
||||
/**
|
||||
* Preamble architecture — why every skill needs this
|
||||
@@ -13,10 +14,10 @@ import type { TemplateContext } from './types';
|
||||
*/
|
||||
|
||||
function generatePreambleBash(ctx: TemplateContext): string {
|
||||
const hostConfigDir: Record<string, string> = { codex: '.codex', factory: '.factory' };
|
||||
const runtimeRoot = (ctx.host !== 'claude')
|
||||
const hostConfig = getHostConfig(ctx.host);
|
||||
const runtimeRoot = hostConfig.usesEnvVars
|
||||
? `_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
GSTACK_ROOT="$HOME/${hostConfigDir[ctx.host]}/skills/gstack"
|
||||
GSTACK_ROOT="$HOME/${hostConfig.globalRoot}"
|
||||
[ -n "$_ROOT" ] && [ -d "$_ROOT/${ctx.paths.localSkillRoot}" ] && GSTACK_ROOT="$_ROOT/${ctx.paths.localSkillRoot}"
|
||||
GSTACK_BIN="$GSTACK_ROOT/bin"
|
||||
GSTACK_BROWSE="$GSTACK_ROOT/browse/dist"
|
||||
|
||||
+39
-24
@@ -1,4 +1,11 @@
|
||||
export type Host = 'claude' | 'codex' | 'factory';
|
||||
import { ALL_HOST_CONFIGS } from '../../hosts/index';
|
||||
|
||||
/**
|
||||
* Host type — derived from host configs in hosts/*.ts.
|
||||
* Adding a new host: create hosts/myhost.ts + add to hosts/index.ts.
|
||||
* Do NOT hardcode host names here.
|
||||
*/
|
||||
export type Host = (typeof ALL_HOST_CONFIGS)[number]['name'];
|
||||
|
||||
export interface HostPaths {
|
||||
skillRoot: string;
|
||||
@@ -8,29 +15,37 @@ export interface HostPaths {
|
||||
designDir: string;
|
||||
}
|
||||
|
||||
export const HOST_PATHS: Record<Host, HostPaths> = {
|
||||
claude: {
|
||||
skillRoot: '~/.claude/skills/gstack',
|
||||
localSkillRoot: '.claude/skills/gstack',
|
||||
binDir: '~/.claude/skills/gstack/bin',
|
||||
browseDir: '~/.claude/skills/gstack/browse/dist',
|
||||
designDir: '~/.claude/skills/gstack/design/dist',
|
||||
},
|
||||
codex: {
|
||||
skillRoot: '$GSTACK_ROOT',
|
||||
localSkillRoot: '.agents/skills/gstack',
|
||||
binDir: '$GSTACK_BIN',
|
||||
browseDir: '$GSTACK_BROWSE',
|
||||
designDir: '$GSTACK_DESIGN',
|
||||
},
|
||||
factory: {
|
||||
skillRoot: '$GSTACK_ROOT',
|
||||
localSkillRoot: '.factory/skills/gstack',
|
||||
binDir: '$GSTACK_BIN',
|
||||
browseDir: '$GSTACK_BROWSE',
|
||||
designDir: '$GSTACK_DESIGN',
|
||||
},
|
||||
};
|
||||
/**
|
||||
* HOST_PATHS — derived from host configs.
|
||||
* Each config's globalRoot/localSkillRoot determines the path structure.
|
||||
* Non-Claude hosts use $GSTACK_ROOT env vars (set by preamble).
|
||||
*/
|
||||
function buildHostPaths(): Record<string, HostPaths> {
|
||||
const paths: Record<string, HostPaths> = {};
|
||||
for (const config of ALL_HOST_CONFIGS) {
|
||||
if (config.usesEnvVars) {
|
||||
paths[config.name] = {
|
||||
skillRoot: '$GSTACK_ROOT',
|
||||
localSkillRoot: config.localSkillRoot,
|
||||
binDir: '$GSTACK_BIN',
|
||||
browseDir: '$GSTACK_BROWSE',
|
||||
designDir: '$GSTACK_DESIGN',
|
||||
};
|
||||
} else {
|
||||
const root = `~/${config.globalRoot}`;
|
||||
paths[config.name] = {
|
||||
skillRoot: root,
|
||||
localSkillRoot: config.localSkillRoot,
|
||||
binDir: `${root}/bin`,
|
||||
browseDir: `${root}/browse/dist`,
|
||||
designDir: `${root}/design/dist`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
export const HOST_PATHS: Record<string, HostPaths> = buildHostPaths();
|
||||
|
||||
export interface TemplateContext {
|
||||
skillName: string;
|
||||
|
||||
@@ -367,13 +367,9 @@ Minimum 0 per category.
|
||||
}
|
||||
|
||||
export function generateCoAuthorTrailer(ctx: TemplateContext): string {
|
||||
if (ctx.host === 'codex') {
|
||||
return 'Co-Authored-By: OpenAI Codex <noreply@openai.com>';
|
||||
}
|
||||
if (ctx.host === 'factory') {
|
||||
return 'Co-Authored-By: Factory Droid <droid@users.noreply.github.com>';
|
||||
}
|
||||
return 'Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>';
|
||||
const { getHostConfig } = require('../../hosts/index');
|
||||
const hostConfig = getHostConfig(ctx.host);
|
||||
return hostConfig.coAuthorTrailer || 'Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>';
|
||||
}
|
||||
|
||||
export function generateChangelogWorkflow(_ctx: TemplateContext): string {
|
||||
|
||||
+43
-94
@@ -79,111 +79,60 @@ for (const file of SKILL_FILES) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Codex Skills ───────────────────────────────────────────
|
||||
// ─── External Host Skills (config-driven) ───────────────────
|
||||
|
||||
const AGENTS_DIR = path.join(ROOT, '.agents', 'skills');
|
||||
if (fs.existsSync(AGENTS_DIR)) {
|
||||
console.log('\n Codex Skills (.agents/skills/):');
|
||||
const codexDirs = fs.readdirSync(AGENTS_DIR).sort();
|
||||
let codexCount = 0;
|
||||
let codexMissing = 0;
|
||||
for (const dir of codexDirs) {
|
||||
const skillMd = path.join(AGENTS_DIR, dir, 'SKILL.md');
|
||||
if (fs.existsSync(skillMd)) {
|
||||
codexCount++;
|
||||
const content = fs.readFileSync(skillMd, 'utf-8');
|
||||
// Quick validation: must have frontmatter with name + description only
|
||||
const hasClaude = content.includes('.claude/skills');
|
||||
if (hasClaude) {
|
||||
hasErrors = true;
|
||||
console.log(` \u274c ${dir.padEnd(30)} — contains .claude/skills reference`);
|
||||
import { getExternalHosts } from '../hosts/index';
|
||||
|
||||
for (const hostConfig of getExternalHosts()) {
|
||||
const hostDir = path.join(ROOT, hostConfig.hostSubdir, 'skills');
|
||||
if (fs.existsSync(hostDir)) {
|
||||
console.log(`\n ${hostConfig.displayName} Skills (${hostConfig.hostSubdir}/skills/):`);
|
||||
const dirs = fs.readdirSync(hostDir).sort();
|
||||
let count = 0;
|
||||
let missing = 0;
|
||||
for (const dir of dirs) {
|
||||
const skillMd = path.join(hostDir, dir, 'SKILL.md');
|
||||
if (fs.existsSync(skillMd)) {
|
||||
count++;
|
||||
const content = fs.readFileSync(skillMd, 'utf-8');
|
||||
const hasClaude = content.includes('.claude/skills');
|
||||
if (hasClaude) {
|
||||
hasErrors = true;
|
||||
console.log(` \u274c ${dir.padEnd(30)} — contains .claude/skills reference`);
|
||||
} else {
|
||||
console.log(` \u2705 ${dir.padEnd(30)} — OK`);
|
||||
}
|
||||
} else {
|
||||
console.log(` \u2705 ${dir.padEnd(30)} — OK`);
|
||||
}
|
||||
} else {
|
||||
codexMissing++;
|
||||
hasErrors = true;
|
||||
console.log(` \u274c ${dir.padEnd(30)} — SKILL.md missing`);
|
||||
}
|
||||
}
|
||||
console.log(` Total: ${codexCount} skills, ${codexMissing} missing`);
|
||||
} else {
|
||||
console.log('\n Codex Skills: .agents/skills/ not found (run: bun run gen:skill-docs --host codex)');
|
||||
}
|
||||
|
||||
// ─── Factory Skills ─────────────────────────────────────────
|
||||
|
||||
const FACTORY_DIR = path.join(ROOT, '.factory', 'skills');
|
||||
if (fs.existsSync(FACTORY_DIR)) {
|
||||
console.log('\n Factory Skills (.factory/skills/):');
|
||||
const factoryDirs = fs.readdirSync(FACTORY_DIR).sort();
|
||||
let factoryCount = 0;
|
||||
let factoryMissing = 0;
|
||||
for (const dir of factoryDirs) {
|
||||
const skillMd = path.join(FACTORY_DIR, dir, 'SKILL.md');
|
||||
if (fs.existsSync(skillMd)) {
|
||||
factoryCount++;
|
||||
const content = fs.readFileSync(skillMd, 'utf-8');
|
||||
const hasClaude = content.includes('.claude/skills');
|
||||
if (hasClaude) {
|
||||
missing++;
|
||||
hasErrors = true;
|
||||
console.log(` \u274c ${dir.padEnd(30)} — contains .claude/skills reference`);
|
||||
} else {
|
||||
console.log(` \u2705 ${dir.padEnd(30)} — OK`);
|
||||
console.log(` \u274c ${dir.padEnd(30)} — SKILL.md missing`);
|
||||
}
|
||||
} else {
|
||||
factoryMissing++;
|
||||
hasErrors = true;
|
||||
console.log(` \u274c ${dir.padEnd(30)} — SKILL.md missing`);
|
||||
}
|
||||
console.log(` Total: ${count} skills, ${missing} missing`);
|
||||
} else {
|
||||
console.log(`\n ${hostConfig.displayName} Skills: ${hostConfig.hostSubdir}/skills/ not found (run: bun run gen:skill-docs --host ${hostConfig.name})`);
|
||||
}
|
||||
console.log(` Total: ${factoryCount} skills, ${factoryMissing} missing`);
|
||||
} else {
|
||||
console.log('\n Factory Skills: .factory/skills/ not found (run: bun run gen:skill-docs --host factory)');
|
||||
}
|
||||
|
||||
// ─── Freshness ──────────────────────────────────────────────
|
||||
// ─── Freshness (config-driven) ──────────────────────────────
|
||||
|
||||
console.log('\n Freshness (Claude):');
|
||||
try {
|
||||
execSync('bun run scripts/gen-skill-docs.ts --dry-run', { cwd: ROOT, stdio: 'pipe' });
|
||||
console.log(' \u2705 All Claude generated files are fresh');
|
||||
} catch (err: any) {
|
||||
hasErrors = true;
|
||||
const output = err.stdout?.toString() || '';
|
||||
console.log(' \u274c Claude generated files are stale:');
|
||||
for (const line of output.split('\n').filter((l: string) => l.startsWith('STALE'))) {
|
||||
console.log(` ${line}`);
|
||||
}
|
||||
console.log(' Run: bun run gen:skill-docs');
|
||||
}
|
||||
import { ALL_HOST_CONFIGS } from '../hosts/index';
|
||||
|
||||
console.log('\n Freshness (Codex):');
|
||||
try {
|
||||
execSync('bun run scripts/gen-skill-docs.ts --host codex --dry-run', { cwd: ROOT, stdio: 'pipe' });
|
||||
console.log(' \u2705 All Codex generated files are fresh');
|
||||
} catch (err: any) {
|
||||
hasErrors = true;
|
||||
const output = err.stdout?.toString() || '';
|
||||
console.log(' \u274c Codex generated files are stale:');
|
||||
for (const line of output.split('\n').filter((l: string) => l.startsWith('STALE'))) {
|
||||
console.log(` ${line}`);
|
||||
for (const hostConfig of ALL_HOST_CONFIGS) {
|
||||
const hostFlag = hostConfig.name === 'claude' ? '' : ` --host ${hostConfig.name}`;
|
||||
console.log(`\n Freshness (${hostConfig.displayName}):`);
|
||||
try {
|
||||
execSync(`bun run scripts/gen-skill-docs.ts${hostFlag} --dry-run`, { cwd: ROOT, stdio: 'pipe' });
|
||||
console.log(` \u2705 All ${hostConfig.displayName} generated files are fresh`);
|
||||
} catch (err: any) {
|
||||
hasErrors = true;
|
||||
const output = err.stdout?.toString() || '';
|
||||
console.log(` \u274c ${hostConfig.displayName} generated files are stale:`);
|
||||
for (const line of output.split('\n').filter((l: string) => l.startsWith('STALE'))) {
|
||||
console.log(` ${line}`);
|
||||
}
|
||||
console.log(` Run: bun run gen:skill-docs${hostFlag}`);
|
||||
}
|
||||
console.log(' Run: bun run gen:skill-docs --host codex');
|
||||
}
|
||||
|
||||
console.log('\n Freshness (Factory):');
|
||||
try {
|
||||
execSync('bun run scripts/gen-skill-docs.ts --host factory --dry-run', { cwd: ROOT, stdio: 'pipe' });
|
||||
console.log(' \u2705 All Factory generated files are fresh');
|
||||
} catch (err: any) {
|
||||
hasErrors = true;
|
||||
const output = err.stdout?.toString() || '';
|
||||
console.log(' \u274c Factory generated files are stale:');
|
||||
for (const line of output.split('\n').filter((l: string) => l.startsWith('STALE'))) {
|
||||
console.log(` ${line}`);
|
||||
}
|
||||
console.log(' Run: bun run gen:skill-docs --host factory');
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
Reference in New Issue
Block a user