@khanhcan148/mk 0.1.21 → 0.1.24
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/README.md +9 -3
- package/bin/mk.js +8 -0
- package/package.json +10 -4
- package/scripts/.gitkeep +0 -0
- package/scripts/codex-diff-check.js +69 -0
- package/scripts/convert-agents-to-codex.js +352 -0
- package/scripts/convert-hooks-to-codex.js +204 -0
- package/scripts/convert-skills-to-codex.js +347 -0
- package/src/commands/codex.js +315 -0
- package/src/commands/update.js +107 -3
- package/src/lib/codex-rewrite.js +36 -0
- package/src/lib/constants.js +26 -0
- package/src/lib/runtime-codex.js +148 -0
- package/src/lib/toml-emit.js +219 -0
|
@@ -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
|
+
}
|