host-config.ts
267 lines · scripts/host-config.ts at 3f79d81
1/**
2 * Declarative host config system.
3 *
4 * Each supported host (Claude, Codex, Factory, OpenCode, OpenClaw, etc.) is
5 * defined as a typed HostConfig object in hosts/*.ts. This module provides
6 * the interface, loader, and validator.
7 *
8 * Architecture:
9 * hosts/*.ts → hosts/index.ts → host-config.ts (this file)
10 * │ │
11 * └── typed configs ──────────────────→ consumed by gen-skill-docs.ts,
12 * setup (via host-config-export.ts),
13 * skill-check.ts, worktree.ts,
14 * platform-detect, uninstall
15 */
17import type { Model } from './models';
18import { validateModel } from './models';
20export type HostTier = 'full' | 'experimental' | 'instruction-only';
21export const HOST_TIERS: readonly HostTier[] = ['full', 'experimental', 'instruction-only'];
23export interface HostCapabilities {
24 /** Runs shell commands, so bin/ helpers and preambles execute. */
25 toolExecution: boolean;
26 /** 'native': a structured question tool; 'prose': questions are plain text. */
27 questions: 'native' | 'prose';
28 /** Has plan-mode transitions (enter/exit plan mode). */
29 planMode: boolean;
30 /** Can delegate work to sub-agents. */
31 delegation: boolean;
32 /** Can drive gstack's browser (needs toolExecution). */
33 browser: boolean;
34 /** 'enforced': hooks can block a tool call; 'advisory': safety skills only warn. */
35 safetyHooks: 'enforced' | 'advisory';
36}
38export interface HostConfig {
39 /** Unique host identifier (e.g., 'opencode'). Must match filename in hosts/. */
40 name: string;
41 /** Human-readable name for UI/logs (e.g., 'OpenCode'). */
42 displayName: string;
43 /** Binary name for `command -v` detection (e.g., 'opencode'). */
44 cliCommand: string;
45 /** Alternative binary names (e.g., ['droid'] for factory). */
46 cliAliases?: string[];
48 /** Model overlay used when generation does not receive an explicit --model. */
49 defaultModel: Model;
51 // --- Support Contract (docs/ADDING_A_HOST.md "Host tiers and capabilities") ---
52 /**
53 * full: installs, renders, and has a dated certification record (a real
54 * workflow run plus an upgrade from an existing install).
55 * experimental: installs and renders; not certified by a real run.
56 * instruction-only: no install arm; setup prints instructions and changes nothing.
57 */
58 tier: HostTier;
59 /** What the host runtime offers. Rendering and docs must not claim more. */
60 capabilities: HostCapabilities;
62 // --- Path Configuration ---
63 /** Global install path relative to $HOME (e.g., '.config/opencode/skills/gstack'). */
64 globalRoot: string;
65 /** Project-local skill path relative to repo root (e.g., '.opencode/skills/gstack'). */
66 localSkillRoot: string;
67 /** Gitignored directory under repo root for generated docs (e.g., '.opencode'). */
68 hostSubdir: string;
69 /** Whether preamble generates $GSTACK_ROOT env vars (true for non-Claude hosts). */
70 usesEnvVars: boolean;
72 // --- Frontmatter Transformation ---
73 frontmatter: {
74 /** 'allowlist': ONLY keepFields survive. 'denylist': strip listed fields. */
75 mode: 'allowlist' | 'denylist';
76 /** Fields to preserve (allowlist mode only). */
77 keepFields?: string[];
78 /** Fields to remove (denylist mode only). */
79 stripFields?: string[];
80 /** Max chars for description field. null = no limit. */
81 descriptionLimit?: number | null;
82 /** What to do when description exceeds limit. Default: 'error'. */
83 descriptionLimitBehavior?: 'error' | 'truncate' | 'warn';
84 /**
85 * Put a trigger-first lead of at most this many chars on the first line of
86 * the description (lib/skill-triggers.ts withTriggerLead), for hosts whose
87 * skills index shows only the start of a description.
88 */
89 descriptionLead?: number;
90 /** Additional frontmatter fields to inject (host-wide). */
91 extraFields?: Record<string, unknown>;
92 /**
93 * Write the installed directory name (gstack-<skill>) into `name:`. For
94 * hosts that index and de-duplicate skills by frontmatter name, where a
95 * bare `review` would be shadowed by any other `review` skill (#2825).
96 */
97 nameMatchesDirectory?: boolean;
98 /** Rename fields from template (e.g., { 'voice-triggers': 'triggers' }). */
99 renameFields?: Record<string, string>;
100 /** Conditionally add fields based on template frontmatter values. */
101 conditionalFields?: Array<{ if: Record<string, unknown>; add: Record<string, unknown> }>;
102 };
104 // --- Generation ---
105 generation: {
106 /** Whether to create a metadata file alongside skills (always openai.yaml; gen-skill-docs hardcodes the format and reads the template's `codex_default_prompt:` key for its default_prompt). */
107 generateMetadata: boolean;
108 /** Skill directories to exclude from generation for this host. */
109 skipSkills?: string[];
110 /** Skill directories to include (allowlist). Union logic: include minus skip. */
111 includeSkills?: string[];
112 };
114 // --- Content Rewrites ---
115 /** Literal string replacements on generated SKILL.md content. Order matters, replaceAll. */
116 pathRewrites: Array<{ from: string; to: string }>;
117 /** Tool name string replacements on content. */
118 toolRewrites?: Record<string, string>;
119 /** Resolver functions that return empty string for this host. */
120 suppressedResolvers?: string[];
122 // --- Runtime Root ---
123 runtimeRoot: {
124 /** Explicit asset list for global install symlinks (no globs). */
125 globalSymlinks: string[];
126 /** Dir → explicit file list for selective file linking. */
127 globalFiles?: Record<string, string[]>;
128 };
129 // --- Install Behavior ---
130 install: {
131 /** How skills are linked into the host dir. */
132 linkingStrategy: 'real-dir-symlink' | 'symlink-generated';
133 /**
134 * 'harness': setup's arm for this host is `gstack-harness register <host>`
135 * (lib/harness-register.ts): the host's render copied into the harness's own
136 * skills folder with an ownership ledger, not linked into a host skills dir.
137 */
138 registration?: 'harness';
139 };
141 // --- Host-Specific Behavioral Config ---
142 /** Git co-author trailer string. */
143 coAuthorTrailer?: string;
144 /** Learnings implementation: 'full' = cross-project, 'basic' = simple. */
145 learningsMode?: 'full' | 'basic';
146 /** Anti-prompt-injection boundary instruction for cross-model invocations. */
147 boundaryInstruction?: string;
149 // --- Session transcripts ---
150 /**
151 * Where this host keeps its own chat transcripts on disk, read by
152 * /context-restore's no-checkpoint fallback (sections/transcripts.md).
153 * `root` is the path pattern shown to the user; `layout` says how the
154 * current workspace's files are told apart from another project's. Absent:
155 * the host declares no transcript store, and the skill says so and falls
156 * back to the shared record and git.
157 */
158 transcripts?: { root: string; layout: string };
159}
161// --- Validation ---
163const NAME_REGEX = /^[a-z][a-z0-9-]*$/;
164const PATH_REGEX = /^[a-zA-Z0-9_.\/${}~-]+$/;
165const CLI_REGEX = /^[a-z][a-z0-9_-]*$/;
167export function validateHostConfig(config: HostConfig, validResolverNames?: ReadonlySet<string>): string[] {
168 const errors: string[] = [];
170 if (!NAME_REGEX.test(config.name)) {
171 errors.push(`name '${config.name}' must be lowercase alphanumeric with hyphens`);
172 }
173 if (!config.displayName) {
174 errors.push('displayName is required');
175 }
176 if (!CLI_REGEX.test(config.cliCommand)) {
177 errors.push(`cliCommand '${config.cliCommand}' contains invalid characters`);
178 }
179 if (config.cliAliases) {
180 for (const alias of config.cliAliases) {
181 if (!CLI_REGEX.test(alias)) {
182 errors.push(`cliAlias '${alias}' contains invalid characters`);
183 }
184 }
185 }
186 if (!HOST_TIERS.includes(config.tier)) {
187 errors.push(`tier '${config.tier}' must be one of ${HOST_TIERS.join(', ')}`);
188 }
189 const caps = config.capabilities;
190 if (!caps) {
191 errors.push('capabilities are required');
192 } else {
193 if (caps.browser && !caps.toolExecution) errors.push('capabilities.browser requires toolExecution');
194 if (!['native', 'prose'].includes(caps.questions)) errors.push(`capabilities.questions must be 'native' or 'prose'`);
195 if (!['enforced', 'advisory'].includes(caps.safetyHooks)) errors.push(`capabilities.safetyHooks must be 'enforced' or 'advisory'`);
196 }
197 if (config.install.registration === 'harness' && config.tier !== 'experimental') {
198 errors.push('install.registration harness needs tier experimental (setup registers it through gstack-harness)');
199 }
200 const modelError = validateModel(config.defaultModel);
201 if (modelError) {
202 errors.push(`defaultModel ${modelError}`);
203 }
204 if (!PATH_REGEX.test(config.globalRoot)) {
205 errors.push(`globalRoot '${config.globalRoot}' contains invalid characters`);
206 }
207 if (!PATH_REGEX.test(config.localSkillRoot)) {
208 errors.push(`localSkillRoot '${config.localSkillRoot}' contains invalid characters`);
209 }
210 if (!PATH_REGEX.test(config.hostSubdir)) {
211 errors.push(`hostSubdir '${config.hostSubdir}' contains invalid characters`);
212 }
213 if (!['allowlist', 'denylist'].includes(config.frontmatter.mode)) {
214 errors.push(`frontmatter.mode must be 'allowlist' or 'denylist'`);
215 }
216 if (!['real-dir-symlink', 'symlink-generated'].includes(config.install.linkingStrategy)) {
217 errors.push(`install.linkingStrategy must be 'real-dir-symlink' or 'symlink-generated'`);
218 }
220 // Cross-check suppressedResolvers against the known resolver names (injected to avoid a
221 // circular import on the resolver registry). A typo would otherwise silently no-op: the
222 // generator short-circuits suppressed names before the "unknown placeholder" throw, so an
223 // unknown entry never surfaces at generation time either.
224 if (validResolverNames && config.suppressedResolvers) {
225 for (const name of config.suppressedResolvers) {
226 if (!validResolverNames.has(name)) {
227 errors.push(`suppressedResolvers entry '${name}' is not a known resolver`);
228 }
229 }
230 }
232 return errors;
233}
235export function validateAllConfigs(configs: HostConfig[], validResolverNames?: ReadonlySet<string>): string[] {
236 const errors: string[] = [];
238 // Per-config validation
239 for (const config of configs) {
240 const configErrors = validateHostConfig(config, validResolverNames);
241 errors.push(...configErrors.map(e => `[${config.name}] ${e}`));
242 }
244 // Cross-config uniqueness checks
245 const hostSubdirs = new Map<string, string>();
246 const globalRoots = new Map<string, string>();
247 const names = new Map<string, string>();
249 for (const config of configs) {
250 if (names.has(config.name)) {
251 errors.push(`Duplicate name '${config.name}' (also used by ${names.get(config.name)})`);
252 }
253 names.set(config.name, config.name);
255 if (hostSubdirs.has(config.hostSubdir)) {
256 errors.push(`Duplicate hostSubdir '${config.hostSubdir}' (${config.name} and ${hostSubdirs.get(config.hostSubdir)})`);
257 }
258 hostSubdirs.set(config.hostSubdir, config.name);
260 if (globalRoots.has(config.globalRoot)) {
261 errors.push(`Duplicate globalRoot '${config.globalRoot}' (${config.name} and ${globalRoots.get(config.globalRoot)})`);
262 }
263 globalRoots.set(config.globalRoot, config.name);
264 }
266 return errors;
267}