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 +21 -0
- package/README.md +107 -0
- package/examples/agents/example-reviewer/checklist.md +8 -0
- package/examples/agents/example-reviewer.md +17 -0
- package/examples/skills/hello-greeter/SKILL.md +20 -0
- package/examples/skills/hello-greeter/greeting.txt +1 -0
- package/examples/skills/hello-greeter/reference.md +7 -0
- package/package.json +46 -0
- package/src/cli.js +155 -0
- package/src/compile.js +306 -0
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,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
|
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
|
+
}
|