@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 +120 -0
- package/bin/concept-workspace-mcp.js +16 -0
- package/package.json +25 -0
- package/src/claude-code-config.mjs +130 -0
- package/src/cli.mjs +287 -0
- package/src/codex-config.mjs +260 -0
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
|
+
}
|