polyskill 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexander Penkin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # polyskill
2
+
3
+ Author your AI-coding-agent **skills and subagents once**, compile them to
4
+ every harness. One Markdown source tree → Claude Code and Codex config trees,
5
+ with **inline per-target macros** so a single file can say different things to
6
+ different agents.
7
+
8
+ ```bash
9
+ npx --yes polyskill --source ./src-agents --out .
10
+ ```
11
+
12
+ ## Why
13
+
14
+ Most "sync my agent rules everywhere" tools broadcast the *same* body to every
15
+ target and only vary the frontmatter. That breaks down the moment a skill needs
16
+ to say something genuinely different per harness — e.g. Claude Code has
17
+ `Explore` / `general-purpose` subagents and an `ExitPlanMode` tool that Codex
18
+ does not. polyskill solves that with inline macros:
19
+
20
+ ```markdown
21
+ <claude>On Claude Code, dispatch read-only checks with `subagent_type: "Explore"`.</claude>
22
+ <codex>On Codex, use the default worker — there is no Explore / general-purpose split.</codex>
23
+ ```
24
+
25
+ Everything outside a macro tag is shared verbatim. Tags are expanded only inside
26
+ `.md` files; every other file is copied byte-for-byte.
27
+
28
+ ## Usage
29
+
30
+ Zero install — run it with `npx`. Always pass `--yes` in scripts/CI so npx does
31
+ not stall on its install prompt, and pin an exact version for reproducible
32
+ builds:
33
+
34
+ ```bash
35
+ npx --yes polyskill@0.1.0 # walk up for polyskill.config.json
36
+ npx --yes polyskill --source ./a --out ./dist --target claude
37
+ ```
38
+
39
+ Before publishing, you can run straight from GitHub:
40
+
41
+ ```bash
42
+ npx --yes github:SSS135/polyskill --source ./a --out .
43
+ ```
44
+
45
+ ### Source layout
46
+
47
+ ```
48
+ <source>/
49
+ skills/<name>/SKILL.md # required; bundled files copy verbatim
50
+ skills/<name>/<other files>
51
+ agents/<name>.md # single-file subagent
52
+ agents/<name>/<helpers> # optional sibling folder
53
+ ```
54
+
55
+ ### Config
56
+
57
+ With no flags, polyskill walks up from the current directory looking for
58
+ `polyskill.config.json`:
59
+
60
+ ```json
61
+ {
62
+ "source": "./src-agents",
63
+ "targets": {
64
+ "claude": { "kind": "claude", "out": "." },
65
+ "codex": { "kind": "codex", "out": "." }
66
+ }
67
+ }
68
+ ```
69
+
70
+ Relative paths in the config resolve against the config file's directory;
71
+ relative paths passed as flags resolve against the current directory. Flags
72
+ override config (`--source`, `--out`, `--target`). With no config file present,
73
+ `--source` and `--out` are required and all built-in kinds are emitted.
74
+
75
+ ## Targets
76
+
77
+ | Kind | Skills | Subagents |
78
+ | ------ | ------------------------ | ---------------------- |
79
+ | claude | `<out>/.claude/skills/` | `<out>/.claude/agents/`|
80
+ | codex | `<out>/.agents/skills/` | `<out>/.codex/agents/` |
81
+
82
+ ### Codex conversions
83
+
84
+ - `SKILL.md` frontmatter is shrunk to `name` + `description`.
85
+ - Subagent `.md` → TOML: the body becomes `developer_instructions`;
86
+ `mcpServers` → `mcp_servers`, `effort` → `model_reasoning_effort`.
87
+ - `model` and other Claude-only fields (`tools`, `color`, `hooks`, …) are
88
+ dropped — a Claude model name is not a valid Codex model id. Set a
89
+ Codex-specific value in a `<codex>` frontmatter block if you need one.
90
+
91
+ The build is overwrite-only: unchanged files are left untouched, and orphans
92
+ from deleted sources are not auto-cleaned.
93
+
94
+ ## Development
95
+
96
+ ```bash
97
+ npm install
98
+ npm test # node:test — golden byte-identical fixtures + unit tests
99
+ npm run typecheck # tsc --noEmit over JSDoc-typed source (dev-only; runtime is zero-dep)
100
+ ```
101
+
102
+ The runtime has **no dependencies** — it uses only Node built-ins and needs
103
+ Node ≥ 20.
104
+
105
+ ## License
106
+
107
+ MIT
@@ -0,0 +1,8 @@
1
+ # Review checklist
2
+
3
+ A sibling file next to `example-reviewer.md`. Sibling folders are copied into
4
+ the target's agent directory, with macros expanded in `.md` files.
5
+
6
+ - Off-by-one and boundary conditions
7
+ - Unhandled error paths
8
+ - Resource leaks
@@ -0,0 +1,17 @@
1
+ ---
2
+ name: example-reviewer
3
+ description: Reviews a diff for obvious mistakes.
4
+ tools: Read, Grep, Bash
5
+ model: sonnet
6
+ mcpServers: my-server
7
+ effort: high
8
+ color: blue
9
+ ---
10
+
11
+ You are a focused code reviewer. Read the diff and report only real issues —
12
+ no style nits, no praise.
13
+
14
+ <claude>Use the Read and Grep tools to inspect surrounding code.</claude>
15
+ <codex>Use the available read/search capabilities to inspect surrounding code.</codex>
16
+
17
+ See `checklist.md` for the review checklist.
@@ -0,0 +1,20 @@
1
+ ---
2
+ name: hello-greeter
3
+ description: Demonstrates an author-once skill compiled to multiple harnesses.
4
+ allowed-tools: Read, Grep
5
+ model: inherit
6
+ ---
7
+
8
+ # Hello Greeter
9
+
10
+ A tiny example skill, authored once and emitted to every target. Everything
11
+ outside a macro tag is shared verbatim.
12
+
13
+ <claude>
14
+ On Claude Code, dispatch read-only checks with `subagent_type: "Explore"`.
15
+ </claude>
16
+ <codex>
17
+ On Codex, use the default worker — there is no Explore / general-purpose split.
18
+ </codex>
19
+
20
+ See `reference.md` for the canned greetings; `greeting.txt` ships verbatim.
@@ -0,0 +1 @@
1
+ hello, world
@@ -0,0 +1,7 @@
1
+ # Greetings reference
2
+
3
+ A non-`SKILL.md` markdown file. Macros still expand here, but the Codex
4
+ frontmatter shrink does not apply (it only touches `SKILL.md`).
5
+
6
+ <claude>Claude users see this line.</claude>
7
+ <codex>Codex users see this line.</codex>
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "polyskill",
3
+ "version": "0.1.0",
4
+ "description": "Compile author-once skills and subagents into per-harness agent configs (Claude Code, Codex), with inline per-target macros.",
5
+ "type": "module",
6
+ "bin": {
7
+ "polyskill": "src/cli.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=20"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "examples",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "test": "node --test",
20
+ "typecheck": "tsc --noEmit"
21
+ },
22
+ "keywords": [
23
+ "claude-code",
24
+ "codex",
25
+ "skills",
26
+ "subagents",
27
+ "agents",
28
+ "ai",
29
+ "cli",
30
+ "codegen"
31
+ ],
32
+ "license": "MIT",
33
+ "author": "SSS135 (https://github.com/SSS135)",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/SSS135/polyskill.git"
37
+ },
38
+ "homepage": "https://github.com/SSS135/polyskill#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/SSS135/polyskill/issues"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^24",
44
+ "typescript": "^5"
45
+ }
46
+ }
package/src/cli.js ADDED
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ /**
4
+ * polyskill CLI.
5
+ *
6
+ * Compiles an "author-once" source tree (skills/ + agents/) into per-target
7
+ * agent-config trees. Driven by a JSON config, by flags, or both — flags
8
+ * override config, and the tool runs with no config file at all when given
9
+ * --source/--out directly.
10
+ */
11
+ import { existsSync, readFileSync } from 'node:fs';
12
+ import path from 'node:path';
13
+ import { parseArgs } from 'node:util';
14
+ import { buildTarget, KNOWN_KINDS, Report } from './compile.js';
15
+
16
+ const CONFIG_NAME = 'polyskill.config.json';
17
+
18
+ const USAGE = `polyskill — compile author-once skills/subagents to per-harness config trees
19
+
20
+ Usage:
21
+ polyskill [--config <path>] [--source <dir>] [--out <dir>] [--target <id> ...]
22
+
23
+ Options:
24
+ --config <path> JSON config file. If omitted, walk up from the cwd looking
25
+ for ${CONFIG_NAME}; if none is found, run from flags alone.
26
+ --source <dir> Source root holding skills/ and agents/. Overrides config.
27
+ --out <dir> Output root. Overrides every target's configured out.
28
+ --target <id> Target to build (repeatable). Defaults to every target in
29
+ the config, or to all built-in kinds (${KNOWN_KINDS.join(', ')})
30
+ in no-config mode.
31
+ -h, --help Show this help.
32
+
33
+ Config shape:
34
+ {
35
+ "source": "./.universal-agent",
36
+ "targets": {
37
+ "claude": { "kind": "claude", "out": "../.." },
38
+ "codex": { "kind": "codex", "out": "../.." }
39
+ }
40
+ }
41
+ Relative paths in the config resolve against the config file's directory;
42
+ relative paths passed as flags resolve against the current directory.
43
+ `;
44
+
45
+ /** @param {string} msg */
46
+ function fail(msg) {
47
+ process.stderr.write(`${msg}\n`);
48
+ return 2;
49
+ }
50
+
51
+ /** @param {string} startDir @returns {string|null} */
52
+ function findConfig(startDir) {
53
+ let dir = startDir;
54
+ for (;;) {
55
+ const candidate = path.join(dir, CONFIG_NAME);
56
+ if (existsSync(candidate)) return candidate;
57
+ const parent = path.dirname(dir);
58
+ if (parent === dir) return null;
59
+ dir = parent;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * @param {string[]} argv
65
+ * @returns {number} process exit code
66
+ */
67
+ export function main(argv) {
68
+ let values;
69
+ try {
70
+ ({ values } = parseArgs({
71
+ args: argv,
72
+ options: {
73
+ config: { type: 'string' },
74
+ source: { type: 'string' },
75
+ out: { type: 'string' },
76
+ target: { type: 'string', multiple: true },
77
+ help: { type: 'boolean', short: 'h' },
78
+ },
79
+ allowPositionals: false,
80
+ }));
81
+ } catch (err) {
82
+ return fail(`error: ${err instanceof Error ? err.message : String(err)}`);
83
+ }
84
+
85
+ if (values.help) {
86
+ process.stdout.write(USAGE);
87
+ return 0;
88
+ }
89
+
90
+ const cwd = process.cwd();
91
+ const flagTargets = /** @type {string[]} */ (values.target ?? []);
92
+
93
+ // Resolve the config path: explicit flag, else walk-up discovery, else none.
94
+ /** @type {string|null} */
95
+ let configPath = null;
96
+ if (values.config) {
97
+ configPath = path.resolve(cwd, values.config);
98
+ if (!existsSync(configPath)) return fail(`error: config not found: ${configPath}`);
99
+ } else {
100
+ configPath = findConfig(cwd);
101
+ }
102
+
103
+ /** @type {Array<{ id: string, kind: string, sourceRoot: string, outRoot: string }>} */
104
+ const plan = [];
105
+
106
+ if (configPath) {
107
+ let cfg;
108
+ try {
109
+ cfg = JSON.parse(readFileSync(configPath, 'utf-8'));
110
+ } catch (err) {
111
+ return fail(`error: invalid JSON in ${configPath}: ${err instanceof Error ? err.message : String(err)}`);
112
+ }
113
+ const base = path.dirname(configPath);
114
+ const targets = cfg.targets ?? {};
115
+ const ids = Object.keys(targets);
116
+ if (ids.length === 0) return fail(`error: no "targets" defined in ${configPath}`);
117
+
118
+ const sourceRoot = values.source
119
+ ? path.resolve(cwd, values.source)
120
+ : path.resolve(base, cfg.source ?? '.');
121
+
122
+ const selected = flagTargets.length ? flagTargets : ids;
123
+ for (const id of selected) {
124
+ if (!(id in targets)) return fail(`error: unknown target: ${id}`);
125
+ const t = targets[id];
126
+ if (!KNOWN_KINDS.includes(t.kind)) return fail(`error: target ${id}: unknown kind '${t.kind}'`);
127
+ const outRoot = values.out
128
+ ? path.resolve(cwd, values.out)
129
+ : path.resolve(base, t.out ?? '.');
130
+ plan.push({ id, kind: t.kind, sourceRoot, outRoot });
131
+ }
132
+ } else {
133
+ // No-config mode: everything comes from flags.
134
+ if (!values.source || !values.out) {
135
+ return fail(`error: no ${CONFIG_NAME} found; --source and --out are required.\n\n${USAGE}`);
136
+ }
137
+ const sourceRoot = path.resolve(cwd, values.source);
138
+ const outRoot = path.resolve(cwd, values.out);
139
+ const selected = flagTargets.length ? flagTargets : KNOWN_KINDS;
140
+ for (const id of selected) {
141
+ if (!KNOWN_KINDS.includes(id)) return fail(`error: unknown target kind: ${id} (expected one of ${KNOWN_KINDS.join(', ')})`);
142
+ plan.push({ id, kind: id, sourceRoot, outRoot });
143
+ }
144
+ }
145
+
146
+ const report = new Report();
147
+ for (const t of plan) {
148
+ process.stdout.write(`[${t.id}] kind=${t.kind} -> ${t.outRoot}\n`);
149
+ buildTarget(t.sourceRoot, t.kind, t.outRoot, report);
150
+ }
151
+ report.emit();
152
+ return 0;
153
+ }
154
+
155
+ process.exit(main(process.argv.slice(2)));
package/src/compile.js ADDED
@@ -0,0 +1,306 @@
1
+ // @ts-check
2
+ /**
3
+ * polyskill core compiler.
4
+ *
5
+ * Reads "author-once" Claude-flavored skills and subagents, expands per-kind
6
+ * macros (<claude>...</claude>, <codex>...</codex>), and emits one config tree
7
+ * per target. Behavior is a faithful port of the original Python build.py:
8
+ * same macro pass, same line-based frontmatter parse, same TOML escaping, and
9
+ * the same overwrite-only / no-orphan-cleanup contract.
10
+ */
11
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
12
+ import path from 'node:path';
13
+
14
+ export const KNOWN_KINDS = ['claude', 'codex'];
15
+
16
+ /** Frontmatter fields that survive the Codex skill shrinkage step. */
17
+ const CODEX_SKILL_KEEP = ['name', 'description'];
18
+
19
+ /**
20
+ * Claude subagent frontmatter -> Codex TOML field renames.
21
+ * @type {Record<string, string>}
22
+ */
23
+ const TOML_RENAMES = { mcpServers: 'mcp_servers', effort: 'model_reasoning_effort' };
24
+
25
+ /**
26
+ * Claude subagent frontmatter fields that never reach the Codex TOML.
27
+ * `model` is intentionally dropped — a Claude model name (opus/sonnet) is not a
28
+ * valid Codex model id, so passing it through would be wrong, not lossless. A
29
+ * Codex-specific model belongs in a <codex> frontmatter block.
30
+ */
31
+ const TOML_DROP = new Set([
32
+ 'model', 'tools', 'disallowedTools', 'permissionMode', 'hooks',
33
+ 'memory', 'isolation', 'background', 'initialPrompt', 'color',
34
+ ]);
35
+
36
+ const TAG_RE = /<(claude|codex)>([\s\S]*?)<\/\1>/g;
37
+ const FRONT_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/;
38
+ const YAML_LINE_RE = /^([A-Za-z_][\w-]*)\s*:\s?(.*)$/;
39
+
40
+ export class Report {
41
+ constructor() {
42
+ /** @type {string[]} */ this.written = [];
43
+ /** @type {string[]} */ this.warnings = [];
44
+ /** @type {string[]} */ this.dropped = [];
45
+ }
46
+ emit() {
47
+ process.stdout.write(`wrote ${this.written.length} file(s)\n`);
48
+ for (const w of this.warnings) process.stdout.write(`WARN: ${w}\n`);
49
+ for (const d of this.dropped) process.stdout.write(`drop: ${d}\n`);
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Keep the body of macro tags matching `kind`; drop the rest.
55
+ * @param {string} text
56
+ * @param {string} kind
57
+ * @returns {string}
58
+ */
59
+ export function processMacros(text, kind) {
60
+ return text.replace(TAG_RE, (_m, tagKind, body) => (tagKind === kind ? body : ''));
61
+ }
62
+
63
+ /**
64
+ * Split a leading `---` frontmatter block from the body.
65
+ * @param {string} text
66
+ * @returns {{ fields: Record<string,string> | null, raw: string, body: string }}
67
+ */
68
+ export function splitFrontmatter(text) {
69
+ const stripped = text.replace(/^[\r\n]+/, '');
70
+ const leadingWs = text.slice(0, text.length - stripped.length);
71
+ const m = FRONT_RE.exec(stripped);
72
+ if (!m) return { fields: null, raw: '', body: text };
73
+ const raw = stripped.slice(0, m[0].length);
74
+ const body = stripped.slice(m[0].length);
75
+ /** @type {Record<string,string>} */
76
+ const fields = {};
77
+ for (const line of m[1].split(/\r\n|\r|\n/)) {
78
+ if (!line.trim()) continue;
79
+ const fm = YAML_LINE_RE.exec(line);
80
+ if (fm) fields[fm[1]] = fm[2];
81
+ }
82
+ return { fields, raw: leadingWs + raw, body };
83
+ }
84
+
85
+ /**
86
+ * Render a frontmatter block from ordered key/value pairs.
87
+ * @param {Record<string,string>} fields
88
+ * @returns {string}
89
+ */
90
+ export function emitFrontmatter(fields) {
91
+ const lines = ['---'];
92
+ for (const [k, v] of Object.entries(fields)) lines.push(v ? `${k}: ${v}` : `${k}:`);
93
+ lines.push('---');
94
+ return lines.join('\n') + '\n';
95
+ }
96
+
97
+ /** @param {string} s @returns {string} */
98
+ export function tomlBasic(s) {
99
+ const escaped = s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
100
+ return `"${escaped}"`;
101
+ }
102
+
103
+ /** @param {string} s @returns {string} */
104
+ export function tomlMultiline(s) {
105
+ let out = s.replace(/\\/g, '\\\\').replaceAll('"""', '\\"""');
106
+ if (!out.endsWith('\n')) out += '\n';
107
+ return `"""\n${out}"""`;
108
+ }
109
+
110
+ // ---------- fs helpers ----------
111
+
112
+ /** @param {string} dir @returns {string[]} absolute file paths, recursive, sorted */
113
+ function walkFiles(dir) {
114
+ /** @type {string[]} */
115
+ const out = [];
116
+ /** @param {string} d */
117
+ const rec = (d) => {
118
+ for (const name of readdirSync(d)) {
119
+ const full = path.join(d, name);
120
+ if (statSync(full).isDirectory()) rec(full);
121
+ else out.push(full);
122
+ }
123
+ };
124
+ rec(dir);
125
+ return out.sort();
126
+ }
127
+
128
+ /** @param {string} p @returns {boolean} */
129
+ function isDir(p) {
130
+ return existsSync(p) && statSync(p).isDirectory();
131
+ }
132
+
133
+ /**
134
+ * Read a text file with universal-newline normalization (CRLF/CR -> LF), so
135
+ * output matches the Python tool, whose text-mode reads normalize newlines.
136
+ * Verbatim/binary copies deliberately do NOT go through here.
137
+ * @param {string} p @returns {string}
138
+ */
139
+ function readText(p) {
140
+ return readFileSync(p, 'utf-8').replace(/\r\n?/g, '\n');
141
+ }
142
+
143
+ /** @param {string} dest @param {Buffer} data @param {Report} report */
144
+ function writeBytesIfChanged(dest, data, report) {
145
+ mkdirSync(path.dirname(dest), { recursive: true });
146
+ if (existsSync(dest) && readFileSync(dest).equals(data)) return;
147
+ writeFileSync(dest, data);
148
+ report.written.push(dest);
149
+ }
150
+
151
+ /** @param {string} dest @param {string} text @param {Report} report */
152
+ function writeIfChanged(dest, text, report) {
153
+ writeBytesIfChanged(dest, Buffer.from(text, 'utf-8'), report);
154
+ }
155
+
156
+ /** @param {string} src @param {string} dest @param {string} kind @param {Report} report */
157
+ function copyMdWithMacros(src, dest, kind, report) {
158
+ writeIfChanged(dest, processMacros(readText(src), kind), report);
159
+ }
160
+
161
+ /** @param {string} src @param {string} dest @param {Report} report */
162
+ function copyVerbatim(src, dest, report) {
163
+ writeBytesIfChanged(dest, readFileSync(src), report);
164
+ }
165
+
166
+ // ---------- claude target ----------
167
+
168
+ /** @param {string} srcDir @param {string} outDir @param {Report} report */
169
+ function emitClaudeSkill(srcDir, outDir, report) {
170
+ for (const f of walkFiles(srcDir)) {
171
+ const dest = path.join(outDir, path.relative(srcDir, f));
172
+ if (f.endsWith('.md')) copyMdWithMacros(f, dest, 'claude', report);
173
+ else copyVerbatim(f, dest, report);
174
+ }
175
+ }
176
+
177
+ /**
178
+ * @param {string} srcFile @param {string|null} siblingDir
179
+ * @param {string} outFile @param {string} outSibling @param {Report} report
180
+ */
181
+ function emitClaudeAgent(srcFile, siblingDir, outFile, outSibling, report) {
182
+ copyMdWithMacros(srcFile, outFile, 'claude', report);
183
+ if (siblingDir && isDir(siblingDir)) {
184
+ for (const f of walkFiles(siblingDir)) {
185
+ const dest = path.join(outSibling, path.relative(siblingDir, f));
186
+ if (f.endsWith('.md')) copyMdWithMacros(f, dest, 'claude', report);
187
+ else copyVerbatim(f, dest, report);
188
+ }
189
+ }
190
+ }
191
+
192
+ // ---------- codex target ----------
193
+
194
+ /** @param {string} srcDir @param {string} outDir @param {Report} report */
195
+ function emitCodexSkill(srcDir, outDir, report) {
196
+ for (const f of walkFiles(srcDir)) {
197
+ const dest = path.join(outDir, path.relative(srcDir, f));
198
+ if (path.basename(f) === 'SKILL.md') {
199
+ const text = processMacros(readText(f), 'codex');
200
+ const { fields, body } = splitFrontmatter(text);
201
+ if (fields === null) {
202
+ report.warnings.push(`${f}: no frontmatter after macro pass`);
203
+ writeIfChanged(dest, text, report);
204
+ continue;
205
+ }
206
+ /** @type {Record<string,string>} */
207
+ const kept = {};
208
+ for (const k of CODEX_SKILL_KEEP) if (k in fields) kept[k] = fields[k];
209
+ for (const k of Object.keys(fields)) {
210
+ if (!CODEX_SKILL_KEEP.includes(k)) report.dropped.push(`${f}: frontmatter '${k}'`);
211
+ }
212
+ writeIfChanged(dest, emitFrontmatter(kept) + body, report);
213
+ } else if (f.endsWith('.md')) {
214
+ copyMdWithMacros(f, dest, 'codex', report);
215
+ } else {
216
+ copyVerbatim(f, dest, report);
217
+ }
218
+ }
219
+ }
220
+
221
+ /**
222
+ * @param {string} srcFile @param {string|null} siblingDir
223
+ * @param {string} outFile @param {string} outSibling @param {Report} report
224
+ */
225
+ function emitCodexAgent(srcFile, siblingDir, outFile, outSibling, report) {
226
+ const text = processMacros(readText(srcFile), 'codex');
227
+ let { fields, body } = splitFrontmatter(text);
228
+ if (fields === null) {
229
+ report.warnings.push(`${srcFile}: no frontmatter after macro pass`);
230
+ fields = {};
231
+ }
232
+ const lines = [];
233
+ const nameVal = fields.name ?? path.basename(srcFile, '.md');
234
+ const descVal = fields.description ?? '';
235
+ lines.push(`name = ${tomlBasic(nameVal)}`);
236
+ lines.push(`description = ${tomlBasic(descVal)}`);
237
+ for (const [k, v] of Object.entries(fields)) {
238
+ if (k === 'name' || k === 'description') continue;
239
+ if (TOML_DROP.has(k)) {
240
+ report.dropped.push(`${srcFile}: frontmatter '${k}'`);
241
+ continue;
242
+ }
243
+ const outKey = TOML_RENAMES[k] ?? k;
244
+ lines.push(`${outKey} = ${tomlBasic(v)}`);
245
+ }
246
+ lines.push(`developer_instructions = ${tomlMultiline(body)}`);
247
+ writeIfChanged(outFile, lines.join('\n') + '\n', report);
248
+
249
+ if (siblingDir && isDir(siblingDir)) {
250
+ for (const f of walkFiles(siblingDir)) {
251
+ const dest = path.join(outSibling, path.relative(siblingDir, f));
252
+ if (f.endsWith('.md')) copyMdWithMacros(f, dest, 'codex', report);
253
+ else copyVerbatim(f, dest, report);
254
+ }
255
+ }
256
+ }
257
+
258
+ // ---------- driver ----------
259
+
260
+ /**
261
+ * Compile one source tree into one target's output tree.
262
+ * @param {string} sourceRoot absolute path holding skills/ and agents/
263
+ * @param {string} kind 'claude' | 'codex'
264
+ * @param {string} outRoot absolute output root
265
+ * @param {Report} report
266
+ */
267
+ export function buildTarget(sourceRoot, kind, outRoot, report) {
268
+ const skillsSrc = path.join(sourceRoot, 'skills');
269
+ const agentsSrc = path.join(sourceRoot, 'agents');
270
+
271
+ let skillsOut, agentsOut;
272
+ if (kind === 'claude') {
273
+ skillsOut = path.join(outRoot, '.claude', 'skills');
274
+ agentsOut = path.join(outRoot, '.claude', 'agents');
275
+ } else if (kind === 'codex') {
276
+ skillsOut = path.join(outRoot, '.agents', 'skills');
277
+ agentsOut = path.join(outRoot, '.codex', 'agents');
278
+ } else {
279
+ throw new Error(`unknown kind: ${kind}`);
280
+ }
281
+
282
+ if (isDir(skillsSrc)) {
283
+ for (const name of readdirSync(skillsSrc).sort()) {
284
+ const skillDir = path.join(skillsSrc, name);
285
+ if (!isDir(skillDir)) continue;
286
+ const dest = path.join(skillsOut, name);
287
+ if (kind === 'claude') emitClaudeSkill(skillDir, dest, report);
288
+ else emitCodexSkill(skillDir, dest, report);
289
+ }
290
+ }
291
+
292
+ if (isDir(agentsSrc)) {
293
+ for (const entry of readdirSync(agentsSrc).sort()) {
294
+ const full = path.join(agentsSrc, entry);
295
+ if (!entry.endsWith('.md') || isDir(full)) continue;
296
+ const name = path.basename(entry, '.md');
297
+ const sibling = path.join(agentsSrc, name);
298
+ const siblingArg = isDir(sibling) ? sibling : null;
299
+ if (kind === 'claude') {
300
+ emitClaudeAgent(full, siblingArg, path.join(agentsOut, `${name}.md`), path.join(agentsOut, name), report);
301
+ } else {
302
+ emitCodexAgent(full, siblingArg, path.join(agentsOut, `${name}.toml`), path.join(agentsOut, name), report);
303
+ }
304
+ }
305
+ }
306
+ }