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:
Garry Tan
2026-04-04 15:32:20 -07:00
committed by GitHub
parent 447851452a
commit 04b709d91a
34 changed files with 8529 additions and 274 deletions
+188
View File
@@ -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;
}