|
| 1 | +#!/usr/bin/env node |
| 2 | +"use strict"; |
| 3 | + |
| 4 | +const fs = require("node:fs"); |
| 5 | +const os = require("node:os"); |
| 6 | +const path = require("node:path"); |
| 7 | +const { spawnSync } = require("node:child_process"); |
| 8 | + |
| 9 | +const packageRoot = path.join(__dirname, ".."); |
| 10 | +const mcpLauncher = path.join(packageRoot, "bin", "unch-mcp.js"); |
| 11 | +const defaultStartupTimeoutSec = 60; |
| 12 | +const defaultToolTimeoutSec = 300; |
| 13 | + |
| 14 | +async function main(argv = process.argv.slice(2), env = process.env) { |
| 15 | + const options = parseArgs(argv); |
| 16 | + if (options.help) { |
| 17 | + process.stdout.write(helpText()); |
| 18 | + return; |
| 19 | + } |
| 20 | + |
| 21 | + if (options.command !== "install") { |
| 22 | + throw new Error(`unknown unch Codex command: ${options.command}`); |
| 23 | + } |
| 24 | + |
| 25 | + const codexHome = path.resolve(options.codexHome || env.CODEX_HOME || path.join(os.homedir(), ".codex")); |
| 26 | + const skillPath = path.join(codexHome, "skills", "unch", "SKILL.md"); |
| 27 | + const legacyPromptPath = path.join(codexHome, "prompts", "unch.md"); |
| 28 | + const mcpCommand = options.mcpCommand || process.execPath; |
| 29 | + const mcpArgs = options.mcpArgs.length > 0 ? options.mcpArgs : [mcpLauncher]; |
| 30 | + |
| 31 | + if (options.dryRun) { |
| 32 | + process.stdout.write([ |
| 33 | + "Would register Codex MCP server:", |
| 34 | + ` codex mcp add unch -- ${shellJoin([mcpCommand, ...mcpArgs])}`, |
| 35 | + `Would set startup_timeout_sec = ${defaultStartupTimeoutSec}`, |
| 36 | + `Would set tool_timeout_sec = ${defaultToolTimeoutSec}`, |
| 37 | + "Would install Codex skill:", |
| 38 | + ` ${skillPath}`, |
| 39 | + "Would remove legacy slash prompt if present:", |
| 40 | + ` ${legacyPromptPath}`, |
| 41 | + "" |
| 42 | + ].join("\n")); |
| 43 | + return; |
| 44 | + } |
| 45 | + |
| 46 | + if (!options.skipMcp) { |
| 47 | + registerMcp({ |
| 48 | + codexBin: options.codexBin || env.CODEX_BIN || "codex", |
| 49 | + codexHome, |
| 50 | + command: mcpCommand, |
| 51 | + args: mcpArgs |
| 52 | + }); |
| 53 | + configureMcpTimeouts(codexHome, { |
| 54 | + startupTimeoutSec: defaultStartupTimeoutSec, |
| 55 | + toolTimeoutSec: defaultToolTimeoutSec |
| 56 | + }); |
| 57 | + } |
| 58 | + |
| 59 | + if (!options.skipSkill) { |
| 60 | + installSkill(skillPath); |
| 61 | + removeLegacyPrompt(legacyPromptPath); |
| 62 | + } |
| 63 | + |
| 64 | + process.stdout.write([ |
| 65 | + "Installed unch for Codex.", |
| 66 | + options.skipMcp ? "" : "MCP server: unch", |
| 67 | + options.skipSkill ? "" : "Skill: unch", |
| 68 | + "Restart Codex to load the new MCP server and skill.", |
| 69 | + "" |
| 70 | + ].filter(Boolean).join("\n")); |
| 71 | +} |
| 72 | + |
| 73 | +function parseArgs(argv) { |
| 74 | + const options = { |
| 75 | + command: "install", |
| 76 | + codexBin: "", |
| 77 | + codexHome: "", |
| 78 | + dryRun: false, |
| 79 | + help: false, |
| 80 | + mcpArgs: [], |
| 81 | + mcpCommand: "", |
| 82 | + skipMcp: false, |
| 83 | + skipSkill: false |
| 84 | + }; |
| 85 | + |
| 86 | + const args = [...argv]; |
| 87 | + if (args[0] && !args[0].startsWith("-")) { |
| 88 | + options.command = args.shift(); |
| 89 | + } |
| 90 | + |
| 91 | + while (args.length > 0) { |
| 92 | + const arg = args.shift(); |
| 93 | + switch (arg) { |
| 94 | + case "-h": |
| 95 | + case "--help": |
| 96 | + options.help = true; |
| 97 | + break; |
| 98 | + case "--codex-bin": |
| 99 | + options.codexBin = takeValue(arg, args); |
| 100 | + break; |
| 101 | + case "--codex-home": |
| 102 | + options.codexHome = takeValue(arg, args); |
| 103 | + break; |
| 104 | + case "--dry-run": |
| 105 | + options.dryRun = true; |
| 106 | + break; |
| 107 | + case "--mcp-command": |
| 108 | + options.mcpCommand = takeValue(arg, args); |
| 109 | + break; |
| 110 | + case "--mcp-arg": |
| 111 | + options.mcpArgs.push(takeValue(arg, args)); |
| 112 | + break; |
| 113 | + case "--skip-mcp": |
| 114 | + options.skipMcp = true; |
| 115 | + break; |
| 116 | + case "--skip-skill": |
| 117 | + options.skipSkill = true; |
| 118 | + break; |
| 119 | + default: |
| 120 | + throw new Error(`unknown option: ${arg}`); |
| 121 | + } |
| 122 | + } |
| 123 | + |
| 124 | + return options; |
| 125 | +} |
| 126 | + |
| 127 | +function takeValue(option, args) { |
| 128 | + const value = args.shift(); |
| 129 | + if (!value) { |
| 130 | + throw new Error(`${option} requires a value`); |
| 131 | + } |
| 132 | + return value; |
| 133 | +} |
| 134 | + |
| 135 | +function registerMcp({ codexBin, codexHome, command, args }) { |
| 136 | + const result = spawnSync(codexBin, ["mcp", "add", "unch", "--", command, ...args], { |
| 137 | + env: { ...process.env, CODEX_HOME: codexHome }, |
| 138 | + encoding: "utf8", |
| 139 | + stdio: ["ignore", "pipe", "pipe"], |
| 140 | + windowsHide: true |
| 141 | + }); |
| 142 | + |
| 143 | + if (result.error) { |
| 144 | + throw new Error(`failed to run ${codexBin}: ${result.error.message}`); |
| 145 | + } |
| 146 | + if (result.status !== 0) { |
| 147 | + const details = (result.stderr || result.stdout || "").trim(); |
| 148 | + throw new Error(`failed to register unch MCP with Codex${details ? `: ${details}` : ""}`); |
| 149 | + } |
| 150 | +} |
| 151 | + |
| 152 | +function installSkill(skillPath) { |
| 153 | + fs.mkdirSync(path.dirname(skillPath), { recursive: true }); |
| 154 | + fs.writeFileSync(skillPath, skillText(), { mode: 0o644 }); |
| 155 | +} |
| 156 | + |
| 157 | +function removeLegacyPrompt(promptPath) { |
| 158 | + if (fs.existsSync(promptPath)) { |
| 159 | + fs.rmSync(promptPath, { force: true }); |
| 160 | + } |
| 161 | +} |
| 162 | + |
| 163 | +function configureMcpTimeouts(codexHome, { startupTimeoutSec, toolTimeoutSec }) { |
| 164 | + const configPath = path.join(codexHome, "config.toml"); |
| 165 | + const text = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : ""; |
| 166 | + const lines = text.split(/\r?\n/); |
| 167 | + const header = "[mcp_servers.unch]"; |
| 168 | + const start = lines.findIndex((line) => line.trim() === header); |
| 169 | + const timeoutLines = [ |
| 170 | + `startup_timeout_sec = ${startupTimeoutSec}`, |
| 171 | + `tool_timeout_sec = ${toolTimeoutSec}` |
| 172 | + ]; |
| 173 | + |
| 174 | + if (start === -1) { |
| 175 | + const prefix = text.trim() ? `${text.replace(/\s*$/, "")}\n\n` : ""; |
| 176 | + fs.mkdirSync(codexHome, { recursive: true }); |
| 177 | + fs.writeFileSync(configPath, `${prefix}${header}\n${timeoutLines.join("\n")}\n`); |
| 178 | + return; |
| 179 | + } |
| 180 | + |
| 181 | + let end = lines.length; |
| 182 | + for (let i = start + 1; i < lines.length; i += 1) { |
| 183 | + if (/^\s*\[/.test(lines[i])) { |
| 184 | + end = i; |
| 185 | + break; |
| 186 | + } |
| 187 | + } |
| 188 | + |
| 189 | + const block = lines.slice(start, end).filter((line) => { |
| 190 | + const trimmed = line.trim(); |
| 191 | + return !trimmed.startsWith("startup_timeout_sec") && !trimmed.startsWith("tool_timeout_sec"); |
| 192 | + }); |
| 193 | + const nextLines = [ |
| 194 | + ...lines.slice(0, start), |
| 195 | + ...block, |
| 196 | + ...timeoutLines, |
| 197 | + ...lines.slice(end) |
| 198 | + ]; |
| 199 | + fs.writeFileSync(configPath, `${nextLines.join("\n").replace(/\s*$/, "")}\n`); |
| 200 | +} |
| 201 | + |
| 202 | +function skillText() { |
| 203 | + return [ |
| 204 | + "---", |
| 205 | + "name: unch", |
| 206 | + "description: Use when working in a code repository and the user asks to find, understand, debug, review, or modify code. Prefer unch semantic code search before broad file reads, especially for concepts, APIs, implementations, identifiers, error paths, or architecture questions.", |
| 207 | + "---", |
| 208 | + "", |
| 209 | + "# unch", |
| 210 | + "", |
| 211 | + "Use unch semantic code search for the current repository before broad file reads.", |
| 212 | + "", |
| 213 | + "Always pass `directory` as the absolute path of the current repository/workspace in every unch MCP tool call. Do not rely on the MCP server launch directory when the workspace path is known.", |
| 214 | + "", |
| 215 | + "Workflow:", |
| 216 | + "1. Call the unch MCP tool `workspace_status` with `directory`.", |
| 217 | + "2. If the index is missing for the current provider/model, call `index_repository` once with the same `directory`.", |
| 218 | + "3. Call `search_code` with the same `directory` and my task, bug, feature, identifier, or concept.", |
| 219 | + "4. Use `details=true` when signatures, comments, docs, or compact body snippets help choose exact files.", |
| 220 | + "5. Treat results as ranked candidates and open returned paths before editing.", |
| 221 | + "", |
| 222 | + "If the unch MCP tools are unavailable, tell me to run `unch codex install` and restart Codex." |
| 223 | + ].join("\n"); |
| 224 | +} |
| 225 | + |
| 226 | +function helpText() { |
| 227 | + return [ |
| 228 | + "Usage: unch codex install [options]", |
| 229 | + "", |
| 230 | + "Registers unch with Codex as an MCP server and installs the unch skill.", |
| 231 | + "", |
| 232 | + "Options:", |
| 233 | + " --codex-bin <path> Codex executable to call (default: codex)", |
| 234 | + " --codex-home <path> Codex home directory (default: $CODEX_HOME or ~/.codex)", |
| 235 | + " --dry-run Print planned changes without writing anything", |
| 236 | + " --mcp-command <path> MCP command to register (default: current node)", |
| 237 | + " --mcp-arg <value> MCP command argument; repeatable", |
| 238 | + " --skip-mcp Do not register the MCP server", |
| 239 | + " --skip-skill Do not install the unch skill", |
| 240 | + " -h, --help Show this help", |
| 241 | + "" |
| 242 | + ].join("\n"); |
| 243 | +} |
| 244 | + |
| 245 | +function shellJoin(parts) { |
| 246 | + return parts.map((part) => { |
| 247 | + if (/^[A-Za-z0-9_./:=+-]+$/.test(part)) { |
| 248 | + return part; |
| 249 | + } |
| 250 | + return `'${String(part).replace(/'/g, "'\\''")}'`; |
| 251 | + }).join(" "); |
| 252 | +} |
| 253 | + |
| 254 | +if (require.main === module) { |
| 255 | + main().catch((error) => { |
| 256 | + console.error(error.message); |
| 257 | + process.exit(1); |
| 258 | + }); |
| 259 | +} |
| 260 | + |
| 261 | +module.exports = { |
| 262 | + configureMcpTimeouts, |
| 263 | + main, |
| 264 | + parseArgs, |
| 265 | + skillText |
| 266 | +}; |
0 commit comments