Skip to content

Commit f56e4a2

Browse files
authored
Add Codex setup command for npm wrapper (#94)
* Add Codex setup command for npm wrapper * Set Codex MCP timeouts during setup * Install unch as a Codex skill * Teach Codex skill to pass MCP directory --------- Co-authored-by: uchebnick <uchebnick@users.noreply.github.com>
1 parent 3668ed2 commit f56e4a2

7 files changed

Lines changed: 395 additions & 4 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@ npm is the recommended install path:
3131

3232
```bash
3333
npm install -g @uchebnick/unch
34+
unch codex install
3435
```
3536

3637
It installs the `unch` CLI and downloads the matching native release binary for your platform.
38+
Run `unch codex install` once to register unch in Codex through `codex mcp add` and install the `unch` skill in `~/.codex/skills/unch/SKILL.md`, then restart Codex.
3739

3840
Homebrew on macOS:
3941

mintlify/installation.mdx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,16 @@ On Windows source builds, that means an MSYS2 or MinGW-style toolchain, or an eq
4242
unch --version
4343
unch --help
4444
```
45+
46+
To use unch inside Codex as MCP tools plus the `unch` skill:
47+
48+
```bash
49+
unch codex install
50+
```
51+
52+
Then restart Codex. The setup command uses `codex mcp add` for the MCP server and writes the skill to `~/.codex/skills/unch/SKILL.md`.
53+
54+
If you prefer not to add Codex integration, you can skip `unch codex install`.
4555
</Tab>
4656

4757
<Tab title="Homebrew (macOS)">
@@ -142,6 +152,12 @@ unch --version
142152
unch --help
143153
```
144154

155+
If you installed through npm and want Codex integration:
156+
157+
```bash
158+
unch codex install
159+
```
160+
145161
## Configure OpenRouter
146162

147163
If you want to use OpenRouter for embeddings instead of the local `llama.cpp` runtime:

npm/unch/README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,18 @@ unch --help
1010

1111
The package downloads the matching native binary from GitHub Releases during `postinstall`.
1212

13+
## Codex setup
14+
15+
To make unch available in Codex as both MCP tools and the `unch` skill, run:
16+
17+
```bash
18+
unch codex install
19+
```
20+
21+
Then restart Codex. The installer uses `codex mcp add` for the MCP server and writes the skill to `~/.codex/skills/unch/SKILL.md`.
22+
23+
The npm `postinstall` step does not modify your Codex config automatically. This is intentional: installing a package should not silently mutate `~/.codex/config.toml`.
24+
1325
## MCP
1426

1527
For MCP clients, use:
@@ -19,9 +31,9 @@ For MCP clients, use:
1931
- Arguments: leave empty
2032
- Working directory: the repository you want to search
2133

22-
`unch-mcp` is a small launcher for `unch start mcp`. The MCP server also exposes a prompt named `unch`, so clients that render MCP prompts as slash commands can show it as `/unch`.
34+
`unch-mcp` is a small launcher for `unch start mcp`.
2335

24-
If your client supports MCP prompts, run `/unch` before a codebase question to nudge the assistant to call `workspace_status`, `search_code`, and `index_repository` in the right order.
36+
For Codex CLI specifically, `unch codex install` also creates a local reusable skill, so Codex knows when to call `workspace_status`, `search_code`, and `index_repository` in the right order.
2537

2638
Supported targets:
2739

npm/unch/bin/unch.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ const fs = require("node:fs");
55
const path = require("node:path");
66
const { spawnSync } = require("node:child_process");
77

8+
const args = process.argv.slice(2);
9+
10+
if (args[0] === "codex") {
11+
const { main } = require("../scripts/codex-install");
12+
main(args.slice(1)).catch((error) => {
13+
console.error(error.message);
14+
process.exit(1);
15+
});
16+
return;
17+
}
18+
819
const binaryName = process.platform === "win32" ? "unch.exe" : "unch";
920
const binaryPath = path.join(__dirname, "..", "vendor", binaryName);
1021

@@ -14,7 +25,7 @@ if (!fs.existsSync(binaryPath)) {
1425
process.exit(1);
1526
}
1627

17-
const result = spawnSync(binaryPath, process.argv.slice(2), {
28+
const result = spawnSync(binaryPath, args, {
1829
stdio: "inherit",
1930
windowsHide: false
2031
});

npm/unch/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
},
1919
"scripts": {
2020
"postinstall": "node scripts/install.js",
21-
"test": "node scripts/platform.test.js",
21+
"test": "node scripts/platform.test.js && node scripts/codex-install.test.js",
2222
"test:mcp": "node scripts/mcp-smoke.js"
2323
},
2424
"files": [

npm/unch/scripts/codex-install.js

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
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

Comments
 (0)