svcloud 0.1.0-alpha.4 → 0.1.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 +72 -34
- package/package.json +9 -9
- package/src/commands/db.ts +66 -13
- package/src/commands/mcp-check.ts +110 -0
- package/src/commands/mcp-setup.ts +86 -0
- package/src/commands/mcp.ts +48 -6
- package/src/index.ts +7 -5
- package/src/lib/config.ts +4 -0
- package/src/lib/harnesses.ts +148 -0
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.**
|
|
7
|
-
>
|
|
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
|
-
|
|
12
|
+
npm install -g svcloud
|
|
13
|
+
svcloud login
|
|
15
14
|
```
|
|
16
15
|
|
|
17
|
-
Or
|
|
16
|
+
Or try it once without installing:
|
|
18
17
|
|
|
19
18
|
```bash
|
|
20
|
-
|
|
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
|
-
|
|
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
|
|
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": "
|
|
79
|
-
"args": ["
|
|
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 --
|
|
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:** `
|
|
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": "
|
|
103
|
-
"args": ["
|
|
121
|
+
"command": "svcloud",
|
|
122
|
+
"args": ["mcp"]
|
|
104
123
|
}
|
|
105
124
|
}
|
|
106
125
|
}
|
|
107
126
|
```
|
|
108
127
|
|
|
109
128
|
### OpenCode
|
|
110
|
-
|
|
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
|
-
"
|
|
138
|
+
"mcp": {
|
|
114
139
|
"svcloud": {
|
|
115
|
-
"
|
|
116
|
-
"
|
|
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 --
|
|
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": "
|
|
134
|
-
"args": ["
|
|
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": "
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
}
|
package/src/commands/db.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `svcloud db` — PLANNING.md §4 Step 5. Mirrors F8's row editor
|
|
3
|
-
* (`apps/cloud/src/routes/database.ts`) plus the
|
|
4
|
-
* alongside this command (`
|
|
5
|
-
* MCP-tool-only — see that file's header). Cell/column payloads are taken
|
|
6
|
-
* JSON on the command line rather than a flag-per-field grammar: the primary
|
|
3
|
+
* (`apps/cloud/src/routes/database.ts`) plus the DDL routes added
|
|
4
|
+
* alongside this command (`db_create_table`/`db_run_migration`/`db_create_index`,
|
|
5
|
+
* previously MCP-tool-only — see that file's header). Cell/column payloads are taken
|
|
6
|
+
* as 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
|
|
8
8
|
* through `svcloud mcp`'s tools directly (`lib/prompt.ts`'s header), not this
|
|
9
9
|
* command — this path exists for a human or a script at a real terminal.
|
|
10
10
|
*/
|
|
11
11
|
import { apiFetch } from "../lib/api";
|
|
12
12
|
import { findProjectBySlug } from "../lib/find-project";
|
|
13
|
-
import { die, printJson, printTable, takeOption } from "../lib/output";
|
|
13
|
+
import { die, hasFlag, printJson, printTable, takeOption } from "../lib/output";
|
|
14
14
|
|
|
15
15
|
const USAGE = `Usage:
|
|
16
16
|
svcloud db tables <app>
|
|
@@ -18,12 +18,14 @@ const USAGE = `Usage:
|
|
|
18
18
|
svcloud db insert <app> <table> <cells-json>
|
|
19
19
|
svcloud db update <app> <table> <row-id> <cells-json>
|
|
20
20
|
svcloud db delete <app> <table> <row-id>
|
|
21
|
-
svcloud db create-table <app> <table> <columns-json> --primary-key <name>
|
|
21
|
+
svcloud db create-table <app> <table> <columns-json> --primary-key <name> [--indexes <indexes-json>]
|
|
22
|
+
svcloud db create-index <app> <table> <columns-json> [--name <name>] [--unique]
|
|
22
23
|
svcloud db migrate <app> <table> <column-json> [--default <value>]
|
|
23
24
|
|
|
24
25
|
<cells-json> is a JSON object, e.g. '{"title":"Hello"}'
|
|
25
26
|
<columns-json> is a JSON array, e.g. '[{"name":"id","type":"uuid","nullable":false},{"name":"title","type":"text"}]'
|
|
26
27
|
<column-json> is a single column, e.g. '{"name":"count","type":"number","nullable":false}'
|
|
28
|
+
<indexes-json> is a JSON array of indexes, e.g. '[{"columns":["email"],"unique":true}]'
|
|
27
29
|
Column types: text, number, boolean, timestamp, json, uuid`;
|
|
28
30
|
|
|
29
31
|
interface DbColumn {
|
|
@@ -125,12 +127,18 @@ async function dbRows(argv: string[], json: boolean): Promise<void> {
|
|
|
125
127
|
printJson(page);
|
|
126
128
|
return;
|
|
127
129
|
}
|
|
130
|
+
const hasIdColumn = page.columns.some((c) => c.name === "id");
|
|
131
|
+
const headers = hasIdColumn
|
|
132
|
+
? page.columns.map((c) => c.name)
|
|
133
|
+
: ["id", ...page.columns.map((c) => c.name)];
|
|
128
134
|
printTable(
|
|
129
|
-
page.rows.map((r) =>
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
135
|
+
page.rows.map((r) => {
|
|
136
|
+
const cells = Object.fromEntries(
|
|
137
|
+
page.columns.map((c) => [c.name, stringifyCell(r.cells[c.name])]),
|
|
138
|
+
);
|
|
139
|
+
return hasIdColumn ? cells : { id: r.id, ...cells };
|
|
140
|
+
}),
|
|
141
|
+
headers,
|
|
134
142
|
);
|
|
135
143
|
if (page.truncated) console.log(`(showing the first ${page.rows.length} rows — more exist)`);
|
|
136
144
|
if (!page.editable) {
|
|
@@ -187,13 +195,25 @@ async function dbDelete(argv: string[], json: boolean): Promise<void> {
|
|
|
187
195
|
|
|
188
196
|
async function dbCreateTable(argv: string[], json: boolean): Promise<void> {
|
|
189
197
|
const pkOpt = takeOption(argv, "primary-key");
|
|
190
|
-
const
|
|
198
|
+
const indexesOpt = takeOption(pkOpt.rest, "indexes");
|
|
199
|
+
const [slug, table, columnsJson] = indexesOpt.rest;
|
|
191
200
|
if (!table || !pkOpt.value) die(USAGE);
|
|
192
201
|
const projectId = await resolveProjectId(slug);
|
|
193
202
|
const columns = parseJsonArg<unknown[]>(columnsJson, "<columns-json>");
|
|
203
|
+
const indexes = indexesOpt.value
|
|
204
|
+
? parseJsonArg<unknown[]>(indexesOpt.value, "<indexes-json>")
|
|
205
|
+
: undefined;
|
|
194
206
|
const result = await apiFetch<{ table: string; created: boolean }>(
|
|
195
207
|
`/api/v1/projects/${projectId}/database/tables`,
|
|
196
|
-
{
|
|
208
|
+
{
|
|
209
|
+
method: "POST",
|
|
210
|
+
body: {
|
|
211
|
+
name: table,
|
|
212
|
+
columns,
|
|
213
|
+
primary_key: pkOpt.value,
|
|
214
|
+
...(indexes ? { indexes } : {}),
|
|
215
|
+
},
|
|
216
|
+
},
|
|
197
217
|
);
|
|
198
218
|
if (json) {
|
|
199
219
|
printJson(result);
|
|
@@ -202,6 +222,36 @@ async function dbCreateTable(argv: string[], json: boolean): Promise<void> {
|
|
|
202
222
|
console.log(`Created table "${result.table}".`);
|
|
203
223
|
}
|
|
204
224
|
|
|
225
|
+
async function dbCreateIndex(argv: string[], json: boolean): Promise<void> {
|
|
226
|
+
const nameOpt = takeOption(argv, "name");
|
|
227
|
+
const uniqueFlag = hasFlag(nameOpt.rest, "unique");
|
|
228
|
+
const [slug, table, columnsJson] = uniqueFlag.rest;
|
|
229
|
+
if (!table || !columnsJson) die(USAGE);
|
|
230
|
+
const projectId = await resolveProjectId(slug);
|
|
231
|
+
const parsedColumns = parseJsonArg<unknown>(columnsJson, "<columns-json>");
|
|
232
|
+
const columns = Array.isArray(parsedColumns)
|
|
233
|
+
? parsedColumns
|
|
234
|
+
: typeof parsedColumns === "string"
|
|
235
|
+
? [parsedColumns]
|
|
236
|
+
: die("<columns-json> must be a JSON array of column names or a string.");
|
|
237
|
+
const result = await apiFetch<{ table: string; index: string; created: boolean }>(
|
|
238
|
+
`/api/v1/projects/${projectId}/database/tables/${encodeURIComponent(table)}/indexes`,
|
|
239
|
+
{
|
|
240
|
+
method: "POST",
|
|
241
|
+
body: {
|
|
242
|
+
columns,
|
|
243
|
+
name: nameOpt.value,
|
|
244
|
+
unique: uniqueFlag.present ? true : undefined,
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
);
|
|
248
|
+
if (json) {
|
|
249
|
+
printJson(result);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
console.log(`Created index "${result.index}" on "${result.table}".`);
|
|
253
|
+
}
|
|
254
|
+
|
|
205
255
|
async function dbMigrate(argv: string[], json: boolean): Promise<void> {
|
|
206
256
|
const defaultOpt = takeOption(argv, "default");
|
|
207
257
|
const [slug, table, columnJson] = defaultOpt.rest;
|
|
@@ -235,9 +285,12 @@ export async function dbCommand(argv: string[], json: boolean): Promise<void> {
|
|
|
235
285
|
return dbDelete(rest, json);
|
|
236
286
|
case "create-table":
|
|
237
287
|
return dbCreateTable(rest, json);
|
|
288
|
+
case "create-index":
|
|
289
|
+
return dbCreateIndex(rest, json);
|
|
238
290
|
case "migrate":
|
|
239
291
|
return dbMigrate(rest, json);
|
|
240
292
|
default:
|
|
241
293
|
die(USAGE);
|
|
242
294
|
}
|
|
243
295
|
}
|
|
296
|
+
|
|
@@ -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
|
+
}
|
package/src/commands/mcp.ts
CHANGED
|
@@ -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
|
-
/**
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
+
}
|