forked from garrytan/gstack
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhost-config.ts
More file actions
190 lines (169 loc) · 7.35 KB
/
Copy pathhost-config.ts
File metadata and controls
190 lines (169 loc) · 7.35 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
/**
* 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[];
/** Skill directories to include (allowlist). Union logic: include minus skip. */
includeSkills?: 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;
}