@chadanalytics/mcp-setup 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chad Analytics
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # @chadanalytics/mcp-setup
2
+
3
+ Connect [Chad Analytics](https://chadanalytics.com)' hosted MCP server to Codex,
4
+ Claude Code, Cursor, or Claude Desktop without hand-editing client config.
5
+
6
+ The package is dependency-free and ready to run with `npx` on Node 18+.
7
+
8
+ ## 1. Create a credential
9
+
10
+ In the Slack workspace connected to Chad, run:
11
+
12
+ ```text
13
+ /chad mcp
14
+ ```
15
+
16
+ Only the person who connected Chad can issue or rotate the credential. Chad
17
+ shows it once. The current hosted credential is read-only, limited to the
18
+ connected site, replaces the previous credential, and expires after 90 days.
19
+
20
+ ## 2. Run setup
21
+
22
+ Pass the token through the environment so it does not enter shell history or
23
+ the process argument list:
24
+
25
+ ```sh
26
+ CHAD_MCP_TOKEN='chad_mcp_…' npx @chadanalytics/mcp-setup codex
27
+ ```
28
+
29
+ Configure more than one client:
30
+
31
+ ```sh
32
+ CHAD_MCP_TOKEN='chad_mcp_…' npx @chadanalytics/mcp-setup cursor claude-code
33
+ ```
34
+
35
+ Configure every supported client:
36
+
37
+ ```sh
38
+ CHAD_MCP_TOKEN='chad_mcp_…' npx @chadanalytics/mcp-setup all
39
+ ```
40
+
41
+ If the environment variable is omitted in an interactive terminal, setup asks
42
+ for the credential with a hidden prompt. It deliberately has no `--token`
43
+ option.
44
+
45
+ ## Preview without changing files
46
+
47
+ Validate the planned client paths:
48
+
49
+ ```sh
50
+ npx @chadanalytics/mcp-setup all --dry-run
51
+ ```
52
+
53
+ Print isolated Chad config snippets with the credential redacted:
54
+
55
+ ```sh
56
+ npx @chadanalytics/mcp-setup codex cursor --print
57
+ ```
58
+
59
+ Both modes work without a real token and never write files. Print mode does not
60
+ read or echo existing client configuration, so it cannot expose credentials for
61
+ other MCP servers.
62
+
63
+ ## Where setup writes
64
+
65
+ | Client | User-level configuration |
66
+ | --- | --- |
67
+ | Codex | `~/.codex/config.toml` |
68
+ | Claude Code | `~/.claude.json` |
69
+ | Cursor | `~/.cursor/mcp.json` |
70
+ | Claude Desktop, macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` |
71
+ | Claude Desktop, Windows | `%APPDATA%\\Claude\\claude_desktop_config.json` |
72
+ | Claude Desktop, Linux | `~/.config/Claude/claude_desktop_config.json` |
73
+
74
+ Existing settings and other MCP servers are retained. The `chad` entry is
75
+ created or replaced. Files are written atomically and restricted to the current
76
+ user with mode `0600` on platforms that support POSIX permissions.
77
+
78
+ Because these clients need the bearer token on every hosted MCP request, the
79
+ token is stored in their private user configuration. Treat those files as
80
+ credentials. Never commit them, paste them into chat, or use project-shared MCP
81
+ configuration for this token. Rotate it with `/chad mcp` if it is exposed.
82
+
83
+ ## Custom token variable or endpoint
84
+
85
+ ```sh
86
+ MY_CHAD_TOKEN='chad_mcp_…' npx @chadanalytics/mcp-setup codex \
87
+ --token-env MY_CHAD_TOKEN
88
+ ```
89
+
90
+ The production endpoint defaults to `https://chadanalytics.com/api/mcp`. A
91
+ different HTTPS endpoint can be selected for an authorized staging deployment:
92
+
93
+ ```sh
94
+ CHAD_MCP_TOKEN='chad_mcp_…' npx @chadanalytics/mcp-setup codex \
95
+ --url https://staging.example.com/api/mcp
96
+ ```
97
+
98
+ ## Verify
99
+
100
+ Restart the configured client and ask:
101
+
102
+ ```text
103
+ What traffic did I get today?
104
+ Which AI assistants sent visitors this week?
105
+ What pages did AI crawlers request?
106
+ What was my bounce rate in the last 24 hours?
107
+ Break down this week's visitors by country and device.
108
+ How many signups and how much attributed revenue did I get this month?
109
+ Show the drop-off in my signup funnel.
110
+ ```
111
+
112
+ The hosted MCP server currently exposes these read-only tools:
113
+
114
+ | Tool | Returns |
115
+ | --- | --- |
116
+ | `chad_websites_list` | Sites available to this credential |
117
+ | `chad_analytics_overview` | Visitors, sessions, bounce rate, duration, AI traffic, goals, and revenue |
118
+ | `chad_analytics_ai_traffic` | Human referrals from ChatGPT, Claude, Perplexity, Gemini, Copilot, Grok, and other detected AI tools |
119
+ | `chad_analytics_crawlers` | Server-side AI answer, indexing, training, and crawler requests |
120
+ | `chad_analytics_top_pages` | Most-viewed pages |
121
+ | `chad_analytics_dimensions` | Country, device, browser, or operating-system breakdown |
122
+ | `chad_visitor_journeys` | Chronological anonymous page and session journeys |
123
+ | `chad_goals_list` | Custom-goal completions, visitors, and values |
124
+ | `chad_revenue_overview` | Attributed revenue, payments, buyers, conversion rate, and revenue per visitor |
125
+ | `chad_funnels_list` | Saved funnel definitions |
126
+ | `chad_funnel_results` | Sequential reach, conversion, drop-off, and attributed revenue |
127
+ | `chad_segments_list` | Saved segments and active exclusions |
128
+ | `chad_integrations_status` | Connected provider status without credentials |
129
+
130
+ All tools are read-only. The credential is checked on every request and the
131
+ server limits results to its authorized Chad tenant and site IDs. A metric may
132
+ be empty until its tracker, goal, funnel, or provider connection is configured.
133
+ See `docs/mcp.md` in the Chad repository for the transport and security model.
134
+
135
+ ## Development
136
+
137
+ ```sh
138
+ npm test
139
+ npm run check
140
+ npm pack --dry-run
141
+ ```
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const { stdin, stdout, stderr, env } = process;
5
+ const pkg = require("../package.json");
6
+ const {
7
+ CLIENTS,
8
+ DEFAULT_URL,
9
+ REDACTED_TOKEN,
10
+ install,
11
+ normalizeClients,
12
+ redact,
13
+ renderClientConfig,
14
+ resolveClientPath,
15
+ validateToken,
16
+ validateUrl,
17
+ } = require("../src/config");
18
+
19
+ const HELP = `Chad Analytics MCP setup
20
+
21
+ Safely connect Chad's hosted MCP server to your AI clients.
22
+
23
+ Usage:
24
+ CHAD_MCP_TOKEN=chad_mcp_... npx @chadanalytics/mcp-setup codex
25
+ CHAD_MCP_TOKEN=chad_mcp_... npx @chadanalytics/mcp-setup cursor claude-code
26
+ CHAD_MCP_TOKEN=chad_mcp_... npx @chadanalytics/mcp-setup all
27
+
28
+ Clients:
29
+ codex OpenAI Codex CLI, desktop app, and IDE extension
30
+ claude-code Claude Code, user scope
31
+ cursor Cursor, user scope
32
+ claude-desktop Claude Desktop
33
+
34
+ Options:
35
+ -c, --client <name> Add a client (repeatable; comma-separated also works)
36
+ --all Configure every supported client
37
+ --dry-run Validate and show target paths without writing
38
+ --print Print only Chad's redacted config snippets; do not write
39
+ --token-env <n> Read the credential from env var n (default CHAD_MCP_TOKEN)
40
+ --url <url> Override the hosted URL (default ${DEFAULT_URL})
41
+ -h, --help Show help
42
+ -v, --version Show version
43
+
44
+ Security:
45
+ The CLI does not accept a --token argument, so credentials do not enter shell
46
+ history or process arguments. Set the token environment variable or paste it
47
+ into the hidden prompt. Config files are written atomically with mode 0600.
48
+ --dry-run and --print always redact the token.
49
+ `;
50
+
51
+ function parseArgs(argv) {
52
+ const options = { clients: [], tokenEnv: "CHAD_MCP_TOKEN" };
53
+ for (let index = 0; index < argv.length; index += 1) {
54
+ const arg = argv[index];
55
+ if (arg === "-h" || arg === "--help") options.help = true;
56
+ else if (arg === "-v" || arg === "--version") options.version = true;
57
+ else if (arg === "--all") options.clients.push("all");
58
+ else if (arg === "--dry-run") options.dryRun = true;
59
+ else if (arg === "--print") options.print = true;
60
+ else if (arg === "-c" || arg === "--client") {
61
+ if (!argv[index + 1]) throw new Error(`${arg} requires a client name.`);
62
+ options.clients.push(argv[++index]);
63
+ } else if (arg === "--token-env") {
64
+ if (!argv[index + 1]) throw new Error("--token-env requires an environment variable name.");
65
+ options.tokenEnv = argv[++index];
66
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(options.tokenEnv)) {
67
+ throw new Error("--token-env must be a valid environment variable name.");
68
+ }
69
+ } else if (arg === "--url") {
70
+ if (!argv[index + 1]) throw new Error("--url requires an HTTPS URL.");
71
+ options.url = argv[++index];
72
+ } else if (arg.startsWith("-")) {
73
+ throw new Error(`Unknown option "${arg}".`);
74
+ } else {
75
+ options.clients.push(arg);
76
+ }
77
+ }
78
+ return options;
79
+ }
80
+
81
+ function promptSecret(prompt = "Paste the private token from /chad mcp in Slack: ") {
82
+ return new Promise((resolve, reject) => {
83
+ if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") {
84
+ reject(new Error("Set CHAD_MCP_TOKEN because a hidden interactive prompt is not available."));
85
+ return;
86
+ }
87
+ let value = "";
88
+ stdout.write(prompt);
89
+ stdin.setEncoding("utf8");
90
+ stdin.setRawMode(true);
91
+ stdin.resume();
92
+ const finish = (error) => {
93
+ stdin.setRawMode(false);
94
+ stdin.pause();
95
+ stdin.removeListener("data", onData);
96
+ stdout.write("\n");
97
+ if (error) reject(error);
98
+ else resolve(value);
99
+ };
100
+ const onData = (chunk) => {
101
+ for (const char of chunk) {
102
+ if (char === "\u0003") return finish(new Error("Cancelled."));
103
+ if (char === "\r" || char === "\n") return finish();
104
+ if (char === "\u007f" || char === "\b") value = value.slice(0, -1);
105
+ else value += char;
106
+ }
107
+ };
108
+ stdin.on("data", onData);
109
+ });
110
+ }
111
+
112
+ async function main(argv = process.argv.slice(2)) {
113
+ const args = parseArgs(argv);
114
+ if (args.help) {
115
+ stdout.write(HELP);
116
+ return 0;
117
+ }
118
+ if (args.version) {
119
+ stdout.write(`${pkg.version}\n`);
120
+ return 0;
121
+ }
122
+ const clients = normalizeClients(args.clients);
123
+ if (!clients.length) throw new Error(`Choose a client (${CLIENTS.join(", ")}) or use all.`);
124
+
125
+ let token = env[args.tokenEnv];
126
+ if (!token && (args.dryRun || args.print)) token = `chad_mcp_${"0".repeat(64)}`;
127
+ if (!token) token = await promptSecret();
128
+
129
+ if (args.print) {
130
+ validateToken(token);
131
+ const url = validateUrl(args.url || DEFAULT_URL);
132
+ for (const client of clients) {
133
+ stdout.write(`\n# ${client}: ${resolveClientPath(client)}\n`);
134
+ // Render an isolated Chad entry so --print can never echo unrelated
135
+ // credentials already stored in the user's client configuration.
136
+ stdout.write(redact(renderClientConfig(client, "", token, url), token));
137
+ }
138
+ return 0;
139
+ }
140
+
141
+ const plan = install({
142
+ clients,
143
+ token,
144
+ url: args.url,
145
+ dryRun: args.dryRun,
146
+ });
147
+
148
+ for (const item of plan) {
149
+ stdout.write(`${args.dryRun ? "Would configure" : "Configured"} ${item.client}: ${item.path}\n`);
150
+ }
151
+ if (!args.dryRun) {
152
+ stdout.write("Restart the configured app, then ask: ‘What traffic did I get today?’\n");
153
+ } else {
154
+ stdout.write(`Token: ${REDACTED_TOKEN}\nNo files were changed.\n`);
155
+ }
156
+ return 0;
157
+ }
158
+
159
+ if (require.main === module) {
160
+ main().then((code) => { process.exitCode = code; }).catch((error) => {
161
+ stderr.write(`Error: ${String(error && error.message || error)}\n`);
162
+ process.exitCode = 1;
163
+ });
164
+ }
165
+
166
+ module.exports = { HELP, main, parseArgs, promptSecret };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@chadanalytics/mcp-setup",
3
+ "version": "0.1.0",
4
+ "mcpName": "com.chadanalytics/chad",
5
+ "description": "Safely connect Chad Analytics hosted MCP to Codex, Claude Code, Cursor, or Claude Desktop",
6
+ "license": "MIT",
7
+ "type": "commonjs",
8
+ "bin": {
9
+ "chad-mcp-setup": "bin/chad-mcp-setup.js"
10
+ },
11
+ "main": "src/config.js",
12
+ "exports": {
13
+ ".": "./src/config.js"
14
+ },
15
+ "files": [
16
+ "bin",
17
+ "src",
18
+ "README.md"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "scripts": {
24
+ "test": "node --test test/*.test.js",
25
+ "check": "node --check bin/chad-mcp-setup.js && node --check src/config.js && npm test"
26
+ },
27
+ "keywords": [
28
+ "chad analytics",
29
+ "mcp",
30
+ "model context protocol",
31
+ "codex",
32
+ "claude code",
33
+ "cursor"
34
+ ],
35
+ "engines": {
36
+ "node": ">=18"
37
+ }
38
+ }
package/src/config.js ADDED
@@ -0,0 +1,222 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const os = require("node:os");
5
+ const path = require("node:path");
6
+
7
+ const DEFAULT_URL = "https://chadanalytics.com/api/mcp";
8
+ const SERVER_NAME = "chad";
9
+ const CLIENTS = ["codex", "claude-code", "cursor", "claude-desktop"];
10
+ const TOKEN_PATTERN = /^chad_mcp_[a-f0-9]{64}$/;
11
+ const REDACTED_TOKEN = "chad_mcp_<redacted>";
12
+
13
+ const CLIENT_ALIASES = Object.freeze({
14
+ codex: "codex",
15
+ claude: "claude-code",
16
+ "claude-code": "claude-code",
17
+ cursor: "cursor",
18
+ desktop: "claude-desktop",
19
+ "claude-desktop": "claude-desktop",
20
+ });
21
+
22
+ function validateToken(token) {
23
+ if (!TOKEN_PATTERN.test(String(token || ""))) {
24
+ throw new Error("CHAD_MCP_TOKEN must be the private chad_mcp_ credential issued by /chad mcp in Slack.");
25
+ }
26
+ return String(token);
27
+ }
28
+
29
+ function validateUrl(value) {
30
+ let parsed;
31
+ try {
32
+ parsed = new URL(String(value || ""));
33
+ } catch {
34
+ throw new Error("The MCP URL must be a valid HTTPS URL.");
35
+ }
36
+ if (parsed.protocol !== "https:") throw new Error("The MCP URL must use HTTPS.");
37
+ if (parsed.username || parsed.password) throw new Error("The MCP URL must not contain credentials.");
38
+ return parsed.toString().replace(/\/$/, "");
39
+ }
40
+
41
+ function normalizeClients(values) {
42
+ const requested = Array.isArray(values) ? values : [values];
43
+ const expanded = requested
44
+ .flatMap((value) => String(value || "").split(","))
45
+ .map((value) => value.trim().toLowerCase())
46
+ .filter(Boolean);
47
+ if (expanded.includes("all")) return [...CLIENTS];
48
+ const normalized = expanded.map((value) => {
49
+ const client = CLIENT_ALIASES[value];
50
+ if (!client) throw new Error(`Unknown client "${value}". Choose: ${CLIENTS.join(", ")}, or all.`);
51
+ return client;
52
+ });
53
+ return [...new Set(normalized)];
54
+ }
55
+
56
+ function resolveClientPath(client, options = {}) {
57
+ const home = options.home || os.homedir();
58
+ const platform = options.platform || process.platform;
59
+ const env = options.env || process.env;
60
+ switch (client) {
61
+ case "codex":
62
+ return path.join(home, ".codex", "config.toml");
63
+ case "claude-code":
64
+ return path.join(home, ".claude.json");
65
+ case "cursor":
66
+ return path.join(home, ".cursor", "mcp.json");
67
+ case "claude-desktop":
68
+ if (platform === "darwin") {
69
+ return path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
70
+ }
71
+ if (platform === "win32") {
72
+ return path.join(env.APPDATA || path.join(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
73
+ }
74
+ return path.join(home, ".config", "Claude", "claude_desktop_config.json");
75
+ default:
76
+ throw new Error(`Unsupported client "${client}".`);
77
+ }
78
+ }
79
+
80
+ function jsonServer(token, url, includeType) {
81
+ return {
82
+ ...(includeType ? { type: "http" } : {}),
83
+ url,
84
+ headers: { Authorization: `Bearer ${token}` },
85
+ };
86
+ }
87
+
88
+ function mergeJsonConfig(existing, token, url, includeType) {
89
+ let parsed = {};
90
+ if (String(existing || "").trim()) {
91
+ try {
92
+ parsed = JSON.parse(existing);
93
+ } catch {
94
+ throw new Error("Existing client configuration is not valid JSON; it was left unchanged.");
95
+ }
96
+ }
97
+ if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
98
+ throw new Error("Existing client configuration must contain a JSON object; it was left unchanged.");
99
+ }
100
+ const currentServers = parsed.mcpServers;
101
+ if (currentServers !== undefined && (!currentServers || Array.isArray(currentServers) || typeof currentServers !== "object")) {
102
+ throw new Error("Existing mcpServers value must be a JSON object; it was left unchanged.");
103
+ }
104
+ return `${JSON.stringify({
105
+ ...parsed,
106
+ mcpServers: {
107
+ ...(currentServers || {}),
108
+ [SERVER_NAME]: jsonServer(token, url, includeType),
109
+ },
110
+ }, null, 2)}\n`;
111
+ }
112
+
113
+ function tomlString(value) {
114
+ return JSON.stringify(String(value));
115
+ }
116
+
117
+ function stripTomlServer(existing) {
118
+ const lines = String(existing || "").split(/\r?\n/);
119
+ const exact = /^\s*\[mcp_servers\.chad\]\s*(?:#.*)?$/;
120
+ const table = /^\s*\[([^\]]+)\]/;
121
+ const start = lines.findIndex((line) => exact.test(line));
122
+ if (start === -1) return String(existing || "").trimEnd();
123
+ let end = lines.length;
124
+ for (let index = start + 1; index < lines.length; index += 1) {
125
+ const match = lines[index].match(table);
126
+ if (match && match[1] !== "mcp_servers.chad" && !match[1].startsWith("mcp_servers.chad.")) {
127
+ end = index;
128
+ break;
129
+ }
130
+ }
131
+ lines.splice(start, end - start);
132
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
133
+ }
134
+
135
+ function mergeCodexConfig(existing, token, url) {
136
+ const before = stripTomlServer(existing);
137
+ const block = [
138
+ "[mcp_servers.chad]",
139
+ `url = ${tomlString(url)}`,
140
+ `http_headers = { Authorization = ${tomlString(`Bearer ${token}`)} }`,
141
+ ].join("\n");
142
+ return `${before ? `${before}\n\n` : ""}${block}\n`;
143
+ }
144
+
145
+ function readExisting(filePath, fsApi = fs) {
146
+ try {
147
+ return fsApi.readFileSync(filePath, "utf8");
148
+ } catch (error) {
149
+ if (error && error.code === "ENOENT") return "";
150
+ throw error;
151
+ }
152
+ }
153
+
154
+ function renderClientConfig(client, existing, token, url) {
155
+ if (client === "codex") return mergeCodexConfig(existing, token, url);
156
+ // Claude Code requires an explicit HTTP type. Cursor and Claude Desktop use
157
+ // the standard remote-server shape documented for config-based MCP clients.
158
+ return mergeJsonConfig(existing, token, url, client === "claude-code");
159
+ }
160
+
161
+ function buildPlan(options) {
162
+ const clients = normalizeClients(options.clients);
163
+ if (!clients.length) throw new Error("Choose at least one client, or use all.");
164
+ const token = validateToken(options.token);
165
+ const url = validateUrl(options.url || DEFAULT_URL);
166
+ const fsApi = options.fs || fs;
167
+ return clients.map((client) => {
168
+ const filePath = resolveClientPath(client, options);
169
+ const existing = readExisting(filePath, fsApi);
170
+ return {
171
+ client,
172
+ path: filePath,
173
+ existed: Boolean(existing),
174
+ content: renderClientConfig(client, existing, token, url),
175
+ };
176
+ });
177
+ }
178
+
179
+ function atomicWrite(filePath, content, fsApi = fs) {
180
+ const directory = path.dirname(filePath);
181
+ fsApi.mkdirSync(directory, { recursive: true, mode: 0o700 });
182
+ const tempPath = path.join(directory, `.${path.basename(filePath)}.chad-${process.pid}-${Date.now()}.tmp`);
183
+ try {
184
+ fsApi.writeFileSync(tempPath, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
185
+ fsApi.renameSync(tempPath, filePath);
186
+ fsApi.chmodSync(filePath, 0o600);
187
+ } catch (error) {
188
+ try { fsApi.unlinkSync(tempPath); } catch {}
189
+ throw error;
190
+ }
191
+ }
192
+
193
+ function install(options) {
194
+ const fsApi = options.fs || fs;
195
+ const plan = buildPlan(options);
196
+ if (!options.dryRun) {
197
+ for (const item of plan) atomicWrite(item.path, item.content, fsApi);
198
+ }
199
+ return plan;
200
+ }
201
+
202
+ function redact(value, token) {
203
+ return String(value).split(String(token)).join(REDACTED_TOKEN);
204
+ }
205
+
206
+ module.exports = {
207
+ CLIENTS,
208
+ DEFAULT_URL,
209
+ REDACTED_TOKEN,
210
+ TOKEN_PATTERN,
211
+ atomicWrite,
212
+ buildPlan,
213
+ install,
214
+ mergeCodexConfig,
215
+ mergeJsonConfig,
216
+ normalizeClients,
217
+ redact,
218
+ renderClientConfig,
219
+ resolveClientPath,
220
+ validateToken,
221
+ validateUrl,
222
+ };