svcloud 0.1.0-alpha.3 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,24 +3,26 @@
3
3
  The command-line interface for [SV Cloud](https://cloud.sv-academy.org) — and
4
4
  the way an AI coding agent reaches the platform.
5
5
 
6
- > **Alpha.** Sign-in, status, app listing, the `mcp` bridge, and `init`
7
- > (below) are built see Commands for the full current list. `logs`,
8
- > `deploy`, `secrets`, `db`, and `dev` are still on the way. Published under
9
- > the `alpha` dist-tag, not `latest`.
6
+ > **Alpha.** Published under the `alpha` dist-tag, not `latest` — see
7
+ > Commands below for what's built. `logs` and `dev` are still on the way.
10
8
 
11
9
  ## Install
12
10
 
13
11
  ```bash
14
- npx svcloud@alpha login
12
+ npm install -g svcloud
13
+ svcloud login
15
14
  ```
16
15
 
17
- Or install it:
16
+ Or try it once without installing:
18
17
 
19
18
  ```bash
20
- npm install -g svcloud@alpha
21
- svcloud login
19
+ npx svcloud login
22
20
  ```
23
21
 
22
+ An installed `svcloud` is what every example below uses, and is what a
23
+ coding agent's harness config should point at too — see [Connect your coding
24
+ agent](#connect-your-coding-agent).
25
+
24
26
  `login` opens your browser to sign in, then stores your session in your
25
27
  system's keychain (Keychain on macOS; Secret Service on Linux; Credential
26
28
  Manager on Windows — falling back to a local file, with a warning, if none
@@ -37,7 +39,12 @@ svcloud open <app> Open an app's web address in your browser
37
39
  svcloud projects list List your apps
38
40
  svcloud init Connect a repo (defaults to the one in your working directory) as a new app
39
41
  svcloud runs <app> Show or watch an app's provisioning run
42
+ svcloud secrets <cmd> Manage an app's keys & settings (list/set/remove)
43
+ svcloud db <cmd> Browse and edit an app's database (see `svcloud db` for subcommands)
44
+ svcloud deploy <app> Push the current branch and watch the build (must be the app's default branch)
40
45
  svcloud mcp Run the local MCP bridge (for a coding agent's harness config)
46
+ svcloud mcp setup <harness> Write a coding agent harness's MCP config for svcloud
47
+ svcloud mcp check Diagnose sign-in state, harness configs, and live tool visibility
41
48
 
42
49
  Flags:
43
50
  --json Machine-readable output, on every read command
@@ -49,7 +56,7 @@ to skip what it would otherwise infer or ask about; `runs` reads `--id
49
56
  (follow it to completion). Neither needs any of that in the common case:
50
57
  `cd` into your repo and run `svcloud init`.
51
58
 
52
- More commands (`logs`, `deploy`, `secrets`, `db`, `dev`) are on the way.
59
+ `logs` and `dev` are still on the way.
53
60
 
54
61
  ## Connect your coding agent
55
62
 
@@ -66,7 +73,19 @@ First, sign in on your machine:
66
73
  svcloud login
67
74
  ```
68
75
 
69
- Then configure your agent:
76
+ Then let the CLI write your harness's config for you:
77
+
78
+ ```bash
79
+ svcloud mcp setup claude-code # or: cursor | opencode | antigravity | gemini-cli | codex
80
+ ```
81
+
82
+ That writes the one file each harness actually reads, in the shape it
83
+ actually expects — the config blocks below are the same data, by hand, for a
84
+ harness that isn't in the list yet, or if you'd rather see exactly what gets
85
+ written first. If something still doesn't work after setup, run
86
+ `svcloud mcp check` — it reports sign-in state, a live count of tools your
87
+ token can see, and whether each known harness's config file exists and has
88
+ the right shape.
70
89
 
71
90
  ### Antigravity
72
91
  Add to `~/.gemini/config/mcp_config.json` (global) or `.agents/mcp_config.json` (workspace):
@@ -75,8 +94,8 @@ Add to `~/.gemini/config/mcp_config.json` (global) or `.agents/mcp_config.json`
75
94
  {
76
95
  "mcpServers": {
77
96
  "svcloud": {
78
- "command": "npx",
79
- "args": ["-y", "svcloud@alpha", "mcp"]
97
+ "command": "svcloud",
98
+ "args": ["mcp"]
80
99
  }
81
100
  }
82
101
  }
@@ -84,7 +103,7 @@ Add to `~/.gemini/config/mcp_config.json` (global) or `.agents/mcp_config.json`
84
103
 
85
104
  ### Gemini CLI
86
105
  ```bash
87
- gemini mcp add svcloud -- npx -y svcloud@alpha mcp
106
+ gemini mcp add svcloud -- svcloud mcp
88
107
  ```
89
108
  Or add the `svcloud` block under `mcpServers` in `~/.gemini/config.json` or `~/.gemini/mcp_config.json`.
90
109
 
@@ -92,36 +111,56 @@ Or add the `svcloud` block under `mcpServers` in `~/.gemini/config.json` or `~/.
92
111
  Go to **Settings** > **Features** > **MCP** > **Add New MCP Server**:
93
112
  - **Name:** `svcloud`
94
113
  - **Type:** `command`
95
- - **Command:** `npx -y svcloud@alpha mcp`
114
+ - **Command:** `svcloud mcp`
96
115
 
97
116
  Or add directly to `.cursor/mcp.json`:
98
117
  ```json
99
118
  {
100
119
  "mcpServers": {
101
120
  "svcloud": {
102
- "command": "npx",
103
- "args": ["-y", "svcloud@alpha", "mcp"]
121
+ "command": "svcloud",
122
+ "args": ["mcp"]
104
123
  }
105
124
  }
106
125
  }
107
126
  ```
108
127
 
109
128
  ### OpenCode
110
- In `~/.config/opencode/mcp.json` or your OpenCode configuration:
129
+ OpenCode does **not** read `mcpServers` that's the Claude-style shape every
130
+ other harness here uses, and writing it to OpenCode's config produces a file
131
+ that looks installed but does nothing. OpenCode reads a top-level `mcp` key,
132
+ in `~/.config/opencode/opencode.json` (global) or `opencode.json`/
133
+ `opencode.jsonc` at your project root, and each server needs `"type":
134
+ "local"` with `command` as an array:
135
+
111
136
  ```json
112
137
  {
113
- "mcpServers": {
138
+ "mcp": {
114
139
  "svcloud": {
115
- "command": "npx",
116
- "args": ["-y", "svcloud@alpha", "mcp"]
140
+ "type": "local",
141
+ "command": ["svcloud", "mcp"]
117
142
  }
118
143
  }
119
144
  }
120
145
  ```
121
146
 
147
+ `svcloud mcp setup opencode` writes exactly this, to the right file — the
148
+ safest way to get it right by hand is still to run that.
149
+
122
150
  ### Claude Code
123
151
  ```bash
124
- claude mcp add svcloud -- npx -y svcloud@alpha mcp
152
+ claude mcp add svcloud -- svcloud mcp
153
+ ```
154
+ Or add directly to `.mcp.json` at your project root:
155
+ ```json
156
+ {
157
+ "mcpServers": {
158
+ "svcloud": {
159
+ "command": "svcloud",
160
+ "args": ["mcp"]
161
+ }
162
+ }
163
+ }
125
164
  ```
126
165
 
127
166
  ### Codex
@@ -130,8 +169,8 @@ In `~/.codex/config.json` or your Codex MCP settings:
130
169
  {
131
170
  "mcpServers": {
132
171
  "svcloud": {
133
- "command": "npx",
134
- "args": ["-y", "svcloud@alpha", "mcp"]
172
+ "command": "svcloud",
173
+ "args": ["mcp"]
135
174
  }
136
175
  }
137
176
  }
@@ -141,24 +180,21 @@ In `~/.codex/config.json` or your Codex MCP settings:
141
180
  In any harness or configuration file that accepts an `mcpServers` block:
142
181
 
143
182
  ```jsonc
144
- { "svcloud": { "command": "npx", "args": ["-y", "svcloud@alpha", "mcp"] } }
183
+ { "svcloud": { "command": "svcloud", "args": ["mcp"] } }
145
184
  ```
146
185
 
186
+ Running it through `npx` instead works too (`{ "command": "npx", "args":
187
+ ["-y", "svcloud", "mcp"] }`), but costs a network round-trip on every
188
+ start and depends on the npm registry being up — prefer an installed
189
+ `svcloud` for anything you use daily.
190
+
147
191
  Your agent never sees your token and never has to sign in on its own. Signing
148
192
  out (`svcloud logout`) cuts its access off too.
149
193
 
150
- For an agent you use every day, install it rather than running it through
151
- `npx`: an installed `svcloud` starts instantly, works offline, and only changes
152
- version when you ask it to.
153
-
154
- ```jsonc
155
- { "svcloud": { "command": "svcloud", "args": ["mcp"] } }
156
- ```
157
-
158
194
  ### Upgrading
159
195
 
160
196
  ```bash
161
- npm install -g svcloud@alpha
197
+ npm install -g svcloud
162
198
  ```
163
199
 
164
200
  Your agent picks up the new version the next time it starts — restart the MCP
@@ -174,7 +210,9 @@ at the moment you sign in. If we widen what the CLI can be granted, run
174
210
  list no matter how new your CLI is, since scopes are frozen at sign-in, not
175
211
  re-checked against the current defaults. (**2026-08-25:** we just did this —
176
212
  run `svcloud login` again to see the full tool list, including database and
177
- settings tools that were previously invisible to the bridge.)
213
+ settings tools that were previously invisible to the bridge.) `svcloud mcp
214
+ check` flags this for you — it warns when your session has fewer scopes than
215
+ SV Cloud currently grants by default.
178
216
 
179
217
  A harness that can run its own OAuth flow (a hosted agent, not one running on
180
218
  your machine) can also talk to SV Cloud directly at
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svcloud",
3
- "version": "0.1.0-alpha.3",
3
+ "version": "0.1.0",
4
4
  "description": "The SV Cloud CLI. Alpha: login, logout, status, open, projects list, mcp, init, and runs are built; see PLANNING.md for what's still missing.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -15,17 +15,17 @@
15
15
  "engines": {
16
16
  "node": ">=22.18.0"
17
17
  },
18
+ "scripts": {
19
+ "dev": "tsx src/index.ts",
20
+ "typecheck": "tsc --noEmit",
21
+ "test": "node --import tsx --test test/*.test.ts"
22
+ },
18
23
  "dependencies": {
19
24
  "tsx": "^4.20.0"
20
25
  },
21
26
  "devDependencies": {
27
+ "@sv/cloud-contracts": "workspace:*",
22
28
  "@types/node": "^22.13.0",
23
- "typescript": "^5.9.3",
24
- "@sv/cloud-contracts": "0.0.0"
25
- },
26
- "scripts": {
27
- "dev": "tsx src/index.ts",
28
- "typecheck": "tsc --noEmit",
29
- "test": "node --import tsx --test test/*.test.ts"
29
+ "typescript": "^5.9.3"
30
30
  }
31
- }
31
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `svcloud db` — PLANNING.md §4 Step 5. Mirrors F8's row editor
3
3
  * (`apps/cloud/src/routes/database.ts`) plus the two DDL routes added
4
- * alongside this command (`create_table`/`run_migration`, previously
4
+ * alongside this command (`db_create_table`/`db_run_migration`, previously
5
5
  * MCP-tool-only — see that file's header). Cell/column payloads are taken as
6
6
  * JSON on the command line rather than a flag-per-field grammar: the primary
7
7
  * caller for structured data like this is a coding agent, and it goes
@@ -125,12 +125,18 @@ async function dbRows(argv: string[], json: boolean): Promise<void> {
125
125
  printJson(page);
126
126
  return;
127
127
  }
128
+ const hasIdColumn = page.columns.some((c) => c.name === "id");
129
+ const headers = hasIdColumn
130
+ ? page.columns.map((c) => c.name)
131
+ : ["id", ...page.columns.map((c) => c.name)];
128
132
  printTable(
129
- page.rows.map((r) => ({
130
- id: r.id,
131
- ...Object.fromEntries(page.columns.map((c) => [c.name, stringifyCell(r.cells[c.name])])),
132
- })),
133
- ["id", ...page.columns.map((c) => c.name)],
133
+ page.rows.map((r) => {
134
+ const cells = Object.fromEntries(
135
+ page.columns.map((c) => [c.name, stringifyCell(r.cells[c.name])]),
136
+ );
137
+ return hasIdColumn ? cells : { id: r.id, ...cells };
138
+ }),
139
+ headers,
134
140
  );
135
141
  if (page.truncated) console.log(`(showing the first ${page.rows.length} rows — more exist)`);
136
142
  if (!page.editable) {
@@ -0,0 +1,110 @@
1
+ /**
2
+ * `svcloud mcp check` — one-command diagnosis for "the harness doesn't see
3
+ * my tools." Exists because the actual field failure (2026-08-26 report)
4
+ * was a config that *looked* installed — a file existed at a path that
5
+ * seemed right — but was the wrong shape for the harness reading it, and
6
+ * finding that took digging. This prints sign-in state, a live `tools/list`
7
+ * probe (same request the bridge itself makes — `callRemote` from
8
+ * `mcp.ts`), and every known harness's config file: found or not, and if
9
+ * found, right shape or wrong.
10
+ */
11
+ import { callRemote } from "./mcp";
12
+ import { CLI_VERSION, apiBaseUrl } from "../lib/config";
13
+ import { loadTokens } from "../lib/credentials";
14
+ import { HARNESSES, inspectHarness, type HarnessStatus } from "../lib/harnesses";
15
+ import { printJson } from "../lib/output";
16
+
17
+ // The scope vocabulary svcloud-cli/svcloud-mcp currently grant by default
18
+ // (apps/cloud/src/services/oauth.ts's OAUTH_CLIENTS, widened 2026-08-25 to
19
+ // every OAUTH_SCOPES entry except database:sql). A session with fewer
20
+ // scopes than this predates that widening — scopes are frozen at sign-in
21
+ // and only refreshed by signing in again, never silently upgraded.
22
+ const EXPECTED_SCOPE_COUNT = 6;
23
+
24
+ function describeStatus(status: HarnessStatus): string {
25
+ switch (status.state) {
26
+ case "not_found":
27
+ return "not found";
28
+ case "unreadable":
29
+ return `exists but isn't valid JSON (${status.detail})`;
30
+ case "missing_entry":
31
+ return "found, but has no svcloud entry";
32
+ case "wrong_shape":
33
+ return "found, but the svcloud entry is the wrong shape for this harness";
34
+ case "ok":
35
+ return "configured correctly";
36
+ }
37
+ }
38
+
39
+ export async function mcpCheckCommand(json: boolean): Promise<void> {
40
+ const tokens = await loadTokens();
41
+ const cwd = process.cwd();
42
+
43
+ const harnessResults = await Promise.all(
44
+ HARNESSES.map(async (h) => ({ harness: h, status: await inspectHarness(h, cwd) })),
45
+ );
46
+
47
+ let toolCount: number | undefined;
48
+ let toolsListError: string | undefined;
49
+ if (tokens) {
50
+ const result = await callRemote({
51
+ jsonrpc: "2.0",
52
+ id: "__svcloud_mcp_check__",
53
+ method: "tools/list",
54
+ params: {},
55
+ });
56
+ if (result.ok) {
57
+ const body = result.body as { result?: { tools?: unknown[] } };
58
+ toolCount = body.result?.tools?.length;
59
+ } else {
60
+ toolsListError = result.reason === "auth" ? "not signed in" : result.detail;
61
+ }
62
+ }
63
+
64
+ const scopeStale = tokens !== undefined && tokens.scopes.length < EXPECTED_SCOPE_COUNT;
65
+
66
+ if (json) {
67
+ printJson({
68
+ version: CLI_VERSION,
69
+ api_base_url: apiBaseUrl(),
70
+ signed_in: tokens !== undefined,
71
+ scopes: tokens?.scopes ?? [],
72
+ scopes_possibly_stale: scopeStale,
73
+ tools_visible: toolCount,
74
+ tools_list_error: toolsListError,
75
+ harnesses: harnessResults.map(({ harness, status }) => ({
76
+ id: harness.id,
77
+ path: status.path,
78
+ state: status.state,
79
+ })),
80
+ });
81
+ return;
82
+ }
83
+
84
+ console.log(`svcloud ${CLI_VERSION} (API: ${apiBaseUrl()})`);
85
+ console.log("");
86
+ if (!tokens) {
87
+ console.log("Sign-in: not signed in. Run `svcloud login`.");
88
+ } else {
89
+ console.log(`Sign-in: signed in, ${tokens.scopes.length} scopes (${tokens.scopes.join(", ")}).`);
90
+ if (scopeStale) {
91
+ console.log(
92
+ " Fewer scopes than SV Cloud currently grants by default — if a tool you expect is missing, " +
93
+ "run `svcloud login` again. Scopes are frozen at sign-in and don't update on their own.",
94
+ );
95
+ }
96
+ if (toolCount !== undefined) {
97
+ console.log(`Live tools/list: ${toolCount} tools visible to this token.`);
98
+ } else if (toolsListError) {
99
+ console.log(`Live tools/list: failed (${toolsListError}).`);
100
+ }
101
+ }
102
+ console.log("");
103
+ console.log("Harness configs:");
104
+ for (const { harness, status } of harnessResults) {
105
+ console.log(` ${harness.label.padEnd(14)} ${status.path}`);
106
+ console.log(` ${"".padEnd(14)} ${describeStatus(status)}`);
107
+ }
108
+ console.log("");
109
+ console.log("Fix a missing or wrong-shaped one with: svcloud mcp setup <harness>");
110
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `svcloud mcp setup <harness>` — writes or merges the correct MCP config
3
+ * block for one harness, from the single table in `lib/harnesses.ts`. Exists
4
+ * because README.md's prose config blocks are what actually broke: an
5
+ * OpenCode reader followed a Claude-style `mcpServers` block written to the
6
+ * wrong file, ended up with a config that looked installed but was inert,
7
+ * and only found the mismatch by digging (2026-08-26 field report). Writing
8
+ * the file directly, from data instead of prose, is the fix.
9
+ */
10
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
11
+ import { dirname } from "node:path";
12
+ import { findHarness, HARNESSES, type Harness } from "../lib/harnesses";
13
+ import { die, hasFlag } from "../lib/output";
14
+
15
+ function usage(): string {
16
+ const ids = HARNESSES.map((h) => h.id).join(" | ");
17
+ return `Usage: svcloud mcp setup <harness> [--force]\n\nHarnesses: ${ids}`;
18
+ }
19
+
20
+ async function readExistingConfig(path: string, force: boolean): Promise<Record<string, unknown>> {
21
+ let raw: string;
22
+ try {
23
+ raw = await readFile(path, "utf8");
24
+ } catch {
25
+ return {};
26
+ }
27
+ try {
28
+ const parsed: unknown = JSON.parse(raw);
29
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
30
+ throw new Error("not a JSON object");
31
+ }
32
+ return parsed as Record<string, unknown>;
33
+ } catch (err) {
34
+ if (!force) {
35
+ die(
36
+ `${path} exists but isn't valid JSON (${err instanceof Error ? err.message : String(err)}).\n` +
37
+ `Fix it by hand, or rerun with --force to overwrite it.`,
38
+ );
39
+ }
40
+ return {};
41
+ }
42
+ }
43
+
44
+ export async function mcpSetupCommand(argv: string[], json: boolean): Promise<void> {
45
+ const { present: force, rest } = hasFlag(argv, "force");
46
+ const id = rest[0];
47
+ if (!id) die(usage());
48
+ const harness: Harness | undefined = findHarness(id);
49
+ if (!harness) die(`Unknown harness "${id}".\n\n${usage()}`);
50
+
51
+ const path = harness.configPath(process.cwd());
52
+ const config = await readExistingConfig(path, force);
53
+
54
+ const servers = (config[harness.serversKey] ?? {}) as Record<string, unknown>;
55
+ const existing = servers.svcloud;
56
+ const entry = harness.buildEntry("svcloud", ["mcp"]);
57
+
58
+ if (existing !== undefined && !force) {
59
+ if (harness.isWellFormed(existing)) {
60
+ if (json) {
61
+ console.log(JSON.stringify({ harness: harness.id, path, status: "already_configured" }));
62
+ } else {
63
+ console.log(`${harness.label} is already configured at ${path}.`);
64
+ }
65
+ return;
66
+ }
67
+ die(
68
+ `${path} already has a "svcloud" entry under "${harness.serversKey}", but it doesn't look right for ${harness.label}.\n` +
69
+ `Rerun with --force to overwrite it.`,
70
+ );
71
+ }
72
+
73
+ servers.svcloud = entry;
74
+ config[harness.serversKey] = servers;
75
+
76
+ await mkdir(dirname(path), { recursive: true });
77
+ await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, "utf8");
78
+
79
+ if (json) {
80
+ console.log(JSON.stringify({ harness: harness.id, path, status: "written" }));
81
+ return;
82
+ }
83
+ console.log(`Wrote ${harness.label}'s MCP config to ${path}.`);
84
+ console.log(`Sign in first if you haven't: svcloud login`);
85
+ console.log(`Then restart ${harness.label} (or start a new session) to pick it up.`);
86
+ }
@@ -41,6 +41,9 @@ import { createInterface } from "node:readline";
41
41
  import { AuthRequiredError, currentAccessToken, forceRefreshAccessToken } from "../lib/api";
42
42
  import { apiBaseUrl } from "../lib/config";
43
43
  import { loadTokens } from "../lib/credentials";
44
+ import { die } from "../lib/output";
45
+ import { mcpCheckCommand } from "./mcp-check";
46
+ import { mcpSetupCommand } from "./mcp-setup";
44
47
 
45
48
  type JsonRpcId = string | number | null;
46
49
 
@@ -79,13 +82,18 @@ function warnNotSignedInOnce(): void {
79
82
  stderrLine(NOT_SIGNED_IN_MESSAGE);
80
83
  }
81
84
 
82
- type RemoteResult =
85
+ export type RemoteResult =
83
86
  | { ok: true; body: unknown }
84
87
  | { ok: false; reason: "auth" }
85
88
  | { ok: false; reason: "transport"; detail: string };
86
89
 
87
- /** One request to `POST /mcp`, refreshing once on a 401, whatever the eventual status. Never throws. */
88
- async function callRemote(msg: unknown): Promise<RemoteResult> {
90
+ /**
91
+ * One request to `POST /mcp`, refreshing once on a 401, whatever the
92
+ * eventual status. Never throws. Exported for `mcp-check.ts`'s live
93
+ * `tools/list` probe — same request the bridge itself makes, not a second
94
+ * implementation.
95
+ */
96
+ export async function callRemote(msg: unknown): Promise<RemoteResult> {
89
97
  let accessToken: string;
90
98
  try {
91
99
  accessToken = await currentAccessToken();
@@ -156,16 +164,18 @@ async function logStartupState(): Promise<void> {
156
164
  }
157
165
  }
158
166
 
159
- export async function mcpCommand(): Promise<void> {
167
+ /** The stdio bridge itself — everything rules 1-8 above govern. Only reachable as bare `svcloud mcp`. */
168
+ export async function mcpBridgeCommand(): Promise<void> {
160
169
  // Rule 1: stdout is JSON-RPC only. Nothing in this command's own code
161
170
  // calls console.log, but rebinding here means an imported module doing
162
171
  // so can't corrupt the stream either.
163
172
  console.log = (...args: unknown[]) => stderrLine(args.map(String).join(" "));
164
173
  console.info = console.log;
165
174
 
166
- await logStartupState();
167
-
175
+ // Attach to stdin synchronously before any async work so the harness's
176
+ // initialize handshake is never delayed or dropped on session start.
168
177
  const rl = createInterface({ input: process.stdin, terminal: false });
178
+ void logStartupState();
169
179
  rl.on("line", (line) => {
170
180
  const trimmed = line.trim();
171
181
  if (!trimmed) return;
@@ -183,3 +193,35 @@ export async function mcpCommand(): Promise<void> {
183
193
 
184
194
  await new Promise<void>((resolve) => rl.on("close", resolve));
185
195
  }
196
+
197
+ const USAGE = `Usage:
198
+ svcloud mcp Run the local MCP bridge (for a coding agent's harness config)
199
+ svcloud mcp setup <harness> Write this harness's MCP config for svcloud
200
+ svcloud mcp check Diagnose sign-in state, harness configs, and live tool visibility`;
201
+
202
+ /**
203
+ * `svcloud mcp [setup|check]` — bare `mcp` must stay the stdio bridge since
204
+ * every documented harness config invokes it exactly that way
205
+ * (`claude mcp add svcloud -- svcloud mcp`, etc.), so it's the default when
206
+ * `argv` has no subcommand — not a subcommand of its own.
207
+ */
208
+ export async function mcpCommand(argv: string[], json: boolean): Promise<void> {
209
+ const [subcommand, ...rest] = argv;
210
+ switch (subcommand) {
211
+ case undefined:
212
+ await mcpBridgeCommand();
213
+ return;
214
+ case "setup":
215
+ await mcpSetupCommand(rest, json);
216
+ return;
217
+ case "check":
218
+ await mcpCheckCommand(json);
219
+ return;
220
+ case "-h":
221
+ case "--help":
222
+ console.log(USAGE);
223
+ return;
224
+ default:
225
+ die(`Unknown "svcloud mcp" subcommand: ${subcommand}\n\n${USAGE}`);
226
+ }
227
+ }
package/src/index.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Argument parsing and command dispatch PLANNING.md §4 Step 1:
4
- * `login`, `logout`, `whoami`, `status`, `open`, `projects list`.
5
- * Hand-rolled rather than a dependency: six commands, one global flag, no
6
- * subcommand ambiguity worth a parser library yet.
3
+ * Argument parsing and command dispatch. Hand-rolled rather than a
4
+ * dependency: a handful of top-level commands, one global flag, and the
5
+ * few that need subcommands (`secrets`, `db`, `mcp`) do their own
6
+ * second-level switch — no subcommand ambiguity worth a parser library yet.
7
7
  */
8
8
  import { dbCommand } from "./commands/db";
9
9
  import { deployCommand } from "./commands/deploy";
@@ -35,6 +35,8 @@ Usage:
35
35
  svcloud db <cmd> Browse and edit an app's database (see 'svcloud db' for subcommands)
36
36
  svcloud deploy <app> Push the current branch and watch the build (must be the app's default branch)
37
37
  svcloud mcp Run the local MCP bridge (for a coding agent's harness config)
38
+ svcloud mcp setup <harness> Write a coding agent harness's MCP config for svcloud
39
+ svcloud mcp check Diagnose sign-in state, harness configs, and live tool visibility
38
40
 
39
41
  Flags:
40
42
  --json Machine-readable output, on every read command
@@ -66,7 +68,7 @@ async function main(): Promise<void> {
66
68
  await projectsCommand(rest[0], json);
67
69
  return;
68
70
  case "mcp":
69
- await mcpCommand();
71
+ await mcpCommand(rest, json);
70
72
  return;
71
73
  case "init":
72
74
  await initCommand(rest, json);
package/src/lib/config.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * override, for pointing the CLI at a local `wrangler dev` instance while
4
4
  * building it — production has no reason to ever set it.
5
5
  */
6
+ import { createRequire } from "node:module";
6
7
 
7
8
  export const CLIENT_ID = "svcloud-cli";
8
9
 
@@ -13,3 +14,6 @@ export function apiBaseUrl(): string {
13
14
  /** Service/account names the credential store (lib/credentials.ts) files this CLI's token set under. */
14
15
  export const CREDENTIAL_SERVICE = "svcloud-cli";
15
16
  export const CREDENTIAL_ACCOUNT = "default";
17
+
18
+ /** This installed CLI's own version, from package.json — surfaced by `svcloud mcp check`. */
19
+ export const CLI_VERSION: string = createRequire(import.meta.url)("../../package.json").version;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The harness table `svcloud mcp setup <harness>` and `svcloud mcp check`
3
+ * both read — previously this was six prose blocks in README.md, hand-kept
4
+ * in sync with nothing. That's how the OpenCode block drifted wrong
5
+ * (2026-08-26 field report): it told a reader to write a Claude-style
6
+ * `mcpServers` object to `~/.config/opencode/mcp.json`, which OpenCode
7
+ * never reads — OpenCode's actual config is `~/.config/opencode/opencode.json`
8
+ * with a `mcp` key whose entries are `{ type: "local", command: [...] }`,
9
+ * verified against https://opencode.ai/docs/mcp-servers/ and
10
+ * https://opencode.ai/docs/config/ while fixing this. One table now backs
11
+ * the README, `mcp setup`, and `mcp check`, so they cannot drift apart
12
+ * again the same way.
13
+ *
14
+ * Codex's entry is carried over from the README as it stood before this
15
+ * change (`~/.codex/config.json`, `mcpServers`) — not independently
16
+ * re-verified here; only OpenCode's shape was reported wrong.
17
+ */
18
+ import { readFile } from "node:fs/promises";
19
+ import { homedir } from "node:os";
20
+ import { join } from "node:path";
21
+
22
+ export type HarnessId = "claude-code" | "cursor" | "opencode" | "antigravity" | "gemini-cli" | "codex";
23
+
24
+ export interface HarnessEntry {
25
+ command: string | string[];
26
+ args?: string[];
27
+ type?: string;
28
+ }
29
+
30
+ export interface Harness {
31
+ id: HarnessId;
32
+ label: string;
33
+ /** Where the config file lives. "project" is relative to the current working directory. */
34
+ scope: "project" | "global";
35
+ configPath: (cwd: string) => string;
36
+ /** The top-level object key an entry for svcloud lives under. */
37
+ serversKey: string;
38
+ /** Build this harness's shape of the svcloud server entry. */
39
+ buildEntry: (command: string, args: string[]) => HarnessEntry;
40
+ /** True if an existing entry (already parsed as JSON) has this harness's expected shape. */
41
+ isWellFormed: (entry: unknown) => boolean;
42
+ }
43
+
44
+ function isStringArray(v: unknown): v is string[] {
45
+ return Array.isArray(v) && v.every((x) => typeof x === "string");
46
+ }
47
+
48
+ const claudeStyleEntry = (command: string, args: string[]): HarnessEntry => ({ command, args });
49
+ const claudeStyleWellFormed = (entry: unknown): boolean =>
50
+ typeof entry === "object" &&
51
+ entry !== null &&
52
+ typeof (entry as Record<string, unknown>).command === "string" &&
53
+ isStringArray((entry as Record<string, unknown>).args ?? []);
54
+
55
+ export const HARNESSES: Harness[] = [
56
+ {
57
+ id: "claude-code",
58
+ label: "Claude Code",
59
+ scope: "project",
60
+ configPath: (cwd) => join(cwd, ".mcp.json"),
61
+ serversKey: "mcpServers",
62
+ buildEntry: claudeStyleEntry,
63
+ isWellFormed: claudeStyleWellFormed,
64
+ },
65
+ {
66
+ id: "cursor",
67
+ label: "Cursor",
68
+ scope: "project",
69
+ configPath: (cwd) => join(cwd, ".cursor", "mcp.json"),
70
+ serversKey: "mcpServers",
71
+ buildEntry: claudeStyleEntry,
72
+ isWellFormed: claudeStyleWellFormed,
73
+ },
74
+ {
75
+ id: "antigravity",
76
+ label: "Antigravity",
77
+ scope: "global",
78
+ configPath: () => join(homedir(), ".gemini", "config", "mcp_config.json"),
79
+ serversKey: "mcpServers",
80
+ buildEntry: claudeStyleEntry,
81
+ isWellFormed: claudeStyleWellFormed,
82
+ },
83
+ {
84
+ id: "gemini-cli",
85
+ label: "Gemini CLI",
86
+ scope: "global",
87
+ configPath: () => join(homedir(), ".gemini", "mcp_config.json"),
88
+ serversKey: "mcpServers",
89
+ buildEntry: claudeStyleEntry,
90
+ isWellFormed: claudeStyleWellFormed,
91
+ },
92
+ {
93
+ id: "codex",
94
+ label: "Codex",
95
+ scope: "global",
96
+ configPath: () => join(homedir(), ".codex", "config.json"),
97
+ serversKey: "mcpServers",
98
+ buildEntry: claudeStyleEntry,
99
+ isWellFormed: claudeStyleWellFormed,
100
+ },
101
+ {
102
+ id: "opencode",
103
+ label: "OpenCode",
104
+ scope: "global",
105
+ configPath: () => join(homedir(), ".config", "opencode", "opencode.json"),
106
+ serversKey: "mcp",
107
+ buildEntry: (command, args) => ({ type: "local", command: [command, ...args] }),
108
+ isWellFormed: (entry: unknown): boolean =>
109
+ typeof entry === "object" &&
110
+ entry !== null &&
111
+ (entry as Record<string, unknown>).type === "local" &&
112
+ isStringArray((entry as Record<string, unknown>).command) &&
113
+ ((entry as Record<string, unknown>).command as string[]).length > 0,
114
+ },
115
+ ];
116
+
117
+ export function findHarness(id: string): Harness | undefined {
118
+ return HARNESSES.find((h) => h.id === id);
119
+ }
120
+
121
+ export type HarnessStatus =
122
+ | { state: "not_found"; path: string }
123
+ | { state: "unreadable"; path: string; detail: string }
124
+ | { state: "missing_entry"; path: string }
125
+ | { state: "wrong_shape"; path: string }
126
+ | { state: "ok"; path: string };
127
+
128
+ /** Reads and inspects a harness's config file — never throws. */
129
+ export async function inspectHarness(harness: Harness, cwd: string): Promise<HarnessStatus> {
130
+ const path = harness.configPath(cwd);
131
+ let raw: string;
132
+ try {
133
+ raw = await readFile(path, "utf8");
134
+ } catch {
135
+ return { state: "not_found", path };
136
+ }
137
+ let parsed: unknown;
138
+ try {
139
+ parsed = JSON.parse(raw);
140
+ } catch (err) {
141
+ return { state: "unreadable", path, detail: err instanceof Error ? err.message : String(err) };
142
+ }
143
+ const servers = (parsed as Record<string, unknown> | null)?.[harness.serversKey];
144
+ const entry = (servers as Record<string, unknown> | undefined)?.svcloud;
145
+ if (entry === undefined) return { state: "missing_entry", path };
146
+ if (!harness.isWellFormed(entry)) return { state: "wrong_shape", path };
147
+ return { state: "ok", path };
148
+ }
@@ -21,11 +21,218 @@ export interface LoopbackListener {
21
21
  }
22
22
 
23
23
  const CALLBACK_PAGE = (ok: boolean) => `<!doctype html>
24
- <html><head><title>SV Cloud</title></head>
25
- <body style="font-family:system-ui;padding:2rem;color:#111">
26
- <h1>${ok ? "You're signed in." : "Sign-in didn't finish."}</h1>
27
- <p>You can close this tab and go back to your terminal.</p>
28
- </body></html>`;
24
+ <html lang="en">
25
+ <head>
26
+ <meta charset="utf-8" />
27
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
28
+ <title>SV Cloud</title>
29
+ <style>
30
+ :root {
31
+ --bg-canvas: #0b0c0e;
32
+ --bg-surface: #131519;
33
+ --bg-raised: #1b1e24;
34
+ --border-quiet: #23272e;
35
+ --border-loud: #343a44;
36
+ --text-1: #e9ebef;
37
+ --text-2: #a7aeba;
38
+ --text-3: #6e7684;
39
+ --accent: #8e6bff;
40
+ --state-live: #34c77b;
41
+ --state-failed: #e8a33d;
42
+ --font-sans: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
43
+ --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
44
+ --shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
45
+ }
46
+ @media (prefers-color-scheme: light) {
47
+ :root {
48
+ --bg-canvas: #fafbfc;
49
+ --bg-surface: #ffffff;
50
+ --bg-raised: #f2f4f7;
51
+ --border-quiet: #e1e4ea;
52
+ --border-loud: #cdd2dc;
53
+ --text-1: #111318;
54
+ --text-2: #4a5160;
55
+ --text-3: #7d8594;
56
+ --accent: #7c5cfc;
57
+ --state-live: #1da462;
58
+ --state-failed: #d97706;
59
+ --shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
60
+ }
61
+ }
62
+ * {
63
+ box-sizing: border-box;
64
+ margin: 0;
65
+ padding: 0;
66
+ }
67
+ body {
68
+ min-height: 100vh;
69
+ display: flex;
70
+ flex-direction: column;
71
+ align-items: center;
72
+ justify-content: center;
73
+ background-color: var(--bg-canvas);
74
+ background-image: radial-gradient(circle at 50% 15%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 60%);
75
+ color: var(--text-1);
76
+ font-family: var(--font-sans);
77
+ padding: 24px;
78
+ -webkit-font-smoothing: antialiased;
79
+ }
80
+ .auth-container {
81
+ width: min(420px, 100%);
82
+ display: flex;
83
+ flex-direction: column;
84
+ align-items: center;
85
+ gap: 24px;
86
+ animation: fadeIn 0.3s ease-out;
87
+ }
88
+ @keyframes fadeIn {
89
+ from { opacity: 0; transform: translateY(6px); }
90
+ to { opacity: 1; transform: translateY(0); }
91
+ }
92
+ .lockup {
93
+ display: inline-flex;
94
+ align-items: center;
95
+ gap: 10px;
96
+ font-size: 15px;
97
+ font-weight: 500;
98
+ letter-spacing: -0.01em;
99
+ color: var(--text-1);
100
+ text-decoration: none;
101
+ }
102
+ .mark {
103
+ width: 24px;
104
+ height: 24px;
105
+ display: grid;
106
+ place-items: center;
107
+ border-radius: 5px;
108
+ border: 1px solid var(--border-loud);
109
+ background: var(--bg-raised);
110
+ color: var(--text-2);
111
+ font-family: var(--font-mono);
112
+ font-size: 10px;
113
+ font-weight: 600;
114
+ letter-spacing: 0.05em;
115
+ }
116
+ .card {
117
+ width: 100%;
118
+ background: var(--bg-surface);
119
+ border: 1px solid var(--border-quiet);
120
+ border-radius: 12px;
121
+ padding: 28px 24px;
122
+ display: flex;
123
+ flex-direction: column;
124
+ align-items: center;
125
+ text-align: center;
126
+ gap: 16px;
127
+ box-shadow: var(--shadow);
128
+ }
129
+ .status-icon {
130
+ width: 44px;
131
+ height: 44px;
132
+ border-radius: 50%;
133
+ display: grid;
134
+ place-items: center;
135
+ flex-shrink: 0;
136
+ }
137
+ .status-icon--ok {
138
+ background: color-mix(in srgb, var(--state-live) 15%, transparent);
139
+ color: var(--state-live);
140
+ border: 1px solid color-mix(in srgb, var(--state-live) 30%, transparent);
141
+ }
142
+ .status-icon--error {
143
+ background: color-mix(in srgb, var(--state-failed) 15%, transparent);
144
+ color: var(--state-failed);
145
+ border: 1px solid color-mix(in srgb, var(--state-failed) 30%, transparent);
146
+ }
147
+ h1 {
148
+ font-size: 20px;
149
+ font-weight: 600;
150
+ letter-spacing: -0.02em;
151
+ color: var(--text-1);
152
+ line-height: 1.3;
153
+ }
154
+ p.desc {
155
+ font-size: 14px;
156
+ color: var(--text-2);
157
+ line-height: 1.5;
158
+ }
159
+ .terminal-box {
160
+ width: 100%;
161
+ margin-top: 6px;
162
+ background: var(--bg-raised);
163
+ border: 1px solid var(--border-quiet);
164
+ border-radius: 8px;
165
+ padding: 12px 14px;
166
+ font-family: var(--font-mono);
167
+ font-size: 12px;
168
+ color: var(--text-2);
169
+ text-align: left;
170
+ display: flex;
171
+ align-items: center;
172
+ justify-content: space-between;
173
+ gap: 8px;
174
+ }
175
+ .terminal-box .cmd {
176
+ color: var(--text-1);
177
+ display: flex;
178
+ align-items: center;
179
+ gap: 6px;
180
+ }
181
+ .terminal-box .prompt {
182
+ color: var(--accent);
183
+ user-select: none;
184
+ }
185
+ .terminal-box .status-pill {
186
+ font-size: 11px;
187
+ padding: 2px 8px;
188
+ border-radius: 9999px;
189
+ font-family: var(--font-sans);
190
+ font-weight: 500;
191
+ }
192
+ .status-pill--ok {
193
+ background: color-mix(in srgb, var(--state-live) 15%, transparent);
194
+ color: var(--state-live);
195
+ }
196
+ .status-pill--error {
197
+ background: color-mix(in srgb, var(--state-failed) 15%, transparent);
198
+ color: var(--state-failed);
199
+ }
200
+ .foot {
201
+ font-size: 12px;
202
+ color: var(--text-3);
203
+ }
204
+ </style>
205
+ </head>
206
+ <body>
207
+ <div class="auth-container">
208
+ <div class="lockup">
209
+ <span class="mark">SV</span>
210
+ <span>SV Cloud</span>
211
+ </div>
212
+ <div class="card">
213
+ <div class="status-icon \${ok ? 'status-icon--ok' : 'status-icon--error'}">
214
+ \${ok
215
+ ? '<svg width="20" height="20" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8.5 6.5 12 13 4.5"/></svg>'
216
+ : '<svg width="20" height="20" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2.2 15 14H1Z"/><path d="M8 6.6v3.2"/><circle cx="8" cy="11.9" r="0.9" fill="currentColor"/></svg>'}
217
+ </div>
218
+ <div>
219
+ <h1>\${ok ? "You're signed in" : "Sign-in didn't finish"}</h1>
220
+ <p class="desc">\${ok ? "You can close this tab and return to your terminal." : "The sign-in request was not completed. Nothing changed on your account."}</p>
221
+ </div>
222
+ <div class="terminal-box">
223
+ <div class="cmd">
224
+ <span class="prompt">$</span>
225
+ <span>\${ok ? "svcloud" : "svcloud login"}</span>
226
+ </div>
227
+ <span class="status-pill \${ok ? 'status-pill--ok' : 'status-pill--error'}">
228
+ \${ok ? "Ready" : "Retry"}
229
+ </span>
230
+ </div>
231
+ </div>
232
+ <p class="foot">SV Cloud CLI</p>
233
+ </div>
234
+ </body>
235
+ </html>`;
29
236
 
30
237
  export function startLoopbackListener(): Promise<LoopbackListener> {
31
238
  return new Promise((resolve, reject) => {