diff --git a/scripts/host-config.ts b/scripts/host-config.ts new file mode 100644 index 00000000..240fb0d4 --- /dev/null +++ b/scripts/host-config.ts @@ -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; + /** Rename fields from template (e.g., { 'voice-triggers': 'triggers' }). */ + renameFields?: Record; + /** Conditionally add fields based on template frontmatter values. */ + conditionalFields?: Array<{ if: Record; add: Record }>; + }; + + // --- 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; + /** 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; + }; + /** 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; + /** 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(); + const globalRoots = new Map(); + const names = new Map(); + + 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; +}