@tday-com/mcp 0.2.0 → 0.3.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.
Files changed (42) hide show
  1. package/README.md +25 -6
  2. package/dist/api-client.js +1 -18
  3. package/dist/cli.js +0 -3
  4. package/dist/config.js +0 -4
  5. package/dist/download-safety.js +2 -5
  6. package/dist/installer-agents.js +132 -216
  7. package/dist/installer-callback.js +0 -7
  8. package/dist/installer-slash-command.js +94 -35
  9. package/dist/installer.js +34 -11
  10. package/dist/package-info.js +14 -8
  11. package/dist/prompts.js +36 -0
  12. package/dist/result-format.js +7 -31
  13. package/dist/server.js +12 -6
  14. package/dist/tool-schemas.js +0 -14
  15. package/dist/tools-account.js +0 -4
  16. package/dist/tools-features.js +39 -0
  17. package/dist/tools-launch.js +43 -50
  18. package/dist/tools.js +5 -26
  19. package/package.json +4 -14
  20. package/dist/api-client.d.ts +0 -23
  21. package/dist/cli.d.ts +0 -2
  22. package/dist/config.d.ts +0 -7
  23. package/dist/demo-import.d.ts +0 -6
  24. package/dist/demo-import.js +0 -101
  25. package/dist/download-safety.d.ts +0 -7
  26. package/dist/installer-agents.d.ts +0 -17
  27. package/dist/installer-callback.d.ts +0 -13
  28. package/dist/installer-slash-command.d.ts +0 -12
  29. package/dist/installer.d.ts +0 -2
  30. package/dist/observability.d.ts +0 -1
  31. package/dist/observability.js +0 -77
  32. package/dist/package-info.d.ts +0 -11
  33. package/dist/result-format.d.ts +0 -4
  34. package/dist/server.d.ts +0 -1
  35. package/dist/tool-schemas.d.ts +0 -460
  36. package/dist/tools-account.d.ts +0 -6
  37. package/dist/tools-demo.d.ts +0 -11
  38. package/dist/tools-demo.js +0 -57
  39. package/dist/tools-knowledge.d.ts +0 -4
  40. package/dist/tools-knowledge.js +0 -111
  41. package/dist/tools-launch.d.ts +0 -4
  42. 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, and Gemini CLI get MCP config entries; Codex gets a TOML block to paste. No manual key copying is needed.
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
- - `tday_search_knowledge_entries` / `tday_create_knowledge_entry` - read and record tday codebase knowledge entries (tday staff accounts only)
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
- - **Telemetry is off by default.** Set `TDAY_MCP_TELEMETRY=1` to send this server's traces, metrics and logs (tool calls and their requests to tday, never your token or file contents) to help us debug.
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-ideas` in the slash-command autocomplete list.
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
 
@@ -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; // caller cancelled — not a timeout
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) {
@@ -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", // pre-2026 stored URLs
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; // 50 MB
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
  }
@@ -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
- // Command used inside each agent's MCP config. Uses npx -y so the user
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
- // Claude Code — JSON at ~/.claude.json
17
- // --------------------------------------------------------------------------
18
- function patchClaudeCode() {
19
- const configPath = join(homedir(), ".claude.json");
20
- if (!existsSync(configPath)) {
21
- return {
22
- id: "claude-code",
23
- name: "Claude Code",
24
- action: "skipped",
25
- configPath,
26
- message: `No Claude Code config found at ${configPath}. Install Claude Code first, then re-run \`tday install\`.`,
27
- };
28
- }
29
- let raw;
30
- try {
31
- raw = readFileSync(configPath, "utf8");
32
- }
33
- catch (err) {
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
- message: `Added "${SERVER_NAME}" MCP server to ${configPath}.`,
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
- // Cursor — JSON at ~/.cursor/mcp.json
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
- action: "patched",
106
- configPath,
107
- message: `Added "${SERVER_NAME}" MCP server to ${configPath}.`,
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
- // Gemini CLI — JSON at ~/.gemini/settings.json
112
- // Gemini CLI follows the common MCP config shape with a top-level mcpServers
113
- // object, matching Cursor/Claude-style entries.
114
- // --------------------------------------------------------------------------
115
- function patchGeminiCli() {
116
- const geminiDir = join(homedir(), ".gemini");
117
- const configPath = join(geminiDir, "settings.json");
118
- if (!existsSync(geminiDir)) {
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: "gemini-cli",
121
- name: "Gemini CLI",
122
- action: "skipped",
123
- configPath,
124
- message: `No Gemini CLI config directory detected (${geminiDir} missing).`,
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 mcpServers = getMcpServers(config);
144
- mcpServers[SERVER_NAME] = mcpCommand();
145
- config.mcpServers = mcpServers;
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
- // Codex — TOML at ~/.codex/config.toml
157
- // We avoid parsing TOML to keep zero runtime deps; instead, print the
158
- // block so the user can paste it in. Safer than corrupting a user's file.
159
- // --------------------------------------------------------------------------
160
- function patchCodex() {
161
- const codexDir = join(homedir(), ".codex");
162
- const configPath = join(codexDir, "config.toml");
163
- const block = [
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
- ].join("\n");
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${block}\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
- existsSync(claudePath)
204
- ? {
205
- id: "claude-code",
206
- name: "Claude Code",
207
- action: "patched",
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 getMcpServers(config) {
266
- const existing = config.mcpServers;
267
- if (isPlainRecord(existing)) {
268
- return existing;
269
- }
270
- return {};
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;