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));
|
||||
|
||||
Reference in New Issue
Block a user