@upturtle/wizard 0.1.2 → 0.2.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 CHANGED
@@ -8,23 +8,33 @@ npx @upturtle/wizard
8
8
 
9
9
  The installer:
10
10
 
11
- 1. Opens UpTurtle in your browser to authenticate and mint a scoped personal access token.
12
- 2. Detects which coding agent(s) you have installed (Claude Code, Cursor, Antigravity, OpenCode, Zed, Claude Desktop).
13
- 3. Writes the MCP server entry into each agent's native config file.
14
- 4. Verifies the connection.
11
+ 1. Detects which coding agent(s) you have installed (Claude Code, Cursor, Antigravity, OpenCode, Zed, Claude Desktop).
12
+ 2. Shows a checkbox list `↑`/`↓` to move, space to toggle, `a` to toggle all, enter to confirm.
13
+ 3. Opens UpTurtle in your browser to authenticate and mint a scoped personal access token.
14
+ 4. Writes the MCP server entry into each selected agent's config.
15
15
 
16
16
  Once it's done, ask your AI: _"deploy this project to UpTurtle."_
17
17
 
18
+ ## Scope
19
+
20
+ By default the wizard writes to your **user-wide** config — the MCP server is available in every project that agent opens. To install into the **current project** instead (so it's checked into git and shared with the team), pass `--project`:
21
+
22
+ ```bash
23
+ npx @upturtle/wizard --project
24
+ ```
25
+
26
+ Project scope is supported for `claude-code`, `cursor`, `opencode`, and `zed`. Antigravity and Claude Desktop don't have a project-level config format.
27
+
18
28
  ## Flags
19
29
 
20
30
  | Flag | Description |
21
31
  |---|---|
22
- | `--host=<url>` | Override the UpTurtle host. Defaults to `https://what.upturtle.com`. |
23
- | `--agent=<id,...>` | Skip auto-detection and target specific agent(s). |
32
+ | `--host=<url>` | Override the UpTurtle host. Defaults to `https://what.upturtle.com`. Also `UPTURTLE_HOST=<url>`. |
33
+ | `--scope=<scope>` | `user` (default) or `project`. Shorthands: `--user`, `--project`. |
34
+ | `--agent=<id,...>` | Skip auto-detection and the prompt; install into specific agent(s). Comma-separated. |
35
+ | `--yes`, `-y` | Skip the prompt; install into every detected agent. Implied when stdin is not a TTY (e.g. CI). |
24
36
  | `--help`, `-h` | Show usage. |
25
37
 
26
- The host can also be supplied via the `UPTURTLE_HOST` environment variable.
27
-
28
38
  ## License
29
39
 
30
40
  Proprietary.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upturtle/wizard",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Connects your coding agent to UpTurtle's MCP server. Detects the agent, mints a scoped PAT via browser handshake, and writes the right MCP config.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agents.mjs CHANGED
@@ -3,42 +3,51 @@ import { homedir, platform } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
 
6
+ // `scopes` lists the install scopes the agent's writer supports. Agents whose
7
+ // native config formats have no project-level alternative (Antigravity, Claude
8
+ // Desktop) are user-only.
6
9
  export const AGENTS = [
7
10
  {
8
11
  id: "claude-code",
9
12
  label: "Claude Code",
10
13
  detect: () => isOnPath("claude"),
11
14
  writer: "claude-code",
15
+ scopes: ["user", "project"],
12
16
  },
13
17
  {
14
18
  id: "cursor",
15
19
  label: "Cursor",
16
20
  detect: () => existsSync(cursorAppDataDir()),
17
21
  writer: "cursor",
22
+ scopes: ["user", "project"],
18
23
  },
19
24
  {
20
25
  id: "antigravity",
21
26
  label: "Google Antigravity",
22
27
  detect: () => existsSync(antigravityConfigPath()),
23
28
  writer: "antigravity",
29
+ scopes: ["user"],
24
30
  },
25
31
  {
26
32
  id: "opencode",
27
33
  label: "OpenCode",
28
34
  detect: () => isOnPath("opencode") || existsSync(opencodeConfigPath()),
29
35
  writer: "opencode",
36
+ scopes: ["user", "project"],
30
37
  },
31
38
  {
32
39
  id: "zed",
33
40
  label: "Zed",
34
41
  detect: () => existsSync(zedConfigPath()) || existsSync("/Applications/Zed.app"),
35
42
  writer: "zed",
43
+ scopes: ["user", "project"],
36
44
  },
37
45
  {
38
46
  id: "claude-desktop",
39
47
  label: "Claude Desktop",
40
48
  detect: () => existsSync(claudeDesktopConfigPath()),
41
49
  writer: "claude-desktop",
50
+ scopes: ["user"],
42
51
  },
43
52
  ];
44
53
 
package/src/index.mjs CHANGED
@@ -15,25 +15,43 @@ export async function run(argv) {
15
15
  const log = console.log;
16
16
 
17
17
  log("UpTurtle install");
18
- log(" host: " + host);
18
+ log(" host: " + host);
19
+ log(" scope: " + flags.scope + (flags.scope === "project" ? ` (cwd: ${process.cwd()})` : ""));
19
20
 
20
21
  const detected = detectInstalledAgents();
21
- if (detected.length === 0) {
22
+ if (detected.length === 0 && !flags.agent) {
22
23
  log("");
23
24
  log("Couldn't detect any supported coding agents on this machine.");
24
25
  log("Supported: " + AGENTS.map((a) => a.label).join(", "));
25
26
  log("");
26
27
  log("If your agent is installed but not detected, re-run with --agent=<id>:");
27
28
  log(" " + AGENTS.map((a) => a.id).join(", "));
28
- if (!flags.agent) {
29
- process.exitCode = 1;
30
- return;
31
- }
29
+ process.exitCode = 1;
30
+ return;
31
+ }
32
+
33
+ let targets;
34
+ try {
35
+ targets = await pickTargets(detected, flags);
36
+ } catch (err) {
37
+ log(err.message);
38
+ process.exitCode = 1;
39
+ return;
32
40
  }
33
41
 
34
- const targets = resolveTargets(detected, flags);
35
42
  if (targets.length === 0) {
36
- log("No matching agents to install into. Exiting.");
43
+ log("No agents selected. Exiting.");
44
+ process.exitCode = 1;
45
+ return;
46
+ }
47
+
48
+ // Validate scope compatibility BEFORE minting a token — no point burning a
49
+ // PAT just to discover the chosen scope is unsupported.
50
+ const incompatible = targets.filter((a) => !a.scopes.includes(flags.scope));
51
+ if (incompatible.length > 0) {
52
+ log("");
53
+ log(`Scope "${flags.scope}" isn't supported by: ${incompatible.map((a) => a.label).join(", ")}`);
54
+ log("Re-run with --scope=user, or pick different agent(s) via --agent= or the prompt.");
37
55
  process.exitCode = 1;
38
56
  return;
39
57
  }
@@ -55,7 +73,7 @@ export async function run(argv) {
55
73
  continue;
56
74
  }
57
75
  try {
58
- const out = writer({ host, secret });
76
+ const out = writer({ host, secret, scope: flags.scope });
59
77
  log(` ✓ ${agent.label} → ${out.configPath}`);
60
78
  results.push({ agent, ok: true });
61
79
  } catch (err) {
@@ -79,12 +97,21 @@ function parseFlags(argv) {
79
97
  const flags = {
80
98
  host: process.env.UPTURTLE_HOST ?? DEFAULT_HOST,
81
99
  agent: null,
100
+ scope: "user",
101
+ yes: false,
82
102
  help: false,
83
103
  };
84
104
  for (const a of argv) {
85
105
  if (a === "--help" || a === "-h") flags.help = true;
106
+ else if (a === "--yes" || a === "-y") flags.yes = true;
86
107
  else if (a.startsWith("--host=")) flags.host = a.slice("--host=".length);
87
108
  else if (a.startsWith("--agent=")) flags.agent = a.slice("--agent=".length);
109
+ else if (a.startsWith("--scope=")) flags.scope = a.slice("--scope=".length);
110
+ else if (a === "--project") flags.scope = "project";
111
+ else if (a === "--user") flags.scope = "user";
112
+ }
113
+ if (!["user", "project"].includes(flags.scope)) {
114
+ throw new Error(`--scope must be "user" or "project" (got "${flags.scope}")`);
88
115
  }
89
116
  flags.host = stripTrailingSlash(flags.host);
90
117
  return flags;
@@ -94,19 +121,134 @@ function stripTrailingSlash(s) {
94
121
  return s.endsWith("/") ? s.slice(0, -1) : s;
95
122
  }
96
123
 
97
- function resolveTargets(detected, flags) {
124
+ // Decides which agents to install for. Order of precedence:
125
+ // 1. Explicit --agent=id,id (skips prompt; honored even if not in detected)
126
+ // 2. Non-interactive (--yes or non-TTY): every detected agent
127
+ // 3. Interactive: prompt the user
128
+ async function pickTargets(detected, flags) {
98
129
  if (flags.agent) {
99
- const ids = flags.agent.split(",").map((s) => s.trim()).filter(Boolean);
100
- const matched = ids
101
- .map((id) => AGENTS.find((a) => a.id === id))
102
- .filter(Boolean);
103
- if (matched.length !== ids.length) {
104
- const known = AGENTS.map((a) => a.id).join(", ");
105
- throw new Error(`Unknown --agent value. Known ids: ${known}`);
106
- }
107
- return matched;
130
+ return resolveExplicitAgents(flags.agent);
131
+ }
132
+
133
+ if (flags.yes || !process.stdin.isTTY) {
134
+ return detected;
108
135
  }
109
- return detected;
136
+
137
+ return await promptForAgents(detected);
138
+ }
139
+
140
+ function resolveExplicitAgents(value) {
141
+ const ids = value.split(",").map((s) => s.trim()).filter(Boolean);
142
+ const matched = ids
143
+ .map((id) => AGENTS.find((a) => a.id === id))
144
+ .filter(Boolean);
145
+ if (matched.length !== ids.length) {
146
+ const known = AGENTS.map((a) => a.id).join(", ");
147
+ throw new Error(`Unknown --agent value. Known ids: ${known}`);
148
+ }
149
+ return matched;
150
+ }
151
+
152
+ // Inquirer-style checkbox prompt. ↑/↓ moves the cursor, space toggles the
153
+ // current row, `a` toggles all, enter confirms, q or Ctrl-C aborts. Falls back
154
+ // to "install all detected" when the terminal doesn't support raw-mode input
155
+ // (rare; only relevant if some host fakes a TTY without keystroke capture).
156
+ async function promptForAgents(detected) {
157
+ const stdin = process.stdin;
158
+ const stdout = process.stdout;
159
+
160
+ if (typeof stdin.setRawMode !== "function") {
161
+ return detected;
162
+ }
163
+
164
+ return await new Promise((resolve, reject) => {
165
+ let cursor = 0;
166
+ const selected = new Set();
167
+ let linesWritten = 0;
168
+
169
+ const render = () => {
170
+ if (linesWritten > 0) {
171
+ // Move up to start of last render and clear from there to end of screen.
172
+ stdout.write(`\x1b[${linesWritten}A\x1b[0J`);
173
+ }
174
+ let buf = "";
175
+ buf += "Set up MCP for which agents?\n";
176
+ buf += " ↑/↓ move · space toggle · a toggle-all · enter confirm · q quit\n";
177
+ detected.forEach((agent, i) => {
178
+ const cursorMark = i === cursor ? "\x1b[36m❯\x1b[0m" : " ";
179
+ const checkbox = selected.has(i) ? "\x1b[32m[x]\x1b[0m" : "[ ]";
180
+ buf += ` ${cursorMark} ${checkbox} ${agent.label}\n`;
181
+ });
182
+ stdout.write(buf);
183
+ linesWritten = (buf.match(/\n/g) || []).length;
184
+ };
185
+
186
+ const cleanup = () => {
187
+ try { stdin.setRawMode(false); } catch { /* ignore */ }
188
+ stdin.pause();
189
+ stdout.write("\x1b[?25h"); // restore cursor
190
+ stdin.removeListener("data", onData);
191
+ };
192
+
193
+ const finalize = () => {
194
+ if (linesWritten > 0) {
195
+ stdout.write(`\x1b[${linesWritten}A\x1b[0J`);
196
+ }
197
+ const result = [...selected].sort((a, b) => a - b).map((i) => detected[i]);
198
+ stdout.write(
199
+ `Selected: ${result.length > 0 ? result.map((a) => a.label).join(", ") : "(none)"}\n`,
200
+ );
201
+ cleanup();
202
+ if (result.length === 0) {
203
+ reject(new Error("No agents selected. Aborted."));
204
+ } else {
205
+ resolve(result);
206
+ }
207
+ };
208
+
209
+ const abort = (exitCode) => {
210
+ cleanup();
211
+ stdout.write("\n");
212
+ if (exitCode !== undefined) {
213
+ process.exit(exitCode);
214
+ }
215
+ reject(new Error("Aborted."));
216
+ };
217
+
218
+ const onData = (key) => {
219
+ if (key === "\x03") return abort(130); // Ctrl-C
220
+ if (key === "q" || key === "Q") return abort();
221
+ if (key === "\x1b[A" || key === "k") { // ↑
222
+ cursor = (cursor - 1 + detected.length) % detected.length;
223
+ return render();
224
+ }
225
+ if (key === "\x1b[B" || key === "j") { // ↓
226
+ cursor = (cursor + 1) % detected.length;
227
+ return render();
228
+ }
229
+ if (key === " ") {
230
+ if (selected.has(cursor)) selected.delete(cursor);
231
+ else selected.add(cursor);
232
+ return render();
233
+ }
234
+ if (key === "a" || key === "A") {
235
+ if (selected.size === detected.length) {
236
+ selected.clear();
237
+ } else {
238
+ for (let i = 0; i < detected.length; i++) selected.add(i);
239
+ }
240
+ return render();
241
+ }
242
+ if (key === "\r" || key === "\n") return finalize();
243
+ };
244
+
245
+ stdout.write("\x1b[?25l"); // hide cursor
246
+ stdin.setRawMode(true);
247
+ stdin.resume();
248
+ stdin.setEncoding("utf8");
249
+ stdin.on("data", onData);
250
+ render();
251
+ });
110
252
  }
111
253
 
112
254
  function printHelp() {
@@ -115,10 +257,21 @@ function printHelp() {
115
257
  npx @upturtle/wizard [options]
116
258
 
117
259
  Options:
118
- --host=<url> Override the UpTurtle host. Defaults to https://what.upturtle.com.
260
+ --host=<url> Override the UpTurtle host. Defaults to ${DEFAULT_HOST}.
119
261
  Also: UPTURTLE_HOST=<url>.
120
- --agent=<id,...> Skip auto-detection and install into specific agent(s).
262
+ --scope=<scope> Where to write the MCP config:
263
+ user — your home dir; available in every project (default).
264
+ project — the current working directory; checked into git,
265
+ shareable across the team.
266
+ Shorthand flags: --user, --project.
267
+ --agent=<id,...> Skip auto-detection and the prompt; install into specific agent(s).
121
268
  Known ids: ${AGENTS.map((a) => a.id).join(", ")}
269
+ --yes, -y Skip the agent prompt and install into every detected agent.
270
+ Implied when stdin is not a TTY (e.g. CI).
122
271
  --help, -h Show this message.
272
+
273
+ Project scope is supported for: claude-code, cursor, opencode, zed.
274
+ Antigravity and Claude Desktop only support user scope (their config formats
275
+ don't have a project-level alternative).
123
276
  `);
124
277
  }
package/src/writers.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
2
- import { dirname } from "node:path";
2
+ import { dirname, join } from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
4
 
5
5
  import {
@@ -20,26 +20,49 @@ export const WRITERS = {
20
20
  "claude-desktop": writeClaudeDesktop,
21
21
  };
22
22
 
23
- function writeClaudeCode({ host, secret }) {
24
- removeClaudeCodeServer();
23
+ function writeClaudeCode({ host, secret, scope }) {
24
+ removeClaudeCodeServer(scope);
25
25
  const args = [
26
26
  "mcp", "add", SERVER_KEY, host + "/mcp",
27
27
  "--transport", "http",
28
28
  "--header", `Authorization: Bearer ${secret}`,
29
29
  ];
30
+ if (scope === "project") args.push("--scope", "project");
30
31
  const result = spawnSync("claude", args, { stdio: "pipe", encoding: "utf8" });
31
32
  if (result.status !== 0) {
32
33
  const detail = (result.stderr || result.stdout || "").trim();
33
34
  throw new Error(`claude mcp add failed: ${detail || `exit ${result.status}`}`);
34
35
  }
35
- return { configPath: "(managed by `claude mcp`)" };
36
+ return {
37
+ configPath: scope === "project"
38
+ ? "./.mcp.json (managed by `claude mcp`)"
39
+ : "(managed by `claude mcp`)",
40
+ };
36
41
  }
37
42
 
38
- function removeClaudeCodeServer() {
39
- spawnSync("claude", ["mcp", "remove", SERVER_KEY], { stdio: "ignore" });
43
+ function removeClaudeCodeServer(scope) {
44
+ const args = ["mcp", "remove", SERVER_KEY];
45
+ if (scope === "project") args.push("--scope", "project");
46
+ spawnSync("claude", args, { stdio: "ignore" });
40
47
  }
41
48
 
42
- function writeCursor({ host, secret }) {
49
+ function writeCursor({ host, secret, scope }) {
50
+ if (scope === "project") {
51
+ // Project: write .cursor/mcp.json directly. The deep link only handles
52
+ // user-scope; project config lives in the repo and is meant to be checked
53
+ // in.
54
+ const path = join(process.cwd(), ".cursor", "mcp.json");
55
+ return mergeJson(path, (cfg) => {
56
+ cfg.mcpServers ??= {};
57
+ cfg.mcpServers[SERVER_KEY] = {
58
+ url: host + "/mcp",
59
+ headers: { Authorization: `Bearer ${secret}` },
60
+ };
61
+ return cfg;
62
+ });
63
+ }
64
+
65
+ // User scope: open the Cursor deep link.
43
66
  const config = {
44
67
  url: host + "/mcp",
45
68
  headers: { Authorization: `Bearer ${secret}` },
@@ -59,7 +82,12 @@ function writeCursor({ host, secret }) {
59
82
  return { configPath: "(installed via Cursor deep link)" };
60
83
  }
61
84
 
62
- function writeAntigravity({ host, secret }) {
85
+ function writeAntigravity({ host, secret, scope }) {
86
+ if (scope === "project") {
87
+ throw new Error(
88
+ "Antigravity doesn't have a project-scoped MCP config (only user-wide). " +
89
+ "Re-run without --scope=project for this agent.");
90
+ }
63
91
  const path = antigravityConfigPath();
64
92
  return mergeJson(path, (cfg) => {
65
93
  cfg.mcpServers ??= {};
@@ -71,8 +99,10 @@ function writeAntigravity({ host, secret }) {
71
99
  });
72
100
  }
73
101
 
74
- function writeOpenCode({ host, secret }) {
75
- const path = opencodeConfigPath();
102
+ function writeOpenCode({ host, secret, scope }) {
103
+ const path = scope === "project"
104
+ ? join(process.cwd(), "opencode.json")
105
+ : opencodeConfigPath();
76
106
  return mergeJson(path, (cfg) => {
77
107
  cfg.mcp ??= {};
78
108
  cfg.mcp[SERVER_KEY] = {
@@ -84,8 +114,10 @@ function writeOpenCode({ host, secret }) {
84
114
  });
85
115
  }
86
116
 
87
- function writeZed({ host, secret }) {
88
- const path = zedConfigPath();
117
+ function writeZed({ host, secret, scope }) {
118
+ const path = scope === "project"
119
+ ? join(process.cwd(), ".zed", "settings.json")
120
+ : zedConfigPath();
89
121
  return mergeJson(path, (cfg) => {
90
122
  cfg.context_servers ??= {};
91
123
  cfg.context_servers[SERVER_KEY] = {
@@ -103,14 +135,26 @@ function writeZed({ host, secret }) {
103
135
  });
104
136
  }
105
137
 
106
- function writeClaudeDesktop({ host, secret }) {
138
+ function writeClaudeDesktop({ host, secret, scope }) {
139
+ if (scope === "project") {
140
+ throw new Error(
141
+ "Claude Desktop has only one global config file — there's no project " +
142
+ "scope. Re-run without --scope=project for this agent.");
143
+ }
107
144
  const path = claudeDesktopConfigPath();
108
145
  return mergeJson(path, (cfg) => {
109
146
  cfg.mcpServers ??= {};
147
+ // Claude Desktop's config only honors stdio MCP servers (command + args).
148
+ // Bridge the remote HTTP server through `mcp-remote`, same as the Zed entry.
110
149
  cfg.mcpServers[SERVER_KEY] = {
111
- type: "streamable-http",
112
- url: host + "/mcp",
113
- headers: { Authorization: `Bearer ${secret}` },
150
+ command: "npx",
151
+ args: [
152
+ "-y",
153
+ "mcp-remote",
154
+ host + "/mcp",
155
+ "--header",
156
+ `Authorization: Bearer ${secret}`,
157
+ ],
114
158
  };
115
159
  return cfg;
116
160
  });