@tday-com/mcp 0.2.0 → 0.3.1
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 +25 -6
- package/dist/api-client.js +1 -18
- package/dist/cli.js +0 -3
- package/dist/config.js +0 -4
- package/dist/download-safety.js +2 -5
- package/dist/installer-agents.js +132 -216
- package/dist/installer-callback.js +0 -7
- package/dist/installer-slash-command.js +94 -35
- package/dist/installer.js +34 -11
- package/dist/package-info.js +14 -8
- package/dist/prompts.js +36 -0
- package/dist/result-format.js +7 -31
- package/dist/server.js +12 -6
- package/dist/tool-schemas.js +1 -15
- package/dist/tools-account.js +0 -4
- package/dist/tools-features.js +39 -0
- package/dist/tools-launch.js +43 -50
- package/dist/tools.js +5 -26
- package/package.json +5 -15
- package/dist/api-client.d.ts +0 -23
- package/dist/cli.d.ts +0 -2
- package/dist/config.d.ts +0 -7
- package/dist/demo-import.d.ts +0 -6
- package/dist/demo-import.js +0 -101
- package/dist/download-safety.d.ts +0 -7
- package/dist/installer-agents.d.ts +0 -17
- package/dist/installer-callback.d.ts +0 -13
- package/dist/installer-slash-command.d.ts +0 -12
- package/dist/installer.d.ts +0 -2
- package/dist/observability.d.ts +0 -1
- package/dist/observability.js +0 -77
- package/dist/package-info.d.ts +0 -11
- package/dist/result-format.d.ts +0 -4
- package/dist/server.d.ts +0 -1
- package/dist/tool-schemas.d.ts +0 -460
- package/dist/tools-account.d.ts +0 -6
- package/dist/tools-demo.d.ts +0 -11
- package/dist/tools-demo.js +0 -57
- package/dist/tools-knowledge.d.ts +0 -4
- package/dist/tools-knowledge.js +0 -111
- package/dist/tools-launch.d.ts +0 -4
- package/dist/tools.d.ts +0 -15
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @tday-com/mcp
|
|
2
2
|
|
|
3
|
-
Official local MCP server and installer for [tday.com](https://tday.com). Connect Claude Code, Cursor, Gemini CLI, Codex, and any MCP-compatible AI agent to your tday.com account so you can generate designs, manage brands, and query your workspace directly from chat.
|
|
3
|
+
Official local MCP server and installer for [tday.com](https://tday.com). Connect Claude Code, Cursor, VS Code, Gemini CLI, Codex, and any MCP-compatible AI agent to your tday.com account so you can generate designs, make launch videos, manage brands, and query your workspace directly from chat.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -16,7 +16,14 @@ pnpm dlx @tday-com/mcp install
|
|
|
16
16
|
curl -fsSL https://tday.com/install.sh | bash
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
The installer opens your browser, lets you sign in or sign up on tday.com, asks you to authorize this machine, then automatically configures supported agents. Claude Code, Cursor,
|
|
19
|
+
The installer opens your browser, lets you sign in or sign up on tday.com, asks you to authorize this machine, then automatically configures supported agents. Claude Code, Cursor, Gemini CLI and VS Code (stable and Insiders, user `mcp.json`) get MCP config entries; Codex gets a TOML block to paste (the Codex app reads the same file). No manual key copying is needed.
|
|
20
|
+
|
|
21
|
+
One install sets up everything your account can use, with one sign-in:
|
|
22
|
+
|
|
23
|
+
- `tday`: this server (your account, brands, designs, launch videos).
|
|
24
|
+
- `tday-capture`: [`@tday-com/capture`](https://www.npmjs.com/package/@tday-com/capture), which captures real UI from the app running on your machine for tday. Added when your account can capture. It reads the same `~/.tday/config.json`, so it needs no token of its own.
|
|
25
|
+
|
|
26
|
+
Run `install` again whenever you want to update either, or after your account gains access to something new.
|
|
20
27
|
|
|
21
28
|
## Slash commands
|
|
22
29
|
|
|
@@ -27,6 +34,7 @@ Claude Code users get:
|
|
|
27
34
|
- `/tday-brand` - create/list/view brands
|
|
28
35
|
- `/tday-credits` - check your plan and credit balance
|
|
29
36
|
- `/tday-ideas` - brainstorm brand-specific design ideas
|
|
37
|
+
- `/tday-launch-video` - make a launch video of a feature: the one you name, or the newest one without a launch video (when your plan includes launch videos)
|
|
30
38
|
|
|
31
39
|
## MCP tools
|
|
32
40
|
|
|
@@ -37,10 +45,21 @@ Claude Code users get:
|
|
|
37
45
|
- `tday_generate_design` - prompt + brandId
|
|
38
46
|
- `tday_poll_generation` - poll until completed/failed
|
|
39
47
|
- `tday_download_image` - save a completed image locally
|
|
40
|
-
- `
|
|
48
|
+
- `tday_list_features` - a brand's features, newest first, with the launch videos each already has (`without: "launch_video"` lists the ones with none)
|
|
49
|
+
- `tday_create_launch_video` - make a launch video of a feature (`featureId` from `tday_list_features`) or of a new subject (brandId, title, description)
|
|
50
|
+
- `tday_poll_launch_video` / `tday_get_launch_video_mp4` / `tday_revise_launch_video` - follow a film until it is ready, get its MP4, or make a new cut from notes
|
|
41
51
|
|
|
42
52
|
Generating a design spends credits from your organisation's balance, at the same price as in the app, and a failed generation gives them back. When the balance cannot cover a request, tday answers with a 402 and spends nothing; `tday_whoami` shows the balance and when it refreshes.
|
|
43
53
|
|
|
54
|
+
Making a launch video spends credits the same way; `tday_get_credit_prices` lists every price.
|
|
55
|
+
|
|
56
|
+
## MCP prompts
|
|
57
|
+
|
|
58
|
+
Clients that list MCP prompts (Claude Code as `/mcp__tday__<name>`, VS Code as `/mcp.tday.<name>`) get:
|
|
59
|
+
|
|
60
|
+
- `launch-video` - a launch video of the feature you name, or of the newest feature without one, quoted before it starts
|
|
61
|
+
- `features` - the newest features and which already have a launch video
|
|
62
|
+
|
|
44
63
|
All tools return structured content for modern MCP clients. They also accept optional `response_format: "json" | "markdown"` for the text payload; JSON remains the default for backward compatibility.
|
|
45
64
|
|
|
46
65
|
## One-shot CLI calls
|
|
@@ -69,7 +88,7 @@ The hosted connector uses OAuth + PKCE with refresh-token rotation. Users sign i
|
|
|
69
88
|
- **What a connection can do.** The token an MCP connection holds only reaches the routes the tools above use. It cannot delete or transfer a brand, manage members, or see payment details.
|
|
70
89
|
- **Links expire.** A link made by `install` lasts 90 days; run `install` again to renew it. A hosted connector's access token lasts an hour and renews itself for as long as the connection is used; one left unused for 30 days disconnects.
|
|
71
90
|
- **Disconnect** any connection from tday Settings - Integrations.
|
|
72
|
-
- **
|
|
91
|
+
- **No telemetry.** The server talks only to tday, and to tday's file storage when you ask it to download an image or video.
|
|
73
92
|
|
|
74
93
|
## Manual MCP config
|
|
75
94
|
|
|
@@ -86,11 +105,11 @@ The hosted connector uses OAuth + PKCE with refresh-token rotation. Users sign i
|
|
|
86
105
|
|
|
87
106
|
If you add this block yourself, run `npx -y @tday-com/mcp install` once afterward so the browser sign-in flow can link the server to your tday account automatically. `install` writes the entry pinned to its own version (`@tday-com/mcp@<version>`), so an agent never picks up a new release on its own; run `install` again to update. Pin the version in a block you write by hand too.
|
|
88
107
|
|
|
89
|
-
For Gemini CLI, put the same `mcpServers.tday` object in `~/.gemini/settings.json`.
|
|
108
|
+
For Gemini CLI, put the same `mcpServers.tday` object in `~/.gemini/settings.json`. VS Code's user `mcp.json` keys entries by `servers` and names the transport: `{ "servers": { "tday": { "type": "stdio", "command": "npx", "args": ["-y", "@tday-com/mcp", "serve"] } } }`.
|
|
90
109
|
|
|
91
110
|
## Claude Code slash commands
|
|
92
111
|
|
|
93
|
-
The installer writes user-level Claude Code slash commands to `~/.claude/commands`. After restarting Claude Code, typing `/` shows `/tday`, `/tday-design`, `/tday-brand`, `/tday-credits`, and `/tday-
|
|
112
|
+
The installer writes user-level Claude Code slash commands to `~/.claude/commands`. After restarting Claude Code, typing `/` shows `/tday`, `/tday-design`, `/tday-brand`, `/tday-credits`, `/tday-ideas` and, where your plan includes launch videos, `/tday-launch-video` in the slash-command autocomplete list. A command your account no longer has is removed the next time you run `install`.
|
|
94
113
|
|
|
95
114
|
## Security model
|
|
96
115
|
|
package/dist/api-client.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// Authenticated HTTP client for the tday API plus small request/response
|
|
2
|
-
// helpers shared by the tool handlers in tools.ts.
|
|
3
1
|
export async function api(config, path, init = {}) {
|
|
4
2
|
if (!config.token) {
|
|
5
3
|
throw new Error("Not authenticated. Run `npx -y @tday-com/mcp install` to link this MCP server to your tday account in the browser, then restart your AI agent.");
|
|
@@ -9,8 +7,6 @@ export async function api(config, path, init = {}) {
|
|
|
9
7
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
10
8
|
if (typeof timeout === "object" && "unref" in timeout)
|
|
11
9
|
timeout.unref();
|
|
12
|
-
// Forward caller cancellation into the timeout controller so both signals
|
|
13
|
-
// apply (AbortSignal.any needs Node 20.3+; engines allow >=18).
|
|
14
10
|
const callerSignal = init.signal;
|
|
15
11
|
if (callerSignal?.aborted)
|
|
16
12
|
controller.abort(callerSignal.reason);
|
|
@@ -28,8 +24,6 @@ export async function api(config, path, init = {}) {
|
|
|
28
24
|
...init.headers,
|
|
29
25
|
},
|
|
30
26
|
});
|
|
31
|
-
// A failed body read is not an empty acknowledgement. In particular, never
|
|
32
|
-
// repeat a mutation just because its successful response was interrupted.
|
|
33
27
|
const text = await res.text();
|
|
34
28
|
if (!res.ok) {
|
|
35
29
|
throw new Error(formatHttpError(res.status, path, text, res.statusText));
|
|
@@ -46,7 +40,7 @@ export async function api(config, path, init = {}) {
|
|
|
46
40
|
catch (err) {
|
|
47
41
|
if (err instanceof Error && err.name === "AbortError") {
|
|
48
42
|
if (callerSignal?.aborted)
|
|
49
|
-
throw err;
|
|
43
|
+
throw err;
|
|
50
44
|
throw new Error(`tday request timed out after ${timeoutMs / 1000}s: ${path}`);
|
|
51
45
|
}
|
|
52
46
|
throw err;
|
|
@@ -56,8 +50,6 @@ export async function api(config, path, init = {}) {
|
|
|
56
50
|
}
|
|
57
51
|
}
|
|
58
52
|
function formatHttpError(status, path, text, statusText) {
|
|
59
|
-
// A 5xx body is where internal detail lives, so it is never shown. A 4xx contributes only its
|
|
60
|
-
// JSON `error` string, which the tday routes write for people to read.
|
|
61
53
|
if (status >= 500) {
|
|
62
54
|
return `tday ${status} ${path}. tday is temporarily unable to complete the request; retry shortly. If a design or launch video was already queued, keep polling it (tday_poll_generation or tday_poll_launch_video) rather than starting another.`;
|
|
63
55
|
}
|
|
@@ -80,8 +72,6 @@ function formatHttpError(status, path, text, statusText) {
|
|
|
80
72
|
case 401:
|
|
81
73
|
return `${prefix}. This MCP server's tday link is missing, expired (links last 90 days) or was disconnected. Run \`npx -y @tday-com/mcp install\` to sign in and relink this machine, then restart your AI agent and retry tday_whoami.`;
|
|
82
74
|
case 402:
|
|
83
|
-
// The body's sentence already says what the request needed and what is
|
|
84
|
-
// left; this only tells the agent what to do next. Nothing was spent.
|
|
85
75
|
return `${prefix}. Nothing was started and no credits were spent. Do not retry until the balance changes.${linkLine ? ` ${linkLine}` : ""} Run tday_whoami for each organisation's balance and who owns it; tday_get_credit_prices has what things cost.`;
|
|
86
76
|
case 403:
|
|
87
77
|
return `${prefix}. The linked tday account, or this connection, is not allowed to do that. Use tday_whoami to verify the account, then list brands/designs again.`;
|
|
@@ -93,7 +83,6 @@ function formatHttpError(status, path, text, statusText) {
|
|
|
93
83
|
return prefix;
|
|
94
84
|
}
|
|
95
85
|
}
|
|
96
|
-
/** Refusals a plan or more credits resolves (web/src/lib/credits/refusal-links.ts). */
|
|
97
86
|
const PLAN_CODES = new Set(["insufficient_credits", "plan_locked", "brand_plan_locked", "format_locked", "brand_limit_reached"]);
|
|
98
87
|
const PLAN_REFUSAL_LEADS = {
|
|
99
88
|
plan_locked: "This brand is locked by the organisation's plan.",
|
|
@@ -101,10 +90,6 @@ const PLAN_REFUSAL_LEADS = {
|
|
|
101
90
|
format_locked: "The organisation's plan does not include this size; name another or omit it.",
|
|
102
91
|
brand_limit_reached: "The organisation's plan does not include another brand.",
|
|
103
92
|
};
|
|
104
|
-
/**
|
|
105
|
-
* The usage and billing links a plan refusal carries. Only https URLs on a tday host (or local
|
|
106
|
-
* development) are repeated to the assistant; anything else in the body is dropped.
|
|
107
|
-
*/
|
|
108
93
|
function refusalLinks(text) {
|
|
109
94
|
try {
|
|
110
95
|
const parsed = JSON.parse(text);
|
|
@@ -127,7 +112,6 @@ function tdayUrl(value) {
|
|
|
127
112
|
return null;
|
|
128
113
|
}
|
|
129
114
|
}
|
|
130
|
-
/** The refusal's machine-readable code (`insufficient_credits`, `plan_locked`, ...), when the body has one. */
|
|
131
115
|
function errorCode(text) {
|
|
132
116
|
if (!text)
|
|
133
117
|
return null;
|
|
@@ -149,7 +133,6 @@ function formatErrorBody(text) {
|
|
|
149
133
|
return message.slice(0, 300);
|
|
150
134
|
}
|
|
151
135
|
catch {
|
|
152
|
-
// Not JSON: nothing from it is shown.
|
|
153
136
|
}
|
|
154
137
|
return "";
|
|
155
138
|
}
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { startObservability } from "./observability.js";
|
|
3
|
-
startObservability();
|
|
4
2
|
import { packageVersion } from "./package-info.js";
|
|
5
3
|
import { runServer } from "./server.js";
|
|
6
4
|
import { loadConfig } from "./config.js";
|
|
@@ -13,7 +11,6 @@ async function main() {
|
|
|
13
11
|
await runServer();
|
|
14
12
|
return;
|
|
15
13
|
case "install":
|
|
16
|
-
// Lazy-load installer to keep `serve` cold-start tiny.
|
|
17
14
|
const { runInstaller } = await import("./installer.js");
|
|
18
15
|
await runInstaller(rest);
|
|
19
16
|
return;
|
package/dist/config.js
CHANGED
|
@@ -37,16 +37,12 @@ async function readFirstConfig(paths) {
|
|
|
37
37
|
return parsed;
|
|
38
38
|
}
|
|
39
39
|
catch {
|
|
40
|
-
// Try the next location. Missing or invalid config should not crash the server;
|
|
41
|
-
// the first tool call will return an actionable authentication error instead.
|
|
42
40
|
}
|
|
43
41
|
}
|
|
44
42
|
return null;
|
|
45
43
|
}
|
|
46
44
|
function normalizeBaseUrl(raw) {
|
|
47
45
|
const trimmed = raw.trim().replace(/\/+$/, "");
|
|
48
|
-
// Many users naturally set TDAY_API_URL=https://tday.com/api. The MCP tools
|
|
49
|
-
// pass full route paths like /api/billing/info, so strip a trailing /api.
|
|
50
46
|
return trimmed.replace(/\/api$/i, "");
|
|
51
47
|
}
|
|
52
48
|
function deriveWebBaseUrl(apiBaseUrl) {
|
package/dist/download-safety.js
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { lstat, mkdir, realpath } from "node:fs/promises";
|
|
2
2
|
import { dirname, isAbsolute, resolve, relative, sep } from "node:path";
|
|
3
|
-
// Download safety: URL allowlist + path sandboxing + size cap for
|
|
4
|
-
// tday_download_image (see tools.ts).
|
|
5
3
|
export const ALLOWED_IMAGE_HOSTS = new Set([
|
|
6
4
|
"cdn.tday.com",
|
|
7
|
-
"video-cdn.communities.one",
|
|
5
|
+
"video-cdn.communities.one",
|
|
8
6
|
"communities.one",
|
|
9
7
|
"media.brand.dev",
|
|
10
8
|
"asset.brandfetch.io",
|
|
@@ -13,7 +11,7 @@ export const ALLOWED_IMAGE_HOSTS = new Set([
|
|
|
13
11
|
"www.tday.com",
|
|
14
12
|
"app.tday.com",
|
|
15
13
|
]);
|
|
16
|
-
export const MAX_IMAGE_BYTES = 50 * 1024 * 1024;
|
|
14
|
+
export const MAX_IMAGE_BYTES = 50 * 1024 * 1024;
|
|
17
15
|
export function validateImageUrl(raw) {
|
|
18
16
|
let url;
|
|
19
17
|
try {
|
|
@@ -34,7 +32,6 @@ export function sandboxFilePath(raw) {
|
|
|
34
32
|
const cwd = process.cwd();
|
|
35
33
|
const resolved = resolve(cwd, raw);
|
|
36
34
|
const rel = relative(cwd, resolved);
|
|
37
|
-
// Match ".." only as a full path segment — a file literally named "..foo" is inside cwd.
|
|
38
35
|
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel) || resolve(cwd, rel) !== resolved) {
|
|
39
36
|
throw new Error(`File path must be inside the working directory. "${raw}" resolves outside of ${cwd}`);
|
|
40
37
|
}
|
package/dist/installer-agents.js
CHANGED
|
@@ -1,172 +1,146 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join, dirname } from "node:path";
|
|
4
|
-
import { pinnedPackageSpec } from "./package-info.js";
|
|
5
|
-
const SERVER_NAME = "tday";
|
|
6
|
-
|
|
7
|
-
// doesn't need the package installed globally; -y skips the prompt. The
|
|
8
|
-
// version is pinned to the installer's own (see pinnedPackageSpec).
|
|
4
|
+
import { pinnedCaptureSpec, pinnedPackageSpec } from "./package-info.js";
|
|
5
|
+
export const SERVER_NAME = "tday";
|
|
6
|
+
export const CAPTURE_SERVER_NAME = "tday-capture";
|
|
9
7
|
export function mcpCommand() {
|
|
10
8
|
return {
|
|
11
9
|
command: "npx",
|
|
12
10
|
args: ["-y", pinnedPackageSpec(), "serve"],
|
|
13
11
|
};
|
|
14
12
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return {
|
|
35
|
-
id: "claude-code",
|
|
36
|
-
name: "Claude Code",
|
|
37
|
-
action: "skipped",
|
|
38
|
-
configPath,
|
|
39
|
-
message: `Could not read ${configPath}: ${err.message}`,
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
let config;
|
|
43
|
-
try {
|
|
44
|
-
config = raw.trim() ? JSON.parse(raw) : {};
|
|
45
|
-
}
|
|
46
|
-
catch (err) {
|
|
47
|
-
return {
|
|
48
|
-
id: "claude-code",
|
|
49
|
-
name: "Claude Code",
|
|
50
|
-
action: "skipped",
|
|
51
|
-
configPath,
|
|
52
|
-
message: `Existing ${configPath} is not valid JSON (${err.message}). Fix it or patch manually.`,
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
const mcpServers = getMcpServers(config);
|
|
56
|
-
mcpServers[SERVER_NAME] = mcpCommand();
|
|
57
|
-
config.mcpServers = mcpServers;
|
|
58
|
-
writeFileAtomic(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
13
|
+
export function captureCommand() {
|
|
14
|
+
return {
|
|
15
|
+
command: "npx",
|
|
16
|
+
args: ["-y", pinnedCaptureSpec(), "mcp"],
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function serverEntries(servers) {
|
|
20
|
+
return {
|
|
21
|
+
[SERVER_NAME]: mcpCommand(),
|
|
22
|
+
...(servers.capture ? { [CAPTURE_SERVER_NAME]: captureCommand() } : {}),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function serverList(servers) {
|
|
26
|
+
return servers.capture ? `"${SERVER_NAME}" and "${CAPTURE_SERVER_NAME}" MCP servers` : `"${SERVER_NAME}" MCP server`;
|
|
27
|
+
}
|
|
28
|
+
const plainEntry = (server) => ({ ...server });
|
|
29
|
+
const vscodeEntry = (server) => ({ type: "stdio", ...server });
|
|
30
|
+
function claudeCodeTarget(home) {
|
|
31
|
+
const configPath = join(home, ".claude.json");
|
|
59
32
|
return {
|
|
60
33
|
id: "claude-code",
|
|
61
34
|
name: "Claude Code",
|
|
62
|
-
action: "patched",
|
|
63
35
|
configPath,
|
|
64
|
-
|
|
36
|
+
installDir: home,
|
|
37
|
+
requireFile: true,
|
|
38
|
+
missing: `No Claude Code config found at ${configPath}. Install Claude Code first, then re-run \`tday install\`.`,
|
|
39
|
+
key: "mcpServers",
|
|
40
|
+
entry: plainEntry,
|
|
65
41
|
};
|
|
66
42
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
// --------------------------------------------------------------------------
|
|
70
|
-
function patchCursor() {
|
|
71
|
-
const cursorDir = join(homedir(), ".cursor");
|
|
72
|
-
const configPath = join(cursorDir, "mcp.json");
|
|
73
|
-
if (!existsSync(cursorDir)) {
|
|
74
|
-
return {
|
|
75
|
-
id: "cursor",
|
|
76
|
-
name: "Cursor",
|
|
77
|
-
action: "skipped",
|
|
78
|
-
configPath,
|
|
79
|
-
message: `No Cursor install detected (${cursorDir} missing).`,
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
let config = {};
|
|
83
|
-
if (existsSync(configPath)) {
|
|
84
|
-
try {
|
|
85
|
-
const raw = readFileSync(configPath, "utf8");
|
|
86
|
-
config = raw.trim() ? JSON.parse(raw) : {};
|
|
87
|
-
}
|
|
88
|
-
catch (err) {
|
|
89
|
-
return {
|
|
90
|
-
id: "cursor",
|
|
91
|
-
name: "Cursor",
|
|
92
|
-
action: "skipped",
|
|
93
|
-
configPath,
|
|
94
|
-
message: `Existing ${configPath} is not valid JSON (${err.message}).`,
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
const mcpServers = getMcpServers(config);
|
|
99
|
-
mcpServers[SERVER_NAME] = mcpCommand();
|
|
100
|
-
config.mcpServers = mcpServers;
|
|
101
|
-
writeFileAtomic(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
43
|
+
function cursorTarget(home) {
|
|
44
|
+
const installDir = join(home, ".cursor");
|
|
102
45
|
return {
|
|
103
46
|
id: "cursor",
|
|
104
47
|
name: "Cursor",
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
48
|
+
configPath: join(installDir, "mcp.json"),
|
|
49
|
+
installDir,
|
|
50
|
+
missing: `No Cursor install detected (${installDir} missing).`,
|
|
51
|
+
key: "mcpServers",
|
|
52
|
+
entry: plainEntry,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function geminiTarget(home) {
|
|
56
|
+
const installDir = join(home, ".gemini");
|
|
57
|
+
return {
|
|
58
|
+
id: "gemini-cli",
|
|
59
|
+
name: "Gemini CLI",
|
|
60
|
+
configPath: join(installDir, "settings.json"),
|
|
61
|
+
installDir,
|
|
62
|
+
missing: `No Gemini CLI config directory detected (${installDir} missing).`,
|
|
63
|
+
key: "mcpServers",
|
|
64
|
+
entry: plainEntry,
|
|
108
65
|
};
|
|
109
66
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
67
|
+
export function vscodeTargets(home, platform = process.platform, env = process.env) {
|
|
68
|
+
const base = platform === "darwin"
|
|
69
|
+
? join(home, "Library", "Application Support")
|
|
70
|
+
: platform === "win32"
|
|
71
|
+
? env.APPDATA || join(home, "AppData", "Roaming")
|
|
72
|
+
: env.XDG_CONFIG_HOME || join(home, ".config");
|
|
73
|
+
return [
|
|
74
|
+
{ id: "vscode", name: "VS Code", app: "Code" },
|
|
75
|
+
{ id: "vscode-insiders", name: "VS Code Insiders", app: "Code - Insiders" },
|
|
76
|
+
].map(({ id, name, app }) => {
|
|
77
|
+
const installDir = join(base, app, "User");
|
|
119
78
|
return {
|
|
120
|
-
id:
|
|
121
|
-
name
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
79
|
+
id: id,
|
|
80
|
+
name,
|
|
81
|
+
configPath: join(installDir, "mcp.json"),
|
|
82
|
+
installDir,
|
|
83
|
+
missing: `No ${name} install detected (${installDir} missing).`,
|
|
84
|
+
key: "servers",
|
|
85
|
+
entry: vscodeEntry,
|
|
125
86
|
};
|
|
126
|
-
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function jsonTargets(home, env) {
|
|
90
|
+
return [claudeCodeTarget(home), cursorTarget(home), geminiTarget(home), ...vscodeTargets(home, process.platform, env)];
|
|
91
|
+
}
|
|
92
|
+
function patchJson(target, servers) {
|
|
93
|
+
const { id, name, configPath } = target;
|
|
94
|
+
const skipped = (message) => ({ id, name, action: "skipped", configPath, message });
|
|
95
|
+
if (!existsSync(target.installDir) || (target.requireFile && !existsSync(configPath)))
|
|
96
|
+
return skipped(target.missing);
|
|
127
97
|
let config = {};
|
|
128
98
|
if (existsSync(configPath)) {
|
|
99
|
+
let raw;
|
|
100
|
+
try {
|
|
101
|
+
raw = readFileSync(configPath, "utf8");
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
return skipped(`Could not read ${configPath}: ${err.message}`);
|
|
105
|
+
}
|
|
129
106
|
try {
|
|
130
|
-
const raw = readFileSync(configPath, "utf8");
|
|
131
107
|
config = raw.trim() ? JSON.parse(raw) : {};
|
|
132
108
|
}
|
|
133
109
|
catch (err) {
|
|
134
|
-
return {
|
|
135
|
-
id: "gemini-cli",
|
|
136
|
-
name: "Gemini CLI",
|
|
137
|
-
action: "skipped",
|
|
138
|
-
configPath,
|
|
139
|
-
message: `Existing ${configPath} is not valid JSON (${err.message}).`,
|
|
140
|
-
};
|
|
110
|
+
return skipped(`Existing ${configPath} is not valid JSON (${err.message}). Fix it or patch manually.`);
|
|
141
111
|
}
|
|
112
|
+
if (!isPlainRecord(config))
|
|
113
|
+
return skipped(`Existing ${configPath} is not a JSON object. Fix it or patch manually.`);
|
|
142
114
|
}
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
115
|
+
const existing = config[target.key];
|
|
116
|
+
const entries = isPlainRecord(existing) ? existing : {};
|
|
117
|
+
for (const [server, entry] of Object.entries(serverEntries(servers)))
|
|
118
|
+
entries[server] = target.entry(entry);
|
|
119
|
+
config[target.key] = entries;
|
|
146
120
|
writeFileAtomic(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
147
|
-
return {
|
|
148
|
-
id: "gemini-cli",
|
|
149
|
-
name: "Gemini CLI",
|
|
150
|
-
action: "patched",
|
|
151
|
-
configPath,
|
|
152
|
-
message: `Added "${SERVER_NAME}" MCP server to ${configPath}.`,
|
|
153
|
-
};
|
|
121
|
+
return { id, name, action: "patched", configPath, message: `Added ${serverList(servers)} to ${configPath}.` };
|
|
154
122
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
`[mcp_servers."${SERVER_NAME}"]`,
|
|
166
|
-
`command = "npx"`,
|
|
167
|
-
`args = ["-y", "${pinnedPackageSpec()}", "serve"]`,
|
|
123
|
+
function previewJson(target, servers) {
|
|
124
|
+
const { id, name, configPath } = target;
|
|
125
|
+
if (!existsSync(target.installDir) || (target.requireFile && !existsSync(configPath))) {
|
|
126
|
+
return { id, name, action: "skipped", configPath, message: target.missing };
|
|
127
|
+
}
|
|
128
|
+
return { id, name, action: "patched", configPath, message: `Would add ${serverList(servers)} to ${configPath}.` };
|
|
129
|
+
}
|
|
130
|
+
export function codexBlock(servers) {
|
|
131
|
+
return Object.entries(serverEntries(servers))
|
|
132
|
+
.map(([server, entry]) => [
|
|
168
133
|
``,
|
|
169
|
-
|
|
134
|
+
`[mcp_servers."${server}"]`,
|
|
135
|
+
`command = "${entry.command}"`,
|
|
136
|
+
`args = [${entry.args.map((arg) => `"${arg}"`).join(", ")}]`,
|
|
137
|
+
].join("\n"))
|
|
138
|
+
.join("\n")
|
|
139
|
+
.concat("\n");
|
|
140
|
+
}
|
|
141
|
+
function patchCodex(home, servers) {
|
|
142
|
+
const codexDir = join(home, ".codex");
|
|
143
|
+
const configPath = join(codexDir, "config.toml");
|
|
170
144
|
if (!existsSync(codexDir)) {
|
|
171
145
|
return {
|
|
172
146
|
id: "codex",
|
|
@@ -181,93 +155,35 @@ function patchCodex() {
|
|
|
181
155
|
name: "Codex",
|
|
182
156
|
action: "manual",
|
|
183
157
|
configPath,
|
|
184
|
-
message: `Append the following block to ${configPath}:\n${
|
|
158
|
+
message: `Append the following block to ${configPath}:\n${codexBlock(servers)}\n` +
|
|
185
159
|
`(Codex uses TOML; we don't auto-edit it to avoid corrupting your config.)`,
|
|
186
160
|
};
|
|
187
161
|
}
|
|
188
|
-
|
|
189
|
-
// Runner
|
|
190
|
-
// --------------------------------------------------------------------------
|
|
191
|
-
export function patchAllAgents() {
|
|
192
|
-
return [patchClaudeCode(), patchCursor(), patchGeminiCli(), patchCodex()];
|
|
193
|
-
}
|
|
194
|
-
// Preview what patchAllAgents would do without touching the filesystem.
|
|
195
|
-
// Used by `tday install --dry-run`.
|
|
196
|
-
export function previewAllAgents() {
|
|
197
|
-
const home = homedir();
|
|
198
|
-
const claudePath = join(home, ".claude.json");
|
|
199
|
-
const cursorPath = join(home, ".cursor", "mcp.json");
|
|
200
|
-
const geminiPath = join(home, ".gemini", "settings.json");
|
|
162
|
+
function previewCodex(home) {
|
|
201
163
|
const codexPath = join(home, ".codex", "config.toml");
|
|
164
|
+
return existsSync(join(home, ".codex"))
|
|
165
|
+
? { id: "codex", name: "Codex", action: "manual", configPath: codexPath, message: `Would print TOML block for ${codexPath} (manual append).` }
|
|
166
|
+
: { id: "codex", name: "Codex", action: "skipped", configPath: codexPath, message: `No Codex install detected.` };
|
|
167
|
+
}
|
|
168
|
+
export function patchAllAgents(servers = { capture: false }, home = homedir(), env = process.env) {
|
|
169
|
+
const [claude, cursor, gemini, ...vscode] = jsonTargets(home, env);
|
|
202
170
|
return [
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
configPath: claudePath,
|
|
209
|
-
message: `Would add "${SERVER_NAME}" MCP server to ${claudePath}.`,
|
|
210
|
-
}
|
|
211
|
-
: {
|
|
212
|
-
id: "claude-code",
|
|
213
|
-
name: "Claude Code",
|
|
214
|
-
action: "skipped",
|
|
215
|
-
configPath: claudePath,
|
|
216
|
-
message: `No Claude Code config at ${claudePath}.`,
|
|
217
|
-
},
|
|
218
|
-
existsSync(join(home, ".cursor"))
|
|
219
|
-
? {
|
|
220
|
-
id: "cursor",
|
|
221
|
-
name: "Cursor",
|
|
222
|
-
action: "patched",
|
|
223
|
-
configPath: cursorPath,
|
|
224
|
-
message: `Would add "${SERVER_NAME}" MCP server to ${cursorPath}.`,
|
|
225
|
-
}
|
|
226
|
-
: {
|
|
227
|
-
id: "cursor",
|
|
228
|
-
name: "Cursor",
|
|
229
|
-
action: "skipped",
|
|
230
|
-
configPath: cursorPath,
|
|
231
|
-
message: `No Cursor install detected.`,
|
|
232
|
-
},
|
|
233
|
-
existsSync(join(home, ".gemini"))
|
|
234
|
-
? {
|
|
235
|
-
id: "gemini-cli",
|
|
236
|
-
name: "Gemini CLI",
|
|
237
|
-
action: "patched",
|
|
238
|
-
configPath: geminiPath,
|
|
239
|
-
message: `Would add "${SERVER_NAME}" MCP server to ${geminiPath}.`,
|
|
240
|
-
}
|
|
241
|
-
: {
|
|
242
|
-
id: "gemini-cli",
|
|
243
|
-
name: "Gemini CLI",
|
|
244
|
-
action: "skipped",
|
|
245
|
-
configPath: geminiPath,
|
|
246
|
-
message: `No Gemini CLI config directory detected.`,
|
|
247
|
-
},
|
|
248
|
-
existsSync(join(home, ".codex"))
|
|
249
|
-
? {
|
|
250
|
-
id: "codex",
|
|
251
|
-
name: "Codex",
|
|
252
|
-
action: "manual",
|
|
253
|
-
configPath: codexPath,
|
|
254
|
-
message: `Would print TOML block for ${codexPath} (manual append).`,
|
|
255
|
-
}
|
|
256
|
-
: {
|
|
257
|
-
id: "codex",
|
|
258
|
-
name: "Codex",
|
|
259
|
-
action: "skipped",
|
|
260
|
-
configPath: codexPath,
|
|
261
|
-
message: `No Codex install detected.`,
|
|
262
|
-
},
|
|
171
|
+
patchJson(claude, servers),
|
|
172
|
+
patchJson(cursor, servers),
|
|
173
|
+
patchJson(gemini, servers),
|
|
174
|
+
patchCodex(home, servers),
|
|
175
|
+
...vscode.map((target) => patchJson(target, servers)),
|
|
263
176
|
];
|
|
264
177
|
}
|
|
265
|
-
function
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
178
|
+
export function previewAllAgents(servers = { capture: false }, home = homedir(), env = process.env) {
|
|
179
|
+
const [claude, cursor, gemini, ...vscode] = jsonTargets(home, env);
|
|
180
|
+
return [
|
|
181
|
+
previewJson(claude, servers),
|
|
182
|
+
previewJson(cursor, servers),
|
|
183
|
+
previewJson(gemini, servers),
|
|
184
|
+
previewCodex(home),
|
|
185
|
+
...vscode.map((target) => previewJson(target, servers)),
|
|
186
|
+
];
|
|
271
187
|
}
|
|
272
188
|
function isPlainRecord(value) {
|
|
273
189
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -1,8 +1,4 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
|
-
// Boots a loopback HTTP server on a random free port, resolves once the
|
|
3
|
-
// /cli/auth browser page returns its authorization payload, and rejects on
|
|
4
|
-
// timeout or malformed payloads. Binds 127.0.0.1 only so no remote host can
|
|
5
|
-
// reach it.
|
|
6
2
|
export async function startCallbackServer(options) {
|
|
7
3
|
let resolve;
|
|
8
4
|
let reject;
|
|
@@ -58,9 +54,6 @@ export async function startCallbackServer(options) {
|
|
|
58
54
|
}
|
|
59
55
|
const payload = parsed;
|
|
60
56
|
if (payload.state !== options.expectedState) {
|
|
61
|
-
// Do not reject the whole installer on a bad local POST. A different
|
|
62
|
-
// local process can hit the loopback listener, so keep waiting for the
|
|
63
|
-
// browser page carrying the expected nonce until timeout.
|
|
64
57
|
res.statusCode = 400;
|
|
65
58
|
res.end("state mismatch");
|
|
66
59
|
return;
|