@concept-ai/workspace 0.1.0

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 ADDED
@@ -0,0 +1,120 @@
1
+ # Concept Workspace MCP Installer
2
+
3
+ Install Concept Workspace MCP into terminal agents.
4
+
5
+ ## Codex
6
+
7
+ Run:
8
+
9
+ ```bash
10
+ npm exec --yes --package @concept-ai/workspace@latest -- concept-workspace-mcp install codex
11
+ ```
12
+
13
+ The installer writes the Codex MCP server entry with the required OAuth scopes:
14
+
15
+ ```toml
16
+ [mcp_servers.concept]
17
+ url = "https://workspace-api.concept.dev/mcp"
18
+ scopes = ["openid", "profile", "email", "offline_access"]
19
+ ```
20
+
21
+ After the config is written, the installer offers to run:
22
+
23
+ ```bash
24
+ codex mcp login concept
25
+ ```
26
+
27
+ You should not need to pass `--scopes` to `codex mcp login`.
28
+
29
+ ## Claude Code
30
+
31
+ Run:
32
+
33
+ ```bash
34
+ npm exec --yes --package @concept-ai/workspace@latest -- concept-workspace-mcp install claude-code
35
+ ```
36
+
37
+ The installer registers Concept Workspace with Claude Code. When the `claude`
38
+ CLI is available, it delegates to:
39
+
40
+ ```bash
41
+ claude mcp add --transport http --scope user concept https://workspace-api.concept.dev/mcp
42
+ ```
43
+
44
+ If the `claude` CLI is not available, it falls back to writing Claude Code's
45
+ user config with:
46
+
47
+ ```json
48
+ {
49
+ "mcpServers": {
50
+ "concept": {
51
+ "type": "http",
52
+ "url": "https://workspace-api.concept.dev/mcp"
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ Claude Code opens the OAuth sign-in flow the first time it uses a Workspace
59
+ tool. The Claude Code config does not need a `scopes` field because Claude Code
60
+ discovers scopes from Workspace MCP metadata.
61
+
62
+ ## Troubleshooting
63
+
64
+ If an MCP client was already running before install, quit and restart it so it
65
+ reloads the updated config. If Codex still reports an OAuth refresh error after
66
+ a clean install, run:
67
+
68
+ ```bash
69
+ codex mcp logout concept
70
+ codex mcp login concept
71
+ ```
72
+
73
+ ## Local tarball validation
74
+
75
+ Before publishing, validate the packed artifact through `npx` with temporary
76
+ agent config directories:
77
+
78
+ ```bash
79
+ tmp="$(mktemp -d)"
80
+ npm pack --workspace @concept-ai/workspace --pack-destination "$tmp"
81
+
82
+ CODEX_HOME="$tmp/codex" \
83
+ npx --yes --package "$tmp"/concept-ai-workspace-0.1.0.tgz \
84
+ concept-workspace-mcp install codex --no-login --yes
85
+ CODEX_HOME="$tmp/codex" codex mcp get concept
86
+
87
+ CODEX_HOME="$tmp/codex" \
88
+ npx --yes --package "$tmp"/concept-ai-workspace-0.1.0.tgz \
89
+ concept-workspace-mcp install codex --dry-run --no-login --yes
90
+
91
+ CLAUDE_CONFIG_DIR="$tmp/claude" \
92
+ npx --yes --package "$tmp"/concept-ai-workspace-0.1.0.tgz \
93
+ concept-workspace-mcp install claude-code --no-cli --yes
94
+ ```
95
+
96
+ Inspect `$tmp/codex/config.toml` for the Workspace MCP URL plus required
97
+ Codex OAuth scopes. Inspect `$tmp/claude/.claude.json` for
98
+ `mcpServers.concept.type = "http"`, the Workspace MCP URL, and no `scopes`
99
+ field.
100
+
101
+ If the `claude` CLI is installed, also validate CLI delegation:
102
+
103
+ ```bash
104
+ CLAUDE_CONFIG_DIR="$tmp/claude-cli" \
105
+ npx --yes --package "$tmp"/concept-ai-workspace-0.1.0.tgz \
106
+ concept-workspace-mcp install claude-code --yes
107
+ CLAUDE_CONFIG_DIR="$tmp/claude-cli" claude mcp get concept
108
+ ```
109
+
110
+ ## Publishing
111
+
112
+ First-time publish for the renamed npm package:
113
+
114
+ ```bash
115
+ npm run test --workspace @concept-ai/workspace
116
+ npm pack --dry-run --workspace @concept-ai/workspace
117
+ npm publish --workspace @concept-ai/workspace --access public
118
+ ```
119
+
120
+ This package is the canonical npm installer for Concept Workspace.
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from '../src/cli.mjs';
4
+
5
+ try {
6
+ const exitCode = await main(process.argv.slice(2), {
7
+ stderr: process.stderr,
8
+ stdin: process.stdin,
9
+ stdout: process.stdout,
10
+ });
11
+ process.exitCode = exitCode;
12
+ } catch (error) {
13
+ const message = error instanceof Error ? error.message : String(error);
14
+ process.stderr.write(`${message}\n`);
15
+ process.exitCode = 1;
16
+ }
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@concept-ai/workspace",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "bin": {
7
+ "concept-workspace-mcp": "bin/concept-workspace-mcp.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "test": "node test/run-tests.mjs",
16
+ "pack:dry-run": "npm pack --dry-run"
17
+ },
18
+ "dependencies": {
19
+ "smol-toml": "^1.3.3"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public",
23
+ "registry": "https://registry.npmjs.org/"
24
+ }
25
+ }
@@ -0,0 +1,130 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ import { DEFAULT_CODEX_MCP_SERVER_NAME, DEFAULT_WORKSPACE_MCP_URL } from './codex-config.mjs';
7
+
8
+ export function resolveClaudeConfigPath({ claudeConfigDir } = {}) {
9
+ const root = claudeConfigDir || process.env.CLAUDE_CONFIG_DIR || os.homedir();
10
+ return path.join(root, '.claude.json');
11
+ }
12
+
13
+ export async function readClaudeConfig(configPath) {
14
+ try {
15
+ const metadata = await stat(configPath);
16
+ if (!metadata.isFile()) {
17
+ throw new Error(`Claude Code config path is not a regular file: ${configPath}`);
18
+ }
19
+ return await readFile(configPath, 'utf8');
20
+ } catch (error) {
21
+ if (error?.code === 'ENOENT') {
22
+ return '';
23
+ }
24
+ throw error;
25
+ }
26
+ }
27
+
28
+ export function planClaudeCodeMcpInstall({
29
+ sourceText = '',
30
+ name = DEFAULT_CODEX_MCP_SERVER_NAME,
31
+ url = DEFAULT_WORKSPACE_MCP_URL,
32
+ force = false,
33
+ } = {}) {
34
+ assertNonEmptyString(name, 'Claude Code MCP server name');
35
+ assertNonEmptyString(url, 'Claude Code MCP URL');
36
+
37
+ const parsed = parseJsonConfig(sourceText);
38
+ const mcpServers = parsed.mcpServers ?? {};
39
+ if (!isPlainObject(mcpServers)) {
40
+ throw new Error('Existing Claude Code config has invalid mcpServers value; expected an object.');
41
+ }
42
+
43
+ const existing = mcpServers[name];
44
+ if (existing !== undefined && !isPlainObject(existing)) {
45
+ throw new Error(`Existing Claude Code MCP server "${name}" is invalid; expected an object.`);
46
+ }
47
+
48
+ const existingUrl = typeof existing?.url === 'string' ? existing.url : null;
49
+ if (existingUrl && existingUrl !== url && !force) {
50
+ throw new Error(
51
+ `Claude Code MCP server "${name}" already exists and points at ${existingUrl}. Re-run with --force to replace it.`,
52
+ );
53
+ }
54
+
55
+ const nextConfig = {
56
+ ...parsed,
57
+ mcpServers: {
58
+ ...mcpServers,
59
+ [name]: {
60
+ type: 'http',
61
+ url,
62
+ },
63
+ },
64
+ };
65
+ const text = `${JSON.stringify(nextConfig, null, 2)}\n`;
66
+
67
+ return {
68
+ action: existing === undefined ? 'create' : (text === normalizeJsonText(parsed) ? 'unchanged' : 'update'),
69
+ changed: text !== sourceText,
70
+ entryExisted: existing !== undefined,
71
+ text,
72
+ };
73
+ }
74
+
75
+ export async function writeFileAtomic(configPath, text) {
76
+ await mkdir(path.dirname(configPath), { recursive: true });
77
+ try {
78
+ const metadata = await stat(configPath);
79
+ if (!metadata.isFile()) {
80
+ throw new Error(`Claude Code config path is not a regular file: ${configPath}`);
81
+ }
82
+ } catch (error) {
83
+ if (error?.code !== 'ENOENT') {
84
+ throw error;
85
+ }
86
+ }
87
+
88
+ const tempPath = path.join(
89
+ path.dirname(configPath),
90
+ `.${path.basename(configPath)}.${process.pid}.${randomUUID()}.tmp`,
91
+ );
92
+ try {
93
+ await writeFile(tempPath, text, { mode: 0o600 });
94
+ await rename(tempPath, configPath);
95
+ } catch (error) {
96
+ await rm(tempPath, { force: true }).catch(() => {});
97
+ throw error;
98
+ }
99
+ }
100
+
101
+ function parseJsonConfig(sourceText) {
102
+ if (!sourceText.trim()) {
103
+ return {};
104
+ }
105
+ let parsed;
106
+ try {
107
+ parsed = JSON.parse(sourceText);
108
+ } catch (error) {
109
+ const detail = error instanceof Error ? error.message : String(error);
110
+ throw new Error(`Failed to parse existing Claude Code config: ${detail}`);
111
+ }
112
+ if (!isPlainObject(parsed)) {
113
+ throw new Error('Existing Claude Code config must be a JSON object.');
114
+ }
115
+ return parsed;
116
+ }
117
+
118
+ function normalizeJsonText(value) {
119
+ return `${JSON.stringify(value, null, 2)}\n`;
120
+ }
121
+
122
+ function assertNonEmptyString(value, label) {
123
+ if (typeof value !== 'string' || value.trim().length === 0) {
124
+ throw new Error(`${label} must be a non-empty string.`);
125
+ }
126
+ }
127
+
128
+ function isPlainObject(value) {
129
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
130
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,287 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createInterface } from 'node:readline/promises';
3
+
4
+ import {
5
+ DEFAULT_CODEX_MCP_SCOPES,
6
+ DEFAULT_CODEX_MCP_SERVER_NAME,
7
+ DEFAULT_WORKSPACE_MCP_URL,
8
+ planCodexMcpInstall,
9
+ readCodexConfig,
10
+ resolveCodexConfigPath,
11
+ writeFileAtomic as writeCodexFileAtomic,
12
+ } from './codex-config.mjs';
13
+ import {
14
+ planClaudeCodeMcpInstall,
15
+ readClaudeConfig,
16
+ resolveClaudeConfigPath,
17
+ writeFileAtomic as writeClaudeFileAtomic,
18
+ } from './claude-code-config.mjs';
19
+
20
+ const USAGE = `Usage:
21
+ concept-workspace-mcp install codex [options]
22
+ concept-workspace-mcp install claude-code [options]
23
+
24
+ Shared options:
25
+ --name <name> MCP server name. Default: concept
26
+ --url <url> MCP URL. Default: https://workspace-api.concept.dev/mcp
27
+ --force Replace an existing same-name server with a different URL.
28
+ --dry-run Print planned changes without writing.
29
+ --yes Do not prompt in interactive mode.
30
+ --help Print usage.
31
+
32
+ Codex options:
33
+ --codex-home <path> Override Codex home.
34
+ --login Run codex mcp login after config write.
35
+ --no-login Only print the login command.
36
+
37
+ Claude Code options:
38
+ --claude-config-dir <path> Override Claude Code config dir.
39
+ --scope <scope> claude mcp add scope. Default: user.
40
+ --no-cli Skip claude CLI delegation and write JSON directly.
41
+ `;
42
+
43
+ const SHARED_FLAGS = new Set(['force', 'dry-run', 'yes', 'help']);
44
+ const SHARED_VALUES = new Set(['name', 'url']);
45
+ const CODEX_FLAGS = new Set(['login', 'no-login']);
46
+ const CODEX_VALUES = new Set(['codex-home']);
47
+ const CLAUDE_FLAGS = new Set(['no-cli']);
48
+ const CLAUDE_VALUES = new Set(['claude-config-dir', 'scope']);
49
+ const CLAUDE_SCOPES = new Set(['user', 'local', 'project']);
50
+
51
+ export async function main(argv, io = {}) {
52
+ const context = createContext(io);
53
+
54
+ try {
55
+ if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
56
+ context.stdout.write(USAGE);
57
+ return 0;
58
+ }
59
+
60
+ const [command, host, ...rawOptions] = argv;
61
+ if (command !== 'install' || !['codex', 'claude-code'].includes(host)) {
62
+ context.stderr.write(USAGE);
63
+ return 1;
64
+ }
65
+
66
+ const options = parseOptions(rawOptions, host);
67
+ if (options.help) {
68
+ context.stdout.write(USAGE);
69
+ return 0;
70
+ }
71
+
72
+ if (host === 'codex') {
73
+ return await installCodex(options, context);
74
+ }
75
+ return await installClaudeCode(options, context);
76
+ } catch (error) {
77
+ context.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
78
+ return 1;
79
+ }
80
+ }
81
+
82
+ async function installCodex(options, context) {
83
+ if (options.login && options.noLogin) {
84
+ throw new Error('Use either --login or --no-login, not both.');
85
+ }
86
+
87
+ const name = options.name ?? DEFAULT_CODEX_MCP_SERVER_NAME;
88
+ const url = options.url ?? DEFAULT_WORKSPACE_MCP_URL;
89
+ const configPath = resolveCodexConfigPath({ codexHome: options.codexHome });
90
+ const sourceText = await readCodexConfig(configPath);
91
+ const plan = planCodexMcpInstall({
92
+ force: options.force,
93
+ name,
94
+ scopes: DEFAULT_CODEX_MCP_SCOPES,
95
+ sourceText,
96
+ url,
97
+ });
98
+
99
+ if (options.dryRun) {
100
+ context.stdout.write(`Dry run: would ${plan.changed ? 'write' : 'leave unchanged'} Codex config at ${configPath}\n`);
101
+ return 0;
102
+ }
103
+
104
+ if (plan.changed) {
105
+ await writeCodexFileAtomic(configPath, plan.text);
106
+ context.stdout.write(`Installed Concept Workspace MCP for Codex at ${configPath}\n`);
107
+ } else {
108
+ context.stdout.write(`Codex MCP config already up to date at ${configPath}\n`);
109
+ }
110
+
111
+ const loginCommand = `codex mcp login ${name}`;
112
+ const shouldLogin = await shouldRunCodexLogin(options, context, name);
113
+ if (!shouldLogin) {
114
+ context.stdout.write(`Run this to sign in:\n${loginCommand}\n`);
115
+ return 0;
116
+ }
117
+
118
+ const result = await context.runCommand('codex', ['mcp', 'login', name], { stdio: 'inherit' });
119
+ if (isCommandNotFound(result)) {
120
+ context.stderr.write(`codex executable was not found. Run this after installing Codex:\n${loginCommand}\n`);
121
+ return 127;
122
+ }
123
+ if (result.code !== 0) {
124
+ context.stderr.write(`codex mcp login failed with exit code ${result.code ?? 1}.\n`);
125
+ return result.code ?? 1;
126
+ }
127
+ return 0;
128
+ }
129
+
130
+ async function shouldRunCodexLogin(options, context, name) {
131
+ if (options.noLogin) {
132
+ return false;
133
+ }
134
+ if (options.login || options.yes) {
135
+ return true;
136
+ }
137
+ if (!context.stdin?.isTTY) {
138
+ return false;
139
+ }
140
+
141
+ const answer = await context.prompt(`Run codex mcp login ${name} now? [Y/n] `);
142
+ return answer.trim() === '' || answer.trim().toLowerCase() === 'y';
143
+ }
144
+
145
+ async function installClaudeCode(options, context) {
146
+ const name = options.name ?? DEFAULT_CODEX_MCP_SERVER_NAME;
147
+ const url = options.url ?? DEFAULT_WORKSPACE_MCP_URL;
148
+ const scope = options.scope ?? 'user';
149
+ if (!CLAUDE_SCOPES.has(scope)) {
150
+ throw new Error(`Claude Code scope must be one of: ${[...CLAUDE_SCOPES].join(', ')}.`);
151
+ }
152
+
153
+ if (options.dryRun) {
154
+ context.stdout.write(`Dry run: would install Concept Workspace MCP for Claude Code as "${name}".\n`);
155
+ return 0;
156
+ }
157
+
158
+ if (!options.noCli) {
159
+ const args = ['mcp', 'add', '--transport', 'http', '--scope', scope, name, url];
160
+ const result = await context.runCommand('claude', args, { stdio: 'inherit' });
161
+ if (result.code === 0) {
162
+ context.stdout.write('Open Claude Code and use any Workspace tool; authorize in browser when prompted.\n');
163
+ return 0;
164
+ }
165
+
166
+ const reason = isCommandNotFound(result)
167
+ ? 'claude executable was not found'
168
+ : `claude mcp add failed with exit code ${result.code ?? 1}`;
169
+ context.stderr.write(`${reason}; falling back to direct JSON config write.\n`);
170
+
171
+ try {
172
+ await installClaudeCodeJsonFallback(options, context, { name, url });
173
+ context.stdout.write('Open Claude Code and use any Workspace tool; authorize in browser when prompted.\n');
174
+ return 0;
175
+ } catch (error) {
176
+ throw new Error(`${reason}; JSON fallback also failed: ${error instanceof Error ? error.message : String(error)}`);
177
+ }
178
+ }
179
+
180
+ await installClaudeCodeJsonFallback(options, context, { name, url });
181
+ context.stdout.write('Open Claude Code and use any Workspace tool; authorize in browser when prompted.\n');
182
+ return 0;
183
+ }
184
+
185
+ async function installClaudeCodeJsonFallback(options, context, { name, url }) {
186
+ const configPath = resolveClaudeConfigPath({ claudeConfigDir: options.claudeConfigDir });
187
+ const sourceText = await readClaudeConfig(configPath);
188
+ const plan = planClaudeCodeMcpInstall({
189
+ force: options.force,
190
+ name,
191
+ sourceText,
192
+ url,
193
+ });
194
+
195
+ if (plan.changed) {
196
+ await writeClaudeFileAtomic(configPath, plan.text);
197
+ context.stdout.write(`Installed Concept Workspace MCP for Claude Code at ${configPath}\n`);
198
+ } else {
199
+ context.stdout.write(`Claude Code MCP config already up to date at ${configPath}\n`);
200
+ }
201
+ }
202
+
203
+ function parseOptions(rawOptions, host) {
204
+ const options = {};
205
+ const flags = new Set([...SHARED_FLAGS]);
206
+ const values = new Set([...SHARED_VALUES]);
207
+
208
+ if (host === 'codex') {
209
+ for (const flag of CODEX_FLAGS) flags.add(flag);
210
+ for (const value of CODEX_VALUES) values.add(value);
211
+ } else {
212
+ for (const flag of CLAUDE_FLAGS) flags.add(flag);
213
+ for (const value of CLAUDE_VALUES) values.add(value);
214
+ }
215
+
216
+ for (let index = 0; index < rawOptions.length; index += 1) {
217
+ const raw = rawOptions[index];
218
+ if (!raw.startsWith('--')) {
219
+ throw new Error(`Unexpected argument: ${raw}`);
220
+ }
221
+
222
+ const [namePart, inlineValue] = raw.slice(2).split(/=(.*)/s, 2);
223
+ const property = optionPropertyName(namePart);
224
+ if (flags.has(namePart)) {
225
+ if (inlineValue !== undefined) {
226
+ throw new Error(`Option --${namePart} does not take a value.`);
227
+ }
228
+ options[property] = true;
229
+ continue;
230
+ }
231
+
232
+ if (!values.has(namePart)) {
233
+ throw new Error(`Unknown option for ${host}: --${namePart}`);
234
+ }
235
+
236
+ const value = inlineValue ?? rawOptions[index + 1];
237
+ if (!value || value.startsWith('--')) {
238
+ throw new Error(`Option --${namePart} requires a value.`);
239
+ }
240
+ options[property] = value;
241
+ if (inlineValue === undefined) {
242
+ index += 1;
243
+ }
244
+ }
245
+
246
+ return options;
247
+ }
248
+
249
+ function optionPropertyName(name) {
250
+ return name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
251
+ }
252
+
253
+ function createContext(io) {
254
+ const stdin = io.stdin ?? process.stdin;
255
+ const stdout = io.stdout ?? process.stdout;
256
+ const stderr = io.stderr ?? process.stderr;
257
+ return {
258
+ stdin,
259
+ stdout,
260
+ stderr,
261
+ prompt: io.prompt ?? defaultPrompt(stdin, stdout),
262
+ runCommand: io.runCommand ?? runCommand,
263
+ };
264
+ }
265
+
266
+ function defaultPrompt(stdin, stdout) {
267
+ return async (question) => {
268
+ const readline = createInterface({ input: stdin, output: stdout });
269
+ try {
270
+ return await readline.question(question);
271
+ } finally {
272
+ readline.close();
273
+ }
274
+ };
275
+ }
276
+
277
+ function runCommand(command, args, options) {
278
+ return new Promise((resolve) => {
279
+ const child = spawn(command, args, options);
280
+ child.on('error', (error) => resolve({ code: null, error }));
281
+ child.on('close', (code) => resolve({ code }));
282
+ });
283
+ }
284
+
285
+ function isCommandNotFound(result) {
286
+ return result.error?.code === 'ENOENT';
287
+ }
@@ -0,0 +1,260 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { parse } from 'smol-toml';
6
+
7
+ export const DEFAULT_CODEX_MCP_SERVER_NAME = 'concept';
8
+ export const DEFAULT_WORKSPACE_MCP_URL = 'https://workspace-api.concept.dev/mcp';
9
+ export const DEFAULT_CODEX_MCP_SCOPES = ['openid', 'profile', 'email', 'offline_access'];
10
+
11
+ const BARE_TOML_KEY_PATTERN = /^[A-Za-z0-9_-]+$/;
12
+ const REQUIRED_KEYS = new Set(['url', 'scopes']);
13
+
14
+ export function resolveCodexConfigPath({ codexHome } = {}) {
15
+ const root = codexHome || process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
16
+ return path.join(root, 'config.toml');
17
+ }
18
+
19
+ export async function readCodexConfig(configPath) {
20
+ try {
21
+ const metadata = await stat(configPath);
22
+ if (!metadata.isFile()) {
23
+ throw new Error(`Codex config path is not a regular file: ${configPath}`);
24
+ }
25
+ return await readFile(configPath, 'utf8');
26
+ } catch (error) {
27
+ if (error?.code === 'ENOENT') {
28
+ return '';
29
+ }
30
+ throw error;
31
+ }
32
+ }
33
+
34
+ export function planCodexMcpInstall({
35
+ sourceText = '',
36
+ name = DEFAULT_CODEX_MCP_SERVER_NAME,
37
+ url = DEFAULT_WORKSPACE_MCP_URL,
38
+ scopes = DEFAULT_CODEX_MCP_SCOPES,
39
+ force = false,
40
+ } = {}) {
41
+ assertBareTomlKey(name, 'Codex MCP server name');
42
+ assertNonEmptyString(url, 'Codex MCP URL');
43
+ assertScopeList(scopes);
44
+
45
+ const parsed = parseToml(sourceText, 'existing Codex config');
46
+ const existingServer = readExistingServer(parsed, name);
47
+ const existingUrl = typeof existingServer?.url === 'string' ? existingServer.url : null;
48
+ const tableExists = Boolean(existingServer);
49
+ const urlConflicts = tableExists && existingUrl !== url;
50
+
51
+ if (urlConflicts && !force) {
52
+ const detail = existingUrl
53
+ ? `it points at ${existingUrl}`
54
+ : 'it does not have a URL';
55
+ throw new Error(
56
+ `Codex MCP server "${name}" already exists and ${detail}. Re-run with --force to replace it.`,
57
+ );
58
+ }
59
+
60
+ const preserveExistingKeys = tableExists && !urlConflicts;
61
+ const tableText = buildCodexServerTable({
62
+ existingServer: preserveExistingKeys ? existingServer : null,
63
+ name,
64
+ scopes,
65
+ url,
66
+ });
67
+ const block = findCodexServerTableBlock(sourceText, name);
68
+ const nextText = block
69
+ ? replaceBlock(sourceText, block, tableText)
70
+ : appendBlock(sourceText, tableText);
71
+
72
+ parseToml(nextText, 'generated Codex config');
73
+
74
+ return {
75
+ action: block ? (nextText === sourceText ? 'unchanged' : 'update') : 'create',
76
+ changed: nextText !== sourceText,
77
+ tableExisted: Boolean(block),
78
+ text: nextText,
79
+ };
80
+ }
81
+
82
+ export async function writeFileAtomic(configPath, text) {
83
+ await mkdir(path.dirname(configPath), { recursive: true });
84
+ try {
85
+ const metadata = await stat(configPath);
86
+ if (!metadata.isFile()) {
87
+ throw new Error(`Codex config path is not a regular file: ${configPath}`);
88
+ }
89
+ } catch (error) {
90
+ if (error?.code !== 'ENOENT') {
91
+ throw error;
92
+ }
93
+ }
94
+
95
+ const tempPath = path.join(
96
+ path.dirname(configPath),
97
+ `.${path.basename(configPath)}.${process.pid}.${randomUUID()}.tmp`,
98
+ );
99
+ try {
100
+ await writeFile(tempPath, text, { mode: 0o600 });
101
+ await rename(tempPath, configPath);
102
+ } catch (error) {
103
+ await rm(tempPath, { force: true }).catch(() => {});
104
+ throw error;
105
+ }
106
+ }
107
+
108
+ function parseToml(sourceText, label) {
109
+ if (!sourceText.trim()) {
110
+ return {};
111
+ }
112
+ try {
113
+ return parse(sourceText);
114
+ } catch (error) {
115
+ const detail = error instanceof Error ? error.message : String(error);
116
+ throw new Error(`Failed to parse ${label}: ${detail}`);
117
+ }
118
+ }
119
+
120
+ function readExistingServer(parsed, name) {
121
+ const servers = parsed.mcp_servers;
122
+ if (servers === undefined) {
123
+ return null;
124
+ }
125
+ if (!isPlainObject(servers)) {
126
+ throw new Error('Existing Codex config has invalid mcp_servers value; expected a table.');
127
+ }
128
+ const server = servers[name];
129
+ if (server === undefined) {
130
+ return null;
131
+ }
132
+ if (!isPlainObject(server)) {
133
+ throw new Error(`Existing Codex MCP server "${name}" is invalid; expected a table.`);
134
+ }
135
+ return server;
136
+ }
137
+
138
+ function buildCodexServerTable({ existingServer, name, scopes, url }) {
139
+ const lines = [
140
+ `[mcp_servers.${name}]`,
141
+ `url = ${formatTomlValue(url)}`,
142
+ `scopes = ${formatTomlValue(scopes)}`,
143
+ ];
144
+
145
+ if (existingServer) {
146
+ for (const key of Object.keys(existingServer).filter((key) => !REQUIRED_KEYS.has(key)).sort()) {
147
+ assertBareTomlKey(key, `Codex MCP server "${name}" key`);
148
+ lines.push(`${key} = ${formatTomlValue(existingServer[key])}`);
149
+ }
150
+ }
151
+
152
+ return `${lines.join('\n')}\n`;
153
+ }
154
+
155
+ function findCodexServerTableBlock(sourceText, name) {
156
+ const lines = sourceText.match(/^.*(?:\n|$)/gm) ?? [];
157
+ const lineRecords = [];
158
+ const targetHeader = new RegExp(`^\\s*\\[\\s*mcp_servers\\s*\\.\\s*${escapeRegExp(name)}\\s*\\]\\s*(?:#.*)?$`);
159
+ const anyHeader = /^\s*\[[^\]]+\]\s*(?:#.*)?$/;
160
+ let offset = 0;
161
+ let start = -1;
162
+ let startIndex = -1;
163
+ let end = sourceText.length;
164
+
165
+ for (const [index, line] of lines.entries()) {
166
+ lineRecords.push({
167
+ line,
168
+ start: offset,
169
+ });
170
+ const lineWithoutNewline = line.replace(/\n$/, '');
171
+ if (start === -1) {
172
+ if (targetHeader.test(lineWithoutNewline)) {
173
+ start = offset;
174
+ startIndex = index;
175
+ }
176
+ } else if (anyHeader.test(lineWithoutNewline)) {
177
+ let endIndex = index;
178
+ while (endIndex > startIndex + 1 && isTableBoundaryTrivia(lineRecords[endIndex - 1].line)) {
179
+ endIndex -= 1;
180
+ }
181
+ end = lineRecords[endIndex].start;
182
+ break;
183
+ }
184
+ offset += line.length;
185
+ }
186
+
187
+ if (start === -1) {
188
+ return null;
189
+ }
190
+ return { end, start };
191
+ }
192
+
193
+ function isTableBoundaryTrivia(line) {
194
+ const trimmed = line.trim();
195
+ return trimmed === '' || trimmed.startsWith('#');
196
+ }
197
+
198
+ function replaceBlock(sourceText, block, replacement) {
199
+ return `${sourceText.slice(0, block.start)}${replacement}${sourceText.slice(block.end)}`;
200
+ }
201
+
202
+ function appendBlock(sourceText, block) {
203
+ if (!sourceText.trim()) {
204
+ return block;
205
+ }
206
+ let prefix = sourceText;
207
+ if (!prefix.endsWith('\n')) {
208
+ prefix += '\n';
209
+ }
210
+ if (!prefix.endsWith('\n\n')) {
211
+ prefix += '\n';
212
+ }
213
+ return `${prefix}${block}`;
214
+ }
215
+
216
+ function assertBareTomlKey(value, label) {
217
+ assertNonEmptyString(value, label);
218
+ if (!BARE_TOML_KEY_PATTERN.test(value)) {
219
+ throw new Error(`${label} must contain only letters, numbers, underscores, and hyphens.`);
220
+ }
221
+ }
222
+
223
+ function assertNonEmptyString(value, label) {
224
+ if (typeof value !== 'string' || value.trim().length === 0) {
225
+ throw new Error(`${label} must be a non-empty string.`);
226
+ }
227
+ }
228
+
229
+ function assertScopeList(scopes) {
230
+ if (!Array.isArray(scopes) || scopes.length === 0) {
231
+ throw new Error('Codex MCP scopes must be a non-empty string array.');
232
+ }
233
+ for (const scope of scopes) {
234
+ assertNonEmptyString(scope, 'Codex MCP scope');
235
+ }
236
+ }
237
+
238
+ function formatTomlValue(value) {
239
+ if (typeof value === 'string') {
240
+ return JSON.stringify(value);
241
+ }
242
+ if (typeof value === 'boolean') {
243
+ return value ? 'true' : 'false';
244
+ }
245
+ if (typeof value === 'number' && Number.isFinite(value)) {
246
+ return String(value);
247
+ }
248
+ if (Array.isArray(value)) {
249
+ return `[${value.map(formatTomlValue).join(', ')}]`;
250
+ }
251
+ throw new Error(`Unsupported Codex MCP config value: ${JSON.stringify(value)}`);
252
+ }
253
+
254
+ function isPlainObject(value) {
255
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
256
+ }
257
+
258
+ function escapeRegExp(value) {
259
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
260
+ }