@khanhcan148/mk 0.1.20 → 0.1.22

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.
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * convert-hooks-to-codex.js
4
+ *
5
+ * Reads `.claude/settings.json` → extracts hooks array.
6
+ * Copies `.claude/hooks/` (including lib/) to `.codex/hooks/`.
7
+ * Emits a Codex-compatible TOML config fragment with 6 hook blocks.
8
+ *
9
+ * Hook mapping (Claude → Codex):
10
+ * ┌──────────────────────────────┬──────────────┬─────────────────────┬──────────────────────────────────────────────┐
11
+ * │ Claude hook │ Codex event │ matcher │ command │
12
+ * ├──────────────────────────────┼──────────────┼─────────────────────┼──────────────────────────────────────────────┤
13
+ * │ PreToolUse Edit/Write │ PreToolUse │ ^(apply_patch)$ │ …/codex-payload-adapter.cjs privacy-block.cjs│
14
+ * │ PreToolUse Bash │ PreToolUse │ ^(Bash)$ │ …/codex-payload-adapter.cjs credential-scan │
15
+ * │ PostToolUse Edit/Write │ PostToolUse │ ^(apply_patch)$ │ …/codex-payload-adapter.cjs comment-replace │
16
+ * │ PostToolUse test-match Bash │ PostToolUse │ ^(Bash)$ │ …/codex-payload-adapter.cjs test-match.cjs │
17
+ * │ SessionStart │ SessionStart │ clear │ node ./.codex/hooks/session-init.cjs │
18
+ * │ wiki-update-reminder │ PostToolUse │ ^$ │ node ./.codex/hooks/wiki-update-reminder.cjs │
19
+ * └──────────────────────────────┴──────────────┴─────────────────────┴──────────────────────────────────────────────┘
20
+ *
21
+ * Dropped (emit stderr warning per dropped hook):
22
+ * - team-context-inject (SubagentStart — no Codex equivalent)
23
+ * - teammate-idle-handler (TeammateIdle — no Codex equivalent)
24
+ * - task-completed-handler (TaskCompleted — no Codex equivalent)
25
+ *
26
+ * Returns: { added: number, dropped: string[], warnings: string[] }
27
+ *
28
+ * Uses src/lib/toml-emit.js for TOML emission (no @iarna/toml runtime dep).
29
+ */
30
+
31
+ import { existsSync, cpSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
32
+ import { join, resolve, dirname, basename } from 'node:path';
33
+ import { fileURLToPath } from 'node:url';
34
+ import { emitToml, emitArrayOfTables, emitTable } from '../src/lib/toml-emit.js';
35
+
36
+ const __filename = fileURLToPath(import.meta.url);
37
+ const __dirname = dirname(__filename);
38
+ const PACKAGE_ROOT = resolve(__dirname, '..');
39
+
40
+ // Dropped Claude-only hook event types (no Codex equivalent)
41
+ const DROPPED_HOOKS = [
42
+ { source: 'SubagentStart', name: 'team-context-inject' },
43
+ { source: 'TeammateIdle', name: 'teammate-idle-handler' },
44
+ { source: 'TaskCompleted', name: 'task-completed-handler' },
45
+ ];
46
+
47
+ /**
48
+ * The 6 canonical Codex hook blocks to emit.
49
+ * Order is stable/deterministic (used for byte-identical output guarantee).
50
+ */
51
+ function buildHookBlocks() {
52
+ return {
53
+ 'hooks.PreToolUse': {
54
+ __type: 'array',
55
+ items: [
56
+ {
57
+ matcher: '^(apply_patch)$',
58
+ command: 'node ./.codex/hooks/lib/codex-payload-adapter.cjs privacy-block.cjs',
59
+ },
60
+ {
61
+ matcher: '^(Bash)$',
62
+ command: 'node ./.codex/hooks/lib/codex-payload-adapter.cjs credential-scan.cjs',
63
+ },
64
+ ],
65
+ },
66
+ 'hooks.PostToolUse': {
67
+ __type: 'array',
68
+ items: [
69
+ {
70
+ matcher: '^(apply_patch)$',
71
+ command: 'node ./.codex/hooks/lib/codex-payload-adapter.cjs comment-replacement.cjs',
72
+ },
73
+ {
74
+ matcher: '^(Bash)$',
75
+ command: 'node ./.codex/hooks/lib/codex-payload-adapter.cjs test-match.cjs',
76
+ },
77
+ {
78
+ __leadingComments: '# coverage-gap: matcher never fires in Codex (no AskUserQuestion equivalent)',
79
+ matcher: '^$',
80
+ command: 'node ./.codex/hooks/wiki-update-reminder.cjs',
81
+ },
82
+ ],
83
+ },
84
+ 'hooks.SessionStart': {
85
+ __type: 'array',
86
+ items: [
87
+ {
88
+ matcher: 'clear',
89
+ command: 'node ./.codex/hooks/session-init.cjs',
90
+ },
91
+ ],
92
+ },
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Copy `.claude/hooks/` tree to `.codex/hooks/`.
98
+ * @param {string} projectRoot
99
+ */
100
+ function copyHooksTree(projectRoot) {
101
+ const src = join(projectRoot, '.claude', 'hooks');
102
+ const dest = join(projectRoot, '.codex', 'hooks');
103
+
104
+ if (!existsSync(src)) {
105
+ return; // Nothing to copy
106
+ }
107
+
108
+ // Wipe dest first so stale entries from prior runs (e.g. tests/ that were
109
+ // previously copied before the filter was added) don't survive a fresh
110
+ // conversion. cpSync's filter only blocks NEW writes; it doesn't delete.
111
+ if (existsSync(dest)) {
112
+ rmSync(dest, { recursive: true, force: true });
113
+ }
114
+ mkdirSync(dest, { recursive: true });
115
+ cpSync(src, dest, {
116
+ recursive: true,
117
+ force: true,
118
+ filter: (s) => {
119
+ const base = basename(s);
120
+ // Exclude test directories and test files — Codex runtime doesn't need them
121
+ return base !== 'tests' && !base.endsWith('.test.cjs');
122
+ },
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Build TOML document string for the Codex hooks config.
128
+ * @returns {string}
129
+ */
130
+ function buildToml() {
131
+ const hookBlocks = buildHookBlocks();
132
+
133
+ const parts = [
134
+ '# codex >=0.129.0 <0.140.0',
135
+ '',
136
+ emitTable('features', { codex_hooks: true }),
137
+ '',
138
+ emitArrayOfTables('hooks.PreToolUse', hookBlocks['hooks.PreToolUse'].items),
139
+ emitArrayOfTables('hooks.PostToolUse', hookBlocks['hooks.PostToolUse'].items),
140
+ emitArrayOfTables('hooks.SessionStart', hookBlocks['hooks.SessionStart'].items),
141
+ ];
142
+
143
+ return parts.join('\n');
144
+ }
145
+
146
+ /**
147
+ * Main export: convert Claude hooks to Codex TOML config.
148
+ *
149
+ * @param {object} [opts]
150
+ * @param {string} [opts.projectRoot] Root directory containing .claude/ (default: PACKAGE_ROOT)
151
+ * @param {string} [opts.outputDir] Directory to write config.toml (default: <projectRoot>/.codex)
152
+ * @returns {{ added: number, dropped: string[], warnings: string[] }}
153
+ */
154
+ export async function convertHooksToCodex(opts = {}) {
155
+ const projectRoot = opts.projectRoot ? resolve(opts.projectRoot) : PACKAGE_ROOT;
156
+ const outputDir = opts.outputDir ? resolve(opts.outputDir) : join(projectRoot, '.codex');
157
+
158
+ const warnings = [];
159
+ const dropped = [];
160
+
161
+ // Emit warnings for dropped hooks
162
+ for (const { source, name } of DROPPED_HOOKS) {
163
+ const msg = `[convert-hooks-to-codex] WARNING: Dropping ${name} (${source} event has no Codex equivalent)`;
164
+ warnings.push(msg);
165
+ dropped.push(name);
166
+ process.stderr.write(msg + '\n');
167
+ }
168
+
169
+ // Count emitted hooks
170
+ const hookBlocks = buildHookBlocks();
171
+ let added = 0;
172
+ for (const section of Object.values(hookBlocks)) {
173
+ if (section.__type === 'array') {
174
+ added += section.items.length;
175
+ }
176
+ }
177
+
178
+ // Copy hooks tree
179
+ copyHooksTree(projectRoot);
180
+
181
+ // Write config.toml
182
+ mkdirSync(outputDir, { recursive: true });
183
+ const tomlContent = buildToml();
184
+ writeFileSync(join(outputDir, 'config.toml'), tomlContent, 'utf-8');
185
+
186
+ return { added, dropped, warnings };
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // CLI entry point (when run directly)
191
+ // ---------------------------------------------------------------------------
192
+
193
+ if (process.argv[1] === __filename || process.argv[1]?.endsWith('convert-hooks-to-codex.js')) {
194
+ try {
195
+ const result = await convertHooksToCodex();
196
+ process.stdout.write(
197
+ `[convert-hooks-to-codex] Done. ${result.added} hooks emitted, ${result.dropped.length} dropped.\n`
198
+ );
199
+ process.exit(0);
200
+ } catch (err) {
201
+ process.stderr.write(`[convert-hooks-to-codex] ERROR: ${err.message}\n`);
202
+ process.exit(1);
203
+ }
204
+ }
@@ -0,0 +1,347 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * convert-skills-to-codex.js
4
+ *
5
+ * Strips Claude-only frontmatter keys (model, effort, argument-hint) from
6
+ * .claude/skills/<name>/SKILL.md before mirroring into .codex/skills/<name>/SKILL.md.
7
+ *
8
+ * Codex 0.130's quick_validate.py hard-allowlists exactly:
9
+ * {name, description, license, allowed-tools, metadata}
10
+ * and rejects any other YAML key. Source files are read-only.
11
+ *
12
+ * Usage:
13
+ * node scripts/convert-skills-to-codex.js [--input <dir>] [--output <dir>]
14
+ *
15
+ * Compliance
16
+ * ----------
17
+ * SKL-1: model: value is mapped via MODEL_MAP (opus→gpt-5, sonnet→gpt-5-mini); effort and
18
+ * argument-hint are stripped (no Codex skill-level equivalent).
19
+ * SKL-2: Round-trip determinism — two consecutive runs produce byte-identical output
20
+ * SKL-3: Body bytes pass through unchanged; only frontmatter is rewritten
21
+ * R2: Post-emit round-trip assertion — regex-check output SKILL.md and confirm
22
+ * none of {effort, argument-hint} appear as line-anchored YAML keys.
23
+ */
24
+
25
+ import {
26
+ readFileSync,
27
+ writeFileSync,
28
+ mkdirSync,
29
+ rmSync,
30
+ readdirSync,
31
+ existsSync,
32
+ cpSync,
33
+ } from 'node:fs';
34
+ import { join, resolve, dirname, relative, extname } from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+ import { KIT_INTERNAL_SKILLS, COPY_FILTER_PATTERNS } from '../src/lib/constants.js';
37
+ import { loadModelMap } from '../src/lib/runtime-codex.js';
38
+ import { rewriteKitPaths } from '../src/lib/codex-rewrite.js';
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Constants
42
+ // ---------------------------------------------------------------------------
43
+
44
+ const LOG_PREFIX = '[convert-skills-to-codex]';
45
+
46
+ /**
47
+ * Keys stripped from frontmatter (no Codex skill-level equivalent).
48
+ * `model` is NOT in this set — it is transformed via MODEL_MAP instead.
49
+ */
50
+ const STRIP_KEYS = new Set(['effort', 'argument-hint']);
51
+
52
+ /**
53
+ * Frontmatter regex: captures (fm_block, body) where fm_block is between the
54
+ * two `---` delimiters. Supports both LF and CRLF line endings.
55
+ */
56
+ const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/s;
57
+
58
+ /**
59
+ * Per-line strip regex: removes lines whose key is in STRIP_KEYS.
60
+ * Anchored at start-of-line (`m` flag). Handles trailing CRLF or LF.
61
+ */
62
+ const STRIP_RE = /^(effort|argument-hint):[^\n]*(?:\r?\n|$)/gm;
63
+
64
+ /**
65
+ * Regex to locate the model: line for value substitution.
66
+ * Captures: prefix (key + colon + spaces), value (first non-space token), trailing (rest of line).
67
+ */
68
+ const MODEL_LINE_RE = /^(model:\s*)(\S+)([^\n]*)$/m;
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // CLI argument parsing
72
+ // ---------------------------------------------------------------------------
73
+
74
+ const args = process.argv.slice(2);
75
+ let inputDirArg;
76
+ let outputDirArg;
77
+ let modelMapPath;
78
+
79
+ for (let i = 0; i < args.length; i++) {
80
+ if (args[i] === '--input' && args[i + 1]) {
81
+ inputDirArg = resolve(args[++i]);
82
+ } else if (args[i] === '--output' && args[i + 1]) {
83
+ outputDirArg = resolve(args[++i]);
84
+ } else if (args[i] === '--model-map' && args[i + 1]) {
85
+ modelMapPath = resolve(args[++i]);
86
+ }
87
+ }
88
+
89
+ const modelMap = loadModelMap(modelMapPath);
90
+
91
+ const kitRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
92
+ const inputDir = inputDirArg || join(kitRoot, '.claude', 'skills');
93
+ const outputDir = outputDirArg || join(kitRoot, '.codex', 'skills');
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Core: transform Claude-only frontmatter keys for Codex
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /**
100
+ * Replace the `model:` line value using MODEL_MAP.
101
+ * Preserves prefix whitespace and any trailing inline comment.
102
+ * If the value is not in modelMap, logs a warning and keeps the original.
103
+ *
104
+ * @param {string} fm Raw frontmatter block (between --- delimiters)
105
+ * @param {object} map MODEL_MAP or user override
106
+ * @returns {string}
107
+ */
108
+ function transformModelLine(fm, map) {
109
+ return fm.replace(MODEL_LINE_RE, (match, prefix, value, trailing) => {
110
+ const mapped = map[value];
111
+ if (mapped === undefined) {
112
+ process.stderr.write(
113
+ `${LOG_PREFIX} Warning: unknown model "${value}" — keeping original value\n`
114
+ );
115
+ return match;
116
+ }
117
+ return `${prefix}${mapped}${trailing}`;
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Transform a SKILL.md content string for Codex:
123
+ * - model: <claude-tier> → model: <codex-id> (via MODEL_MAP)
124
+ * - effort: … → stripped (no Codex skill-level equivalent)
125
+ * - argument-hint: … → stripped (Claude Code slash-command UI only)
126
+ * Body is passed through byte-identical.
127
+ *
128
+ * @param {string} content Raw SKILL.md content
129
+ * @param {object} map MODEL_MAP or user override
130
+ * @returns {string}
131
+ */
132
+ function transformFrontmatter(content, map) {
133
+ const match = content.match(FM_RE);
134
+ if (!match) return content; // no frontmatter — pass through unchanged
135
+
136
+ const [, fm, body] = match;
137
+ let transformed = fm.replace(STRIP_RE, ''); // strip effort + argument-hint
138
+ transformed = transformModelLine(transformed, map); // map model value
139
+ // FM_RE consumes the \n before the closing --- delimiter, so transformed may
140
+ // not end with \n. Always add it to keep the closing --- on its own line.
141
+ const normalizedFm = transformed.endsWith('\n') ? transformed : transformed + '\n';
142
+ return `---\n${normalizedFm}---\n${body}`;
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Post-copy rewrite walk
147
+ // ---------------------------------------------------------------------------
148
+
149
+ /**
150
+ * Recursively walk `dir` and rewrite kit-path references in every text file.
151
+ * Covers `.md` (skill prose) AND `.js`/`.cjs`/`.mjs`/`.ts` (skill scripts that
152
+ * register hooks or reference sibling scripts via `.claude/skills/X/scripts/Y`
153
+ * paths — under Codex runtime those paths resolve under `.codex/`).
154
+ * Reads each file, applies `rewriteKitPaths`, and writes back only when the
155
+ * content actually changes (avoids touching mtimes unnecessarily).
156
+ *
157
+ * @param {string} dir Absolute path to walk
158
+ */
159
+ const _REWRITE_EXTS = new Set(['.md', '.js', '.cjs', '.mjs', '.ts', '.py']);
160
+
161
+ function walkAndRewriteMarkdown(dir) {
162
+ let entries;
163
+ try {
164
+ entries = readdirSync(dir, { withFileTypes: true });
165
+ } catch {
166
+ return; // directory vanished — skip
167
+ }
168
+ for (const entry of entries) {
169
+ const fullPath = join(dir, entry.name);
170
+ if (entry.isDirectory()) {
171
+ walkAndRewriteMarkdown(fullPath);
172
+ } else if (entry.isFile() && _REWRITE_EXTS.has(extname(entry.name).toLowerCase())) {
173
+ try {
174
+ const original = readFileSync(fullPath, 'utf8');
175
+ const rewritten = rewriteKitPaths(original);
176
+ if (rewritten !== original) {
177
+ writeFileSync(fullPath, rewritten, 'utf8');
178
+ }
179
+ } catch {
180
+ // Non-fatal: if a single file can't be read/written, continue the walk
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Path safety
188
+ // ---------------------------------------------------------------------------
189
+
190
+ /**
191
+ * Validate that a relative path does not escape the base directory.
192
+ * @param {string} rel Relative path from path.relative()
193
+ * @returns {boolean}
194
+ */
195
+ function isSafePath(rel) {
196
+ return rel !== '' && !rel.startsWith('..') && !rel.startsWith('/') && !rel.startsWith('\\');
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Copy filter
201
+ // ---------------------------------------------------------------------------
202
+
203
+ /**
204
+ * cpSync filter that excludes paths matching COPY_FILTER_PATTERNS.
205
+ * @param {string} src
206
+ * @returns {boolean}
207
+ */
208
+ function copyFilter(src) {
209
+ const name = src.split(/[\\/]/).pop() || '';
210
+ return !COPY_FILTER_PATTERNS.some((pat) => name === pat || name.endsWith(pat));
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Main
215
+ // ---------------------------------------------------------------------------
216
+
217
+ let successCount = 0;
218
+ let errorCount = 0;
219
+ const errors = [];
220
+
221
+ function logInfo(msg) {
222
+ process.stderr.write(`${LOG_PREFIX} ${msg}\n`);
223
+ }
224
+
225
+ function logError(msg) {
226
+ process.stderr.write(`${LOG_PREFIX} ERROR: ${msg}\n`);
227
+ }
228
+
229
+ // H1: Guard against --output resolving to the filesystem root (rm-rf blast radius).
230
+ // A directory is its own parent only at the filesystem root (e.g. / on Unix, C:\ on Windows).
231
+ // Allows external temp dirs used by tests; blocks the most dangerous case.
232
+ {
233
+ const resolvedOut = resolve(outputDir);
234
+ if (resolvedOut === resolve(dirname(resolvedOut))) {
235
+ logError(`outputDir "${outputDir}" is the filesystem root. Refusing to run.`);
236
+ process.exit(1);
237
+ }
238
+ }
239
+
240
+ // Destroy and recreate output directory (destructive semantics = deterministic output).
241
+ try {
242
+ rmSync(outputDir, { recursive: true, force: true });
243
+ mkdirSync(outputDir, { recursive: true });
244
+ } catch (err) {
245
+ logError(`Cannot prepare output directory: ${outputDir}\n${err.message}`);
246
+ process.exit(1);
247
+ }
248
+
249
+ // Walk input directory (one level deep).
250
+ let skillDirs;
251
+ try {
252
+ skillDirs = readdirSync(inputDir, { withFileTypes: true }).filter((d) => d.isDirectory());
253
+ } catch (err) {
254
+ logError(`Cannot read input directory: ${inputDir}\n${err.message}`);
255
+ process.exit(1);
256
+ }
257
+
258
+ for (const dirent of skillDirs) {
259
+ const skillName = dirent.name;
260
+
261
+ // SKL-4: Skip KIT_INTERNAL_SKILLS.
262
+ if (KIT_INTERNAL_SKILLS.includes(skillName)) {
263
+ logInfo(`Skipping internal skill: ${skillName}`);
264
+ continue;
265
+ }
266
+
267
+ const skillSrc = join(inputDir, skillName);
268
+ const skillDest = join(outputDir, skillName);
269
+ const topLevelSkillMd = join(skillSrc, 'SKILL.md');
270
+
271
+ // Skip if no top-level SKILL.md exists.
272
+ if (!existsSync(topLevelSkillMd)) {
273
+ logInfo(`Skipping ${skillName}: no SKILL.md found`);
274
+ continue;
275
+ }
276
+
277
+ logInfo(`Converting skill: ${skillName}`);
278
+
279
+ try {
280
+ // Path safety check.
281
+ const relToInput = relative(inputDir, skillSrc);
282
+ if (!isSafePath(relToInput)) {
283
+ throw new Error(`Path traversal detected for skill "${skillName}": ${relToInput}`);
284
+ }
285
+
286
+ // Create skill output directory.
287
+ mkdirSync(skillDest, { recursive: true });
288
+
289
+ // Copy entire skill directory (includes references/, scripts/, assets/, etc.).
290
+ cpSync(skillSrc, skillDest, {
291
+ recursive: true,
292
+ force: true,
293
+ filter: copyFilter,
294
+ });
295
+
296
+ // Now overwrite the top-level SKILL.md with the transformed version.
297
+ const rawContent = readFileSync(topLevelSkillMd, 'utf8');
298
+ const strippedContent = transformFrontmatter(rawContent, modelMap);
299
+
300
+ const outSkillMd = join(skillDest, 'SKILL.md');
301
+ writeFileSync(outSkillMd, strippedContent, 'utf8');
302
+
303
+ // Rewrite all .md files in the skill dest tree (references/, SKILL.md body, etc.)
304
+ // so `.claude/<subdir>/` references become `.codex/<subdir>/`.
305
+ // Runs AFTER the frontmatter-transformed SKILL.md is written so the single
306
+ // pass covers both body and reference files.
307
+ walkAndRewriteMarkdown(skillDest);
308
+
309
+ // R2 round-trip assertion: confirm stripped keys are absent after write.
310
+ // Uses line-anchored regex (same as STRIP_RE) rather than yaml.load, because
311
+ // some skill descriptions contain ': ' sequences that make js-yaml refuse to
312
+ // parse the frontmatter block (the source YAML is valid but ambiguous per
313
+ // js-yaml's strict mode). The regex check is sufficient for our purposes.
314
+ const written = readFileSync(outSkillMd, 'utf8');
315
+ const fmMatch = written.match(FM_RE);
316
+ // H4: Fail-closed — if the output SKILL.md has no valid frontmatter block
317
+ // the write succeeded but the content is corrupt; treat as a hard error so
318
+ // the skill is counted as failed rather than silently "passing" R2.
319
+ if (!fmMatch) {
320
+ throw new Error(`R2: output SKILL.md has no valid frontmatter: ${outSkillMd}`);
321
+ }
322
+ const writtenFm = fmMatch[1];
323
+ for (const key of STRIP_KEYS) {
324
+ const keyRe = new RegExp(`^${key.replace(/-/g, '\\-')}:`, 'm');
325
+ if (keyRe.test(writtenFm)) {
326
+ throw new Error(`R2 violation: key '${key}' survived strip in ${outSkillMd}`);
327
+ }
328
+ }
329
+
330
+ logInfo(`R2 assertion passed for: ${skillName}`);
331
+ successCount++;
332
+ } catch (err) {
333
+ errorCount++;
334
+ errors.push(` ${skillName}: ${err.message}`);
335
+ logError(`Converting "${skillName}": ${err.message}`);
336
+ }
337
+ }
338
+
339
+ process.stdout.write(
340
+ `${LOG_PREFIX} Done. ${successCount} converted, ${errorCount} errors.\n`
341
+ );
342
+ process.stdout.write(`${LOG_PREFIX} Output: ${outputDir}\n`);
343
+
344
+ if (errors.length > 0) {
345
+ process.stderr.write(`${LOG_PREFIX} Errors:\n${errors.join('\n')}\n`);
346
+ process.exit(1);
347
+ }