@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,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codex.js — `mk codex` subcommand.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the current project's `.claude/` kit assets into `.codex/` for
|
|
5
|
+
* OpenAI Codex CLI consumption:
|
|
6
|
+
* - .claude/agents/*.md → .codex/agents/*.toml (via converter script)
|
|
7
|
+
* - .claude/skills/ → .codex/skills/ (via skills converter, Claude-only FM keys stripped)
|
|
8
|
+
* - .claude/workflows/ → .codex/workflows/ (recursive copy)
|
|
9
|
+
* - .claude/hooks/ → .codex/hooks/ (via hooks converter)
|
|
10
|
+
* - .codex/config.toml — emitted with [features] codex_hooks=true + 6 hook blocks
|
|
11
|
+
*
|
|
12
|
+
* The converter is the source of truth for IAC-1/3/4 + R2 compliance; this
|
|
13
|
+
* command is a thin wrapper that supplies cwd-derived paths and handles the
|
|
14
|
+
* skills/workflows copy that the converter intentionally does not.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { existsSync, cpSync, rmSync, statSync, writeFileSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
|
|
19
|
+
import { join, resolve, dirname, basename, sep, parse as pathParse, extname } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import chalk from 'chalk';
|
|
22
|
+
import { TOOL_DIR_NAME, COPY_FILTER_PATTERNS } from '../lib/constants.js';
|
|
23
|
+
import { convertHooksToCodex } from '../../scripts/convert-hooks-to-codex.js';
|
|
24
|
+
import { rewriteKitPaths } from '../lib/codex-rewrite.js';
|
|
25
|
+
|
|
26
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const PACKAGE_ROOT = resolve(__dirname, '..', '..');
|
|
28
|
+
const CONVERTER_SCRIPT = join(PACKAGE_ROOT, 'scripts', 'convert-agents-to-codex.js');
|
|
29
|
+
const SKILLS_CONVERTER_SCRIPT = join(PACKAGE_ROOT, 'scripts', 'convert-skills-to-codex.js');
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Copy a directory tree, excluding nested filenames matching COPY_FILTER_PATTERNS
|
|
33
|
+
* and (when provided) top-level directory segments listed in `excludeNames`.
|
|
34
|
+
*
|
|
35
|
+
* Replaces destDir wholesale (rm -rf semantics) so stale entries from a
|
|
36
|
+
* previous run do not linger.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} srcDir
|
|
39
|
+
* @param {string} destDir
|
|
40
|
+
* @param {{ excludeNames?: string[] }} [opts]
|
|
41
|
+
*/
|
|
42
|
+
function mirrorDir(srcDir, destDir, opts = {}) {
|
|
43
|
+
const excludeNames = new Set(opts.excludeNames ?? []);
|
|
44
|
+
|
|
45
|
+
if (existsSync(destDir)) {
|
|
46
|
+
rmSync(destDir, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
cpSync(srcDir, destDir, {
|
|
50
|
+
recursive: true,
|
|
51
|
+
force: true,
|
|
52
|
+
filter: (src) => {
|
|
53
|
+
const name = basename(src);
|
|
54
|
+
if (COPY_FILTER_PATTERNS.some((pat) => name === pat || name.endsWith(pat))) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
// Top-level directory exclusion (e.g. KIT_INTERNAL_SKILLS).
|
|
58
|
+
// Only match immediate children of srcDir, not nested directories
|
|
59
|
+
// that happen to share a name.
|
|
60
|
+
if (excludeNames.size > 0) {
|
|
61
|
+
const rel = src.slice(srcDir.length).replace(/^[\\/]+/, '');
|
|
62
|
+
if (rel && !rel.includes(sep) && excludeNames.has(rel)) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Mirror a directory tree like `mirrorDir`, but also rewrite kit-path references
|
|
73
|
+
* in text files so `.claude/<subdir>/` becomes `.codex/<subdir>/`.
|
|
74
|
+
*
|
|
75
|
+
* Text files (extensions in `textExtensions`) are read, rewritten, and written;
|
|
76
|
+
* binary/other files are copied byte-identical via cpSync per-file.
|
|
77
|
+
*
|
|
78
|
+
* Destructive: removes destDir wholesale before copying so stale entries from
|
|
79
|
+
* a previous run do not linger (same rm-rf semantics as mirrorDir).
|
|
80
|
+
*
|
|
81
|
+
* @param {string} srcDir
|
|
82
|
+
* @param {string} destDir
|
|
83
|
+
* @param {{
|
|
84
|
+
* excludeNames?: string[],
|
|
85
|
+
* textExtensions?: string[],
|
|
86
|
+
* rewriter?: (text: string) => string,
|
|
87
|
+
* }} [opts]
|
|
88
|
+
*/
|
|
89
|
+
function mirrorDirWithRewrite(srcDir, destDir, opts = {}) {
|
|
90
|
+
const excludeNames = new Set(opts.excludeNames ?? []);
|
|
91
|
+
const textExtensions = new Set(opts.textExtensions ?? ['.md', '.txt', '.toml']);
|
|
92
|
+
const rewriter = opts.rewriter ?? rewriteKitPaths;
|
|
93
|
+
|
|
94
|
+
if (existsSync(destDir)) {
|
|
95
|
+
rmSync(destDir, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function walkAndCopy(src, dest) {
|
|
99
|
+
mkdirSync(dest, { recursive: true });
|
|
100
|
+
let entries;
|
|
101
|
+
try {
|
|
102
|
+
entries = readdirSync(src, { withFileTypes: true });
|
|
103
|
+
} catch {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
const name = entry.name;
|
|
108
|
+
// Apply COPY_FILTER_PATTERNS exclusion
|
|
109
|
+
if (COPY_FILTER_PATTERNS.some((pat) => name === pat || name.endsWith(pat))) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
// Apply top-level excludeNames (only immediate children of srcDir)
|
|
113
|
+
if (excludeNames.size > 0 && src === srcDir && excludeNames.has(name)) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const srcPath = join(src, name);
|
|
117
|
+
const destPath = join(dest, name);
|
|
118
|
+
if (entry.isDirectory()) {
|
|
119
|
+
walkAndCopy(srcPath, destPath);
|
|
120
|
+
} else if (entry.isFile()) {
|
|
121
|
+
const ext = extname(name).toLowerCase();
|
|
122
|
+
if (textExtensions.has(ext)) {
|
|
123
|
+
try {
|
|
124
|
+
const original = readFileSync(srcPath, 'utf8');
|
|
125
|
+
const rewritten = rewriter(original);
|
|
126
|
+
writeFileSync(destPath, rewritten, 'utf8');
|
|
127
|
+
} catch {
|
|
128
|
+
// Fall back to binary copy on read/write failure
|
|
129
|
+
cpSync(srcPath, destPath, { force: true });
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
cpSync(srcPath, destPath, { force: true });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
walkAndCopy(srcDir, destDir);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Single-syscall directory check (replaces existsSync+statSync double-stat). */
|
|
142
|
+
function isDirectory(p) {
|
|
143
|
+
try { return statSync(p).isDirectory(); } catch { return false; }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Run the full Codex conversion pipeline for a project root.
|
|
148
|
+
*
|
|
149
|
+
* Unlike `codexAction`, this function never calls `process.exit`. It returns
|
|
150
|
+
* a result object so callers (e.g. `maybeSyncCodex` in update.js) can inspect
|
|
151
|
+
* the outcome and decide how to handle failures.
|
|
152
|
+
*
|
|
153
|
+
* @param {object} [options]
|
|
154
|
+
* @param {string} [options.cwd] - Project root (default: process.cwd()).
|
|
155
|
+
* Trusted-caller-only: callers must supply a
|
|
156
|
+
* validated path. If set, any `..` segment
|
|
157
|
+
* after resolution is rejected so an injected
|
|
158
|
+
* path can never traverse outside its parent.
|
|
159
|
+
* @param {string} [options.output] - Override for agents output dir
|
|
160
|
+
* @param {string} [options.modelMap] - Path to custom model-map JSON
|
|
161
|
+
* @returns {Promise<{ exitCode: number, hooksResult?: object, errors: string[] }>}
|
|
162
|
+
*/
|
|
163
|
+
export async function runCodexConversion(options = {}) {
|
|
164
|
+
// S1: Reject `..` segments AFTER resolve to catch encoded traversal attempts
|
|
165
|
+
// (e.g. "foo/../../../etc"). A full homedir-bound would break legitimate
|
|
166
|
+
// `mk codex --cwd /tmp/…` invocations, so we use the lighter ..‑segment check.
|
|
167
|
+
if (options.cwd) {
|
|
168
|
+
const resolvedCwd = resolve(options.cwd);
|
|
169
|
+
// Check the original (pre-resolve) string for '..' path segments
|
|
170
|
+
const parts = options.cwd.replace(/\\/g, '/').split('/');
|
|
171
|
+
if (parts.some((p) => p === '..')) {
|
|
172
|
+
const msg = 'error: --cwd must not contain ".." path traversal segments';
|
|
173
|
+
console.error(chalk.red(msg));
|
|
174
|
+
return { exitCode: 1, errors: [msg] };
|
|
175
|
+
}
|
|
176
|
+
// Also verify that resolve didn't collapse to a shorter path unexpectedly
|
|
177
|
+
// (defence in depth: covers OS-level symlink chains that embed traversal)
|
|
178
|
+
void resolvedCwd; // used only for the side-effect check above
|
|
179
|
+
}
|
|
180
|
+
const projectRoot = resolve(options.cwd || process.cwd());
|
|
181
|
+
const claudeDir = join(projectRoot, TOOL_DIR_NAME);
|
|
182
|
+
const agentsDir = join(claudeDir, 'agents');
|
|
183
|
+
const skillsDir = join(claudeDir, 'skills');
|
|
184
|
+
const workflowsDir = join(claudeDir, 'workflows');
|
|
185
|
+
|
|
186
|
+
// Two-mode path resolution:
|
|
187
|
+
// --output set: codexRoot = parent(output), agentsOut = output (output IS agents dir)
|
|
188
|
+
// --output unset: codexRoot = <projectRoot>/.codex, agentsOut = <codexRoot>/agents
|
|
189
|
+
// Tests pass --output to write into a tmpdir; CLI users normally omit it.
|
|
190
|
+
const codexRoot = options.output
|
|
191
|
+
? resolve(options.output, '..')
|
|
192
|
+
: join(projectRoot, '.codex');
|
|
193
|
+
const agentsOut = options.output ? resolve(options.output) : join(codexRoot, 'agents');
|
|
194
|
+
const skillsOut = join(codexRoot, 'skills');
|
|
195
|
+
const workflowsOut = join(codexRoot, 'workflows');
|
|
196
|
+
|
|
197
|
+
const errors = [];
|
|
198
|
+
|
|
199
|
+
// H2: Guard against --output resolving to the filesystem root (rm-rf blast radius).
|
|
200
|
+
// S2: Use pathParse().root === codexRoot instead of comparing against resolve(sep),
|
|
201
|
+
// because Windows has multiple drive roots (C:\, D:\) and UNC paths — resolve(sep)
|
|
202
|
+
// only covers the current drive, while pathParse().root covers ALL canonical roots.
|
|
203
|
+
if (pathParse(codexRoot).root === codexRoot) {
|
|
204
|
+
const msg = 'error: --output must not resolve to the filesystem root';
|
|
205
|
+
console.error(chalk.red(msg));
|
|
206
|
+
errors.push(msg);
|
|
207
|
+
return { exitCode: 1, errors };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!existsSync(agentsDir)) {
|
|
211
|
+
const msg = `error: ${agentsDir} does not exist`;
|
|
212
|
+
console.error(chalk.red(msg));
|
|
213
|
+
console.error(chalk.dim(`run \`mk init\` first, or pass --cwd <project-with-.claude>`));
|
|
214
|
+
errors.push(msg);
|
|
215
|
+
return { exitCode: 1, errors };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!existsSync(CONVERTER_SCRIPT)) {
|
|
219
|
+
const msg = `error: converter script not found at ${CONVERTER_SCRIPT}`;
|
|
220
|
+
console.error(chalk.red(msg));
|
|
221
|
+
errors.push(msg);
|
|
222
|
+
return { exitCode: 1, errors };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Skills and agents converters run in parallel to reduce wall-clock time.
|
|
226
|
+
// Progress lines are emitted before the Promise.all so they appear immediately.
|
|
227
|
+
// workflowsDir mirror is synchronous and runs after both converters complete.
|
|
228
|
+
|
|
229
|
+
// Guard skills converter script availability before starting any spawns.
|
|
230
|
+
if (isDirectory(skillsDir) && !existsSync(SKILLS_CONVERTER_SCRIPT)) {
|
|
231
|
+
const msg = `error: skills converter not found at ${SKILLS_CONVERTER_SCRIPT}`;
|
|
232
|
+
console.error(chalk.red(msg));
|
|
233
|
+
errors.push(msg);
|
|
234
|
+
return { exitCode: 1, errors };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Emit progress lines upfront (before awaiting) so the user sees intent immediately.
|
|
238
|
+
if (isDirectory(skillsDir)) {
|
|
239
|
+
console.log(chalk.cyan(`Converting ${skillsDir}`));
|
|
240
|
+
console.log(chalk.cyan(` → ${skillsOut}`));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const args = [CONVERTER_SCRIPT, '--input', agentsDir, '--output', agentsOut];
|
|
244
|
+
if (options.modelMap) {
|
|
245
|
+
args.push('--model-map', resolve(options.modelMap));
|
|
246
|
+
}
|
|
247
|
+
console.log(chalk.cyan(`Converting ${agentsDir}`));
|
|
248
|
+
console.log(chalk.cyan(` → ${agentsOut}`));
|
|
249
|
+
|
|
250
|
+
// Start both spawns concurrently.
|
|
251
|
+
const skillsPromise = isDirectory(skillsDir)
|
|
252
|
+
? new Promise((resolveExit) => {
|
|
253
|
+
const child = spawn(
|
|
254
|
+
process.execPath,
|
|
255
|
+
[SKILLS_CONVERTER_SCRIPT, '--input', skillsDir, '--output', skillsOut],
|
|
256
|
+
{ stdio: 'inherit' }
|
|
257
|
+
);
|
|
258
|
+
child.on('close', (exitCode) => resolveExit(exitCode ?? 1));
|
|
259
|
+
child.on('error', (err) => {
|
|
260
|
+
console.error(chalk.red(`error: failed to spawn skills converter: ${err.message}`));
|
|
261
|
+
resolveExit(1);
|
|
262
|
+
});
|
|
263
|
+
})
|
|
264
|
+
: Promise.resolve(0);
|
|
265
|
+
|
|
266
|
+
const agentsPromise = new Promise((resolveExit) => {
|
|
267
|
+
const child = spawn(process.execPath, args, { stdio: 'inherit' });
|
|
268
|
+
child.on('close', (exitCode) => resolveExit(exitCode ?? 1));
|
|
269
|
+
child.on('error', (err) => {
|
|
270
|
+
console.error(chalk.red(`error: failed to spawn converter: ${err.message}`));
|
|
271
|
+
resolveExit(1);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const [skillsCode, code] = await Promise.all([skillsPromise, agentsPromise]);
|
|
276
|
+
|
|
277
|
+
if (skillsCode !== 0) {
|
|
278
|
+
errors.push(`skills converter exited with code ${skillsCode}`);
|
|
279
|
+
return { exitCode: skillsCode, errors };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (code !== 0) {
|
|
283
|
+
errors.push(`agent converter exited with code ${code}`);
|
|
284
|
+
return { exitCode: code, errors };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Mirror workflows synchronously after both converters succeed.
|
|
288
|
+
// Uses mirrorDirWithRewrite so .claude/<subdir>/ refs in .md/.txt/.toml files
|
|
289
|
+
// are rewritten to .codex/<subdir>/ for Codex-runtime resolution.
|
|
290
|
+
if (isDirectory(workflowsDir)) {
|
|
291
|
+
console.log(chalk.cyan(`Mirroring ${workflowsDir}`));
|
|
292
|
+
console.log(chalk.cyan(` → ${workflowsOut}`));
|
|
293
|
+
mirrorDirWithRewrite(workflowsDir, workflowsOut);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const hooksResult = await convertHooksToCodex({ projectRoot, outputDir: codexRoot });
|
|
297
|
+
if (hooksResult.dropped.length > 0) {
|
|
298
|
+
console.warn(
|
|
299
|
+
chalk.yellow(
|
|
300
|
+
`Warning: dropped ${hooksResult.dropped.length} hooks (SubagentStart/TeammateIdle/TaskCompleted have no Codex equivalent)`
|
|
301
|
+
)
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
console.log(chalk.green('✓ codex conversion complete'));
|
|
305
|
+
return { exitCode: 0, hooksResult, errors };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* `mk codex` CLI entry point — thin wrapper around runCodexConversion.
|
|
310
|
+
* Calls process.exit with the result exit code.
|
|
311
|
+
*/
|
|
312
|
+
export async function codexAction(options = {}) {
|
|
313
|
+
const result = await runCodexConversion(options);
|
|
314
|
+
process.exit(result.exitCode);
|
|
315
|
+
}
|
package/src/commands/update.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { createInterface } from 'node:readline';
|
|
3
|
-
import { existsSync, unlinkSync, copyFileSync, mkdirSync, readFileSync, rmdirSync } from 'node:fs';
|
|
3
|
+
import { existsSync, unlinkSync, copyFileSync, mkdirSync, readFileSync, rmdirSync, readdirSync } from 'node:fs';
|
|
4
4
|
import { join, dirname, resolve, sep } from 'node:path';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { runCodexConversion } from './codex.js';
|
|
5
7
|
import { fileURLToPath } from 'node:url';
|
|
6
8
|
import { readManifest, updateManifest, diffManifest } from '../lib/manifest.js';
|
|
7
9
|
import { computeChecksum } from '../lib/checksum.js';
|
|
@@ -40,6 +42,98 @@ export function stripTerminalEscapes(str) {
|
|
|
40
42
|
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, ''); // raw control chars (preserve \t \n \r)
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Security: redact secrets and paths from error messages before surfacing them
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Scrub sensitive values from a string before writing it to stderr/stdout.
|
|
51
|
+
*
|
|
52
|
+
* S3: Redacts GitHub personal-access tokens (gh[pousr]_… pattern) and any
|
|
53
|
+
* stored token obtained via readStoredToken(), so credentials cannot leak
|
|
54
|
+
* into terminal output or CI logs.
|
|
55
|
+
* S4: Replaces the user's home directory prefix with `~` and the current
|
|
56
|
+
* working directory prefix with `.` to avoid leaking absolute paths.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} message - Raw error/warning message to sanitise.
|
|
59
|
+
* @param {{ storedToken?: string|null }} [ctx]
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
export function redactSecrets(message, ctx = {}) {
|
|
63
|
+
let out = message;
|
|
64
|
+
|
|
65
|
+
// S4: Path redaction — replace absolute path prefixes with short aliases.
|
|
66
|
+
// Apply homedir first (longer prefix) before cwd to avoid partial replacement.
|
|
67
|
+
const home = homedir();
|
|
68
|
+
const cwd = process.cwd();
|
|
69
|
+
// Replace homedir occurrences with '~'
|
|
70
|
+
if (home && out.includes(home)) {
|
|
71
|
+
out = out.split(home).join('~');
|
|
72
|
+
}
|
|
73
|
+
// Replace cwd occurrences with '.' (only when cwd != home to avoid double-replace)
|
|
74
|
+
if (cwd && cwd !== home && out.includes(cwd)) {
|
|
75
|
+
out = out.split(cwd).join('.');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// S3: Redact explicit stored token if provided
|
|
79
|
+
if (ctx.storedToken && out.includes(ctx.storedToken)) {
|
|
80
|
+
out = out.split(ctx.storedToken).join('[REDACTED-TOKEN]');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// S3: Redact any GitHub token-shaped substring (belt-and-braces)
|
|
84
|
+
out = out.replace(/gh[pousr]_[A-Za-z0-9_]{30,}/g, '[REDACTED-TOKEN]');
|
|
85
|
+
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Codex auto-sync helpers
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Returns true if `codexDir` looks like a real Codex install — i.e. it exists
|
|
95
|
+
* and contains at least one of the indicator children emitted by `mk codex`.
|
|
96
|
+
*
|
|
97
|
+
* Returns false when the directory is absent (not installed) or empty (no
|
|
98
|
+
* conversion has been run yet), so `maybeSyncCodex` can silently skip.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} codexDir - Absolute path to the `.codex/` directory
|
|
101
|
+
* @returns {boolean}
|
|
102
|
+
*/
|
|
103
|
+
export function hasCodexInstall(codexDir) {
|
|
104
|
+
let entries;
|
|
105
|
+
try { entries = readdirSync(codexDir); } catch { return false; }
|
|
106
|
+
if (entries.length === 0) return false;
|
|
107
|
+
const indicators = new Set(['agents', 'skills', 'workflows', 'hooks', 'config.toml']);
|
|
108
|
+
return entries.some((name) => indicators.has(name));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* If a Codex install is detected alongside the updated `.claude/`, re-run the
|
|
113
|
+
* Codex conversion pipeline so the `.codex/` mirror stays coherent.
|
|
114
|
+
*
|
|
115
|
+
* Silent no-op when `.codex/` is absent or empty (no prior conversion).
|
|
116
|
+
* Throws a tagged error (`.codexSyncFailure = true`) on conversion failure so
|
|
117
|
+
* callers can distinguish a codex-only problem from a core update failure.
|
|
118
|
+
*
|
|
119
|
+
* @param {{ projectRoot: string, codexRunner: Function }} opts
|
|
120
|
+
* @param {string} opts.projectRoot - Absolute path to the project root
|
|
121
|
+
* @param {Function} opts.codexRunner - DI-injectable; defaults to runCodexConversion
|
|
122
|
+
*/
|
|
123
|
+
export async function maybeSyncCodex({ projectRoot, codexRunner }) {
|
|
124
|
+
const codexDir = join(projectRoot, '.codex');
|
|
125
|
+
if (!hasCodexInstall(codexDir)) return; // silent skip — no codex install detected
|
|
126
|
+
process.stdout.write(chalk.cyan('Syncing .codex/ mirror…\n'));
|
|
127
|
+
const result = await codexRunner({ cwd: projectRoot });
|
|
128
|
+
if (result.exitCode !== 0) {
|
|
129
|
+
const err = new Error(
|
|
130
|
+
'Codex sync failed after .claude/ update completed. Retry manually: `mk codex`'
|
|
131
|
+
);
|
|
132
|
+
err.codexSyncFailure = true;
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
43
137
|
// ---------------------------------------------------------------------------
|
|
44
138
|
// Prompt helper
|
|
45
139
|
// ---------------------------------------------------------------------------
|
|
@@ -314,7 +408,8 @@ export async function updateAction(options = {}, deps = {}) {
|
|
|
314
408
|
promptUser = defaultPromptUser,
|
|
315
409
|
// Injectable for tests — allows overriding resolved paths without touching CWD
|
|
316
410
|
manifestPath: injectedManifestPath,
|
|
317
|
-
targetDir: injectedTargetDir
|
|
411
|
+
targetDir: injectedTargetDir,
|
|
412
|
+
runCodexConversion: codexRunner = runCodexConversion
|
|
318
413
|
} = deps;
|
|
319
414
|
|
|
320
415
|
// Read local package version (used as fallback when manifest has no version)
|
|
@@ -324,6 +419,9 @@ export async function updateAction(options = {}, deps = {}) {
|
|
|
324
419
|
|
|
325
420
|
const targetDir = injectedTargetDir ?? resolveTargetDir(options);
|
|
326
421
|
const manifestPath = injectedManifestPath ?? resolveManifestPath(options);
|
|
422
|
+
// targetDir is always <root>/.claude (resolveTargetDir returns join(root, '.claude'));
|
|
423
|
+
// dirname yields the project root for project mode and homedir() for global mode.
|
|
424
|
+
const projectRoot = dirname(targetDir);
|
|
327
425
|
|
|
328
426
|
let tempDir = null;
|
|
329
427
|
try {
|
|
@@ -374,6 +472,7 @@ export async function updateAction(options = {}, deps = {}) {
|
|
|
374
472
|
// before hooks were added to the kit or if it was manually edited.
|
|
375
473
|
// resolveSourceDir() points to the CLI package's own .claude/ — no download needed.
|
|
376
474
|
mergeSettingsJson(resolveSourceDir(), targetDir);
|
|
475
|
+
await maybeSyncCodex({ projectRoot, codexRunner });
|
|
377
476
|
return;
|
|
378
477
|
}
|
|
379
478
|
|
|
@@ -444,8 +543,13 @@ export async function updateAction(options = {}, deps = {}) {
|
|
|
444
543
|
}
|
|
445
544
|
}
|
|
446
545
|
}
|
|
546
|
+
await maybeSyncCodex({ projectRoot, codexRunner });
|
|
447
547
|
} catch (err) {
|
|
448
|
-
|
|
548
|
+
// S3+S4: scrub tokens and absolute paths from error messages before surfacing
|
|
549
|
+
let storedToken = null;
|
|
550
|
+
try { storedToken = readStoredToken(); } catch { /* ignore — token may not be stored */ }
|
|
551
|
+
const safeMessage = redactSecrets(err.message ?? String(err), { storedToken });
|
|
552
|
+
process.stderr.write(chalk.red(`Error: ${safeMessage}\n`));
|
|
449
553
|
process.exit(1);
|
|
450
554
|
} finally {
|
|
451
555
|
if (tempDir) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codex-rewrite.js — Shared kit-path rewriter for Codex conversion pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Rewrites kit-relative `.claude/<subdir>/` references to `.codex/<subdir>/`
|
|
5
|
+
* so that Codex-runtime skill/workflow/agent prose resolves correctly under
|
|
6
|
+
* `.codex/` rather than the source `.claude/` tree.
|
|
7
|
+
*
|
|
8
|
+
* Covered subdirectories: workflows, skills, agents, commands, hooks.
|
|
9
|
+
*
|
|
10
|
+
* Properties guaranteed:
|
|
11
|
+
* - Idempotent: rewriteKitPaths(rewriteKitPaths(x)) === rewriteKitPaths(x)
|
|
12
|
+
* because the output `.codex/` prefix never matches the input pattern.
|
|
13
|
+
* - CRLF-safe: uses String.prototype.replace only (no split/join).
|
|
14
|
+
* - Allowlist-scoped: only the 5 listed subdirs match. Bare
|
|
15
|
+
* `.claude/settings.json` (no subdir) is untouched. Global-install
|
|
16
|
+
* paths like `~/.claude/skills/X` ARE rewritten to `~/.codex/skills/X`
|
|
17
|
+
* because under Codex runtime the global install lives at `~/.codex/`,
|
|
18
|
+
* not `~/.claude/`. Hook scripts (`.cjs`/`.js`) bypass this pipeline
|
|
19
|
+
* entirely (the hooks converter byte-copies them), so intentional
|
|
20
|
+
* `.claude/hooks/.logs/` runtime constants survive untouched.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// Substring match is intentional: `~/.claude/skills/X` becomes
|
|
24
|
+
// `~/.codex/skills/X`. No prefix anchor (handles backtick, quote, space,
|
|
25
|
+
// start-of-string, and ~/ uniformly).
|
|
26
|
+
const _KIT_PATH_RE = /\.claude\/(workflows|skills|agents|commands|hooks)\//g;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Rewrite `.claude/<subdir>/` references to `.codex/<subdir>/` in `text`.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} text - Input text (may contain multiple refs across multiple lines).
|
|
32
|
+
* @returns {string} - Text with all kit-internal path references rewritten.
|
|
33
|
+
*/
|
|
34
|
+
export function rewriteKitPaths(text) {
|
|
35
|
+
return text.replace(_KIT_PATH_RE, '.codex/$1/');
|
|
36
|
+
}
|
package/src/lib/constants.js
CHANGED
|
@@ -24,6 +24,7 @@ export const COPY_FILTER_PATTERNS = [
|
|
|
24
24
|
'.pytest_cache',
|
|
25
25
|
'.pyc',
|
|
26
26
|
'.pyo',
|
|
27
|
+
'.coverage',
|
|
27
28
|
'node_modules',
|
|
28
29
|
'.DS_Store',
|
|
29
30
|
'package-lock.json',
|
|
@@ -45,3 +46,28 @@ export const GITHUB_API = 'https://api.github.com';
|
|
|
45
46
|
* Kit repository (owner/repo)
|
|
46
47
|
*/
|
|
47
48
|
export const KIT_REPO = 'khanhtran148/mk-kit';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The Claude Code tool directory name (the `.claude/` folder).
|
|
52
|
+
* Used by the Codex converter to locate agent + skill source files.
|
|
53
|
+
* NOTE: A parent path-abstraction PR is planned to make this configurable
|
|
54
|
+
* via env var. Until that PR lands, this constant provides the default value.
|
|
55
|
+
*/
|
|
56
|
+
export const TOOL_DIR_NAME = '.claude';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Codex SKILL.md frontmatter allowlist used by kit validators.
|
|
60
|
+
* Includes `model` because convert-skills-to-codex.js writes a mapped model value
|
|
61
|
+
* (Claude tier → Codex model ID via MODEL_MAP). The strict Codex quick_validate.py
|
|
62
|
+
* allowlist omits `model`, but the kit writes it intentionally for runtime guidance.
|
|
63
|
+
* Cross-reference: CLAUDE_ONLY_SKILL_FM_KEYS (denylist for keys that are stripped).
|
|
64
|
+
*/
|
|
65
|
+
export const CODEX_SKILL_FM_ALLOWED = Object.freeze(['name', 'description', 'license', 'allowed-tools', 'metadata', 'model']);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Claude-Code-only SKILL.md frontmatter keys that are stripped (not transformed)
|
|
69
|
+
* when mirroring to .codex/skills/ — they have no Codex skill-level equivalent.
|
|
70
|
+
* Note: `model` is NOT in this set; it is transformed via MODEL_MAP instead.
|
|
71
|
+
* Cross-reference: CODEX_SKILL_FM_ALLOWED (allowlist on validate).
|
|
72
|
+
*/
|
|
73
|
+
export const CLAUDE_ONLY_SKILL_FM_KEYS = Object.freeze(['effort', 'argument-hint']);
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runtime-codex.js — MODEL_MAP and model resolution helpers for the Codex converter.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* MODEL_MAP — frozen default map of Claude model tiers → Codex model IDs
|
|
6
|
+
* loadModelMap() — merge user override TOML over the frozen default
|
|
7
|
+
* resolveModel() — look up a Claude model name in a model map; warn on miss
|
|
8
|
+
*
|
|
9
|
+
* Design notes
|
|
10
|
+
* ------------
|
|
11
|
+
* K3=3a: hardcoded MODEL_MAP with per-invocation --model-map override flag.
|
|
12
|
+
* The default map is intentionally small and frozen; future model IDs arrive
|
|
13
|
+
* via the override TOML, not via source edits.
|
|
14
|
+
*
|
|
15
|
+
* ARP-02: unknown model names produce a console.warn (non-fatal) and return
|
|
16
|
+
* undefined so the caller may omit the `model` field from the emitted TOML.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Minimal TOML parser for the model-map override format.
|
|
23
|
+
* Supports only [section] headers and `key = "string"` entries.
|
|
24
|
+
* Throws on unrecognised syntax.
|
|
25
|
+
* @param {string} raw
|
|
26
|
+
* @returns {{ [section: string]: { [key: string]: string } }}
|
|
27
|
+
*/
|
|
28
|
+
function parseModelMapToml(raw) {
|
|
29
|
+
const result = {};
|
|
30
|
+
let section = null;
|
|
31
|
+
for (const rawLine of raw.split('\n')) {
|
|
32
|
+
const line = rawLine.replace(/\r$/, '').trim();
|
|
33
|
+
if (!line || line.startsWith('#')) continue;
|
|
34
|
+
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
|
|
35
|
+
if (sectionMatch) {
|
|
36
|
+
section = sectionMatch[1].trim();
|
|
37
|
+
result[section] = {};
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const kvMatch = line.match(/^([A-Za-z0-9_-]+)\s*=\s*"([^"]*)"$/);
|
|
41
|
+
if (kvMatch && section) {
|
|
42
|
+
result[section][kvMatch[1]] = kvMatch[2];
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
throw new Error(`Unrecognised TOML line: ${line}`);
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Default mapping from Claude model-tier short names to Codex model IDs.
|
|
52
|
+
* Keys are the values that appear in agent YAML frontmatter `model:` fields.
|
|
53
|
+
* @type {Readonly<{[claudeModel: string]: string}>}
|
|
54
|
+
*/
|
|
55
|
+
export const MODEL_MAP = Object.freeze({
|
|
56
|
+
opus: 'gpt-5',
|
|
57
|
+
sonnet: 'gpt-5-mini',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Load a model map, optionally merging a user-supplied TOML override.
|
|
62
|
+
*
|
|
63
|
+
* @param {string|undefined} overridePath Absolute path to a TOML file that
|
|
64
|
+
* contains a `[model_map]` table, e.g.:
|
|
65
|
+
* ```toml
|
|
66
|
+
* [model_map]
|
|
67
|
+
* opus = "gpt-5"
|
|
68
|
+
* sonnet = "gpt-5-mini"
|
|
69
|
+
* haiku = "gpt-4o-mini"
|
|
70
|
+
* ```
|
|
71
|
+
* When `undefined`, returns the frozen default MODEL_MAP.
|
|
72
|
+
* @returns {Readonly<{[claudeModel: string]: string}>}
|
|
73
|
+
* @throws {Error} If `overridePath` is provided but the file cannot be read
|
|
74
|
+
* or parsed, or if it does not contain a `[model_map]` table.
|
|
75
|
+
*/
|
|
76
|
+
export function loadModelMap(overridePath) {
|
|
77
|
+
if (overridePath === undefined) {
|
|
78
|
+
return MODEL_MAP;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let raw;
|
|
82
|
+
try {
|
|
83
|
+
raw = readFileSync(overridePath, 'utf8');
|
|
84
|
+
} catch (err) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`[runtime-codex] Cannot read model-map override file: ${overridePath}\n${err.message}`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = parseModelMapToml(raw);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`[runtime-codex] Failed to parse model-map override TOML: ${overridePath}\n${err.message}`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!parsed.model_map || typeof parsed.model_map !== 'object') {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`[runtime-codex] Override TOML must contain a [model_map] table: ${overridePath}`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// H3: Validate model-map values to prevent TOML/YAML injection via crafted
|
|
106
|
+
// model names that contain whitespace, colons, or newlines.
|
|
107
|
+
const MODEL_VALUE_RE = /^[A-Za-z0-9._-]+$/;
|
|
108
|
+
for (const [k, v] of Object.entries(parsed.model_map)) {
|
|
109
|
+
if (!MODEL_VALUE_RE.test(String(v))) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`[runtime-codex] Invalid model-map value for "${k}": "${v}". ` +
|
|
112
|
+
'Values must match /^[A-Za-z0-9._-]+$/ (no whitespace, colons, or newlines).'
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return Object.freeze({ ...MODEL_MAP, ...parsed.model_map });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve a Claude model-tier name to a Codex model ID.
|
|
122
|
+
*
|
|
123
|
+
* @param {string|undefined} claudeModel Value from agent frontmatter `model:` field.
|
|
124
|
+
* May be undefined (agent has no `model:` declaration).
|
|
125
|
+
* @param {Readonly<{[claudeModel: string]: string}>} modelMap Map returned by
|
|
126
|
+
* `loadModelMap()`.
|
|
127
|
+
* @returns {string|undefined} Codex model ID, or `undefined` when:
|
|
128
|
+
* - `claudeModel` is undefined (caller should omit the `model` field)
|
|
129
|
+
* - `claudeModel` is not in the map (ARP-02: warn emitted to stderr)
|
|
130
|
+
*/
|
|
131
|
+
export function resolveModel(claudeModel, modelMap) {
|
|
132
|
+
if (claudeModel === undefined) {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const codexModel = modelMap[claudeModel];
|
|
137
|
+
if (codexModel === undefined) {
|
|
138
|
+
// ARP-02: unknown model — non-fatal, let caller omit the field
|
|
139
|
+
process.stderr.write(
|
|
140
|
+
`[runtime-codex] Warning: unknown model tier "${claudeModel}" — ` +
|
|
141
|
+
`model field will be omitted from emitted TOML. ` +
|
|
142
|
+
`Add an entry to your --model-map override file to suppress this warning.\n`
|
|
143
|
+
);
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return codexModel;
|
|
148
|
+
}
|