@retasc/cli 1.38.1 → 1.39.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/CHANGELOG.md +40 -0
- package/dist/commands/bind.js +92 -17
- package/dist/commands/setup.js +6 -2
- package/dist/lib/card.js +7 -1
- package/dist/lib/harness.js +315 -17
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,46 @@ release commits and the issues they reference.
|
|
|
6
6
|
|
|
7
7
|
Dates are the npm publish date. Each entry names the RTSC issue behind it.
|
|
8
8
|
|
|
9
|
+
## 1.39.0 (2026-08-30)
|
|
10
|
+
|
|
11
|
+
- **RTSC-793** — `retasc setup` now wires Cursor, OpenCode and Gemini CLI too, bringing it
|
|
12
|
+
to six harnesses. Until this release those three could be installed and running on your
|
|
13
|
+
machine and `setup` would say nothing about them: it only ever named harnesses it already
|
|
14
|
+
knew, so an unsupported one was not reported as skipped, it was invisible, and the receipt
|
|
15
|
+
read as complete. That is the same silent half-install the previous release existed to end,
|
|
16
|
+
one harness over.
|
|
17
|
+
Each was admitted the same way the first three were, by checking a real installation rather
|
|
18
|
+
than a config format from memory. The check that decides it is whether the harness starts
|
|
19
|
+
its MCP server in the project directory, because the entry we write names no project and
|
|
20
|
+
works out the folder for itself; one that started somewhere else would resolve every folder
|
|
21
|
+
to the same wrong place with no symptom. All three passed.
|
|
22
|
+
Cursor is wired by editing `~/.cursor/mcp.json` directly, and carefully: that file is named
|
|
23
|
+
.json but Cursor accepts comments in it, so we splice our entry in textually and leave every
|
|
24
|
+
comment, every other server and everything else exactly where it was. OpenCode and Gemini
|
|
25
|
+
are wired through their own `opencode mcp add` and `gemini mcp add`, which is better than a
|
|
26
|
+
writer of ours when a harness ships one that works: their file format stays their business.
|
|
27
|
+
Note for Cursor users: it is `cursor-agent`, the agent, that gets the tools. The `cursor`
|
|
28
|
+
command is the editor launcher and is a different program.
|
|
29
|
+
Also fixed: the setup receipt could run a path into the word beside it, printing
|
|
30
|
+
`~/.codex/config.tomlupdated`, which read like a corrupted path in the one place whose job
|
|
31
|
+
is to say plainly that everything worked.
|
|
32
|
+
|
|
33
|
+
## 1.38.2 (2026-08-30)
|
|
34
|
+
|
|
35
|
+
- **RTSC-789** — `bind` keeps this folder's key instead of minting a new one on every run.
|
|
36
|
+
The guard meant to prevent that (RTSC-262) read the `--org-id`/`--project-id` flags, so it
|
|
37
|
+
only ever fired for a provisioning script. The canonical `npx @retasc/cli@latest bind`
|
|
38
|
+
leaves both undefined and could never reach it: pick the same org and the same project
|
|
39
|
+
from the menu and it minted anyway, leaving the old key live. The check now runs where
|
|
40
|
+
the target is known, after the pick, so how you got there stops mattering. A different
|
|
41
|
+
org or project still mints, as it must.
|
|
42
|
+
A kept key is resolved against the server before it is kept. `bind` clears a binding only
|
|
43
|
+
when the server answers `UNAUTHORIZED`, so a folder can reach the reuse branch holding a
|
|
44
|
+
key whose health is unknown: a 5xx, a proxy sign-in page, a refusal whose wording drifts.
|
|
45
|
+
Keeping one of those would have stranded the folder for good, since `doctor` sends that
|
|
46
|
+
exact state back to `bind`. It falls back to minting instead, which is the self-healing
|
|
47
|
+
the old mint-every-run was providing by accident.
|
|
48
|
+
|
|
9
49
|
## 1.38.1 (2026-08-28)
|
|
10
50
|
|
|
11
51
|
- **RTSC-780 follow-up** — `retasc init` now sets a folder up the same way every other
|
package/dist/commands/bind.js
CHANGED
|
@@ -99,6 +99,55 @@ export function strandRecoveryHint(args) {
|
|
|
99
99
|
`but has no project or key yet — re-run to finish:\n` +
|
|
100
100
|
` retasc bind --org-id ${orgId}`);
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* The key this folder should KEEP rather than replace (RTSC-789).
|
|
104
|
+
*
|
|
105
|
+
* `bind` used to mint on every run. The guard that was supposed to stop that
|
|
106
|
+
* (RTSC-262) sat in `preflight` and read `args.orgId && args.projectId`, so it
|
|
107
|
+
* only fired for `retasc bind --org-id X --project-id Y` — the provisioning-script
|
|
108
|
+
* path it was written for. The canonical command is a bare `bind`, which leaves
|
|
109
|
+
* both undefined, so the branch was unreachable for almost every real run: pick
|
|
110
|
+
* the same org and the same project from the menu and it minted anyway. Fourteen
|
|
111
|
+
* keys in one org, three of them the same agent on the same project, and the old
|
|
112
|
+
* ones stay live — two were carrying 89 and 283 sessions.
|
|
113
|
+
*
|
|
114
|
+
* So the check moves to where the target is actually KNOWN, after the interactive
|
|
115
|
+
* pick, and asks the only question that matters: does this folder already hold a
|
|
116
|
+
* key for this exact (org, project)? Flags are irrelevant to that.
|
|
117
|
+
*
|
|
118
|
+
* A DIFFERENT org or project still mints.
|
|
119
|
+
*
|
|
120
|
+
* This names a CANDIDATE, not a decision. It reads the keystore and nothing else,
|
|
121
|
+
* so it cannot know whether the key still works — and "`preflight` already cleared
|
|
122
|
+
* anything the server refused" is not true enough to lean on: that clearing fires
|
|
123
|
+
* only on a literal UNAUTHORIZED, and every other failure walks straight past it.
|
|
124
|
+
* The caller resolves the candidate before keeping it.
|
|
125
|
+
*/
|
|
126
|
+
export function reusableKey(existing, target, read = getBinding) {
|
|
127
|
+
if (!existing?.workspaceId)
|
|
128
|
+
return null;
|
|
129
|
+
const cur = read(existing.workspaceId);
|
|
130
|
+
if (!cur?.key)
|
|
131
|
+
return null;
|
|
132
|
+
return cur.orgId === target.orgId && cur.projectId === target.projectId ? cur.key : null;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The binding as the AGENT will see it, or null when the server won't confirm it.
|
|
136
|
+
*
|
|
137
|
+
* One place, because two callers need the same answer for different reasons: the
|
|
138
|
+
* receipt prints it, and the reuse decision above depends on it. Every failure is
|
|
139
|
+
* the same answer here — "the server did not confirm this key" — so the caller
|
|
140
|
+
* decides what that means rather than parsing an error a second time.
|
|
141
|
+
*/
|
|
142
|
+
async function describeBinding(url, key) {
|
|
143
|
+
try {
|
|
144
|
+
const b = await resolveBinding(url, key);
|
|
145
|
+
return { org: clean(b.org.name), prefix: clean(b.project.prefix), name: b.project.name };
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
102
151
|
/**
|
|
103
152
|
* "This folder is already bound — replace it?" (RTSC-262/RTSC-91)
|
|
104
153
|
*
|
|
@@ -423,14 +472,37 @@ export async function completeWorkspaceSetup(args) {
|
|
|
423
472
|
const note = launcherNote(launcher);
|
|
424
473
|
if (note)
|
|
425
474
|
console.log(note);
|
|
426
|
-
// ---
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
475
|
+
// --- keep this folder's key if it already names this (org, project), else mint
|
|
476
|
+
//
|
|
477
|
+
// A key is only kept once the SERVER agrees it works. `preflight` clears a binding
|
|
478
|
+
// only on a literal UNAUTHORIZED, so a folder reaches here holding a key of unknown
|
|
479
|
+
// health whenever that check failed some other way — a 5xx, a proxy page, a refusal
|
|
480
|
+
// whose wording drifts off the regex. Keeping such a key silently would strand the
|
|
481
|
+
// folder permanently: `doctor` tells people in exactly that state to re-run `bind`,
|
|
482
|
+
// and `bind` is this path, so it would answer "✓ bound" forever and never heal. The
|
|
483
|
+
// mint-on-every-run this issue removes was doing that healing by accident; it stays,
|
|
484
|
+
// on purpose, as the fallback.
|
|
485
|
+
//
|
|
486
|
+
// The check is not extra work — it IS the receipt's confirmation call, hoisted, and
|
|
487
|
+
// `bound` carries the answer down to the card. The mint path resolves once below,
|
|
488
|
+
// exactly as before.
|
|
489
|
+
const candidate = reusableKey(existing, { orgId, projectId: projectId });
|
|
490
|
+
let bound = candidate ? await describeBinding(cfg.mcpUrl, candidate) : null;
|
|
491
|
+
// A key that answers for a DIFFERENT project is not this folder's key, whatever the
|
|
492
|
+
// keystore says its ids were. Compared only when the target's prefix is known (a
|
|
493
|
+
// bare --project-id never listed one), because a guess would reject good keys.
|
|
494
|
+
if (bound && prefix && bound.prefix !== clean(prefix))
|
|
495
|
+
bound = null;
|
|
496
|
+
const reused = bound !== null;
|
|
497
|
+
const minted = reused
|
|
498
|
+
? { key: candidate }
|
|
499
|
+
: (await api.mintKey({
|
|
500
|
+
orgId,
|
|
501
|
+
projectId: projectId,
|
|
502
|
+
agentName: opts.agent,
|
|
503
|
+
runtime: opts.runtime ?? "claude-code",
|
|
504
|
+
keyName: prefix ? `${prefix} key` : undefined,
|
|
505
|
+
}));
|
|
434
506
|
// The key is named in the receipt card below, not here — one mention, in the place
|
|
435
507
|
// that says where it went (RTSC-673).
|
|
436
508
|
// RTSC-92: the secret stays OUT of the repo. Store it in the home keystore
|
|
@@ -460,21 +532,24 @@ export async function completeWorkspaceSetup(args) {
|
|
|
460
532
|
// (RTSC-673). Resolved rather than echoed from what we just sent: this is the last
|
|
461
533
|
// chance to notice a folder bound to something other than what was asked for, and a
|
|
462
534
|
// card built from our own inputs could never show that.
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
}
|
|
535
|
+
//
|
|
536
|
+
// A reused key was already resolved above, and that answer is the one that decided to
|
|
537
|
+
// keep it — so only a freshly minted key is left to confirm. Still best-effort here:
|
|
538
|
+
// the binding is written and a key we just minted is live by construction, so a
|
|
539
|
+
// network fault at this point is about the confirmation, not about the key.
|
|
540
|
+
if (!bound)
|
|
541
|
+
bound = await describeBinding(cfg.mcpUrl, minted.key);
|
|
471
542
|
const orgLabel = bound?.org ?? clean(args.orgLabel ?? "");
|
|
472
543
|
const pfx = bound?.prefix ?? clean(prefix ?? "");
|
|
473
544
|
console.log("\n" +
|
|
474
545
|
card(`✓ This folder is bound to ${orgLabel} / ${pfx}`, [
|
|
475
546
|
{ label: "Org", value: orgLabel },
|
|
476
547
|
{ label: "Project", value: bound?.name ? `${pfx} ${DOT} ${bound.name}` : pfx },
|
|
477
|
-
{
|
|
548
|
+
{
|
|
549
|
+
label: "Agent key",
|
|
550
|
+
value: `${minted.key.slice(0, 14)}…`,
|
|
551
|
+
note: reused ? "kept, not replaced" : "never leaves ~/.retasc",
|
|
552
|
+
},
|
|
478
553
|
{ label: "MCP wired", value: marker.where, note: "secret-free, safe to commit" },
|
|
479
554
|
...(wired.wired.length
|
|
480
555
|
? [
|
package/dist/commands/setup.js
CHANGED
|
@@ -66,8 +66,12 @@ export function runSetup(opts) {
|
|
|
66
66
|
export function printSetup(r) {
|
|
67
67
|
if (!r.wired.length && !r.failed.length) {
|
|
68
68
|
console.log("No MCP harness found on this machine.");
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
// Named from the registry rather than a hand-kept list: RTSC-780 shipped three and
|
|
70
|
+
// RTSC-793 added three more, and a sentence naming only the original three would have
|
|
71
|
+
// told a Cursor user to install Codex.
|
|
72
|
+
console.log(`Install one of ${HARNESSES.map((h) => h.label).join(", ")} and run \`retasc setup\` again,\n` +
|
|
73
|
+
"or bind a folder with `retasc bind` and paste the config block it prints into your\n" +
|
|
74
|
+
"own client.");
|
|
71
75
|
return;
|
|
72
76
|
}
|
|
73
77
|
console.log("\n" +
|
package/dist/lib/card.js
CHANGED
|
@@ -50,8 +50,14 @@ export function card(title, rows, footer) {
|
|
|
50
50
|
// A note sits in a third column, so the values stay in one scannable rail. Without a
|
|
51
51
|
// note the value simply runs on — padding it anyway would leave a trench of spaces
|
|
52
52
|
// in the middle of the card.
|
|
53
|
+
//
|
|
54
|
+
// The value is fitted one column NARROWER than its rail so a gutter always survives.
|
|
55
|
+
// `fit` pads to exactly VALUE, so a value of exactly that width, or one ellipsed to
|
|
56
|
+
// it, butted straight against the note: `~/.codex/config.tomlupdated`. It read as a
|
|
57
|
+
// corrupted path in the one surface whose job is to say calmly that this worked
|
|
58
|
+
// (RTSC-793 — RTSC-780's three rows all happened to be shorter).
|
|
53
59
|
const body = r.note
|
|
54
|
-
? `${fit(r.label, LABEL)}${fit(r.value, VALUE)}${r.note}`
|
|
60
|
+
? `${fit(r.label, LABEL)}${fit(r.value, VALUE - 1)} ${r.note}`
|
|
55
61
|
: `${fit(r.label, LABEL)}${r.value}`;
|
|
56
62
|
out.push(line(body));
|
|
57
63
|
}
|
package/dist/lib/harness.js
CHANGED
|
@@ -13,10 +13,16 @@
|
|
|
13
13
|
// with cwd set to the project directory, so the proxy can resolve the folder itself.
|
|
14
14
|
//
|
|
15
15
|
// Adding a harness is one entry in HARNESSES. Deliberately NOT populated from theory:
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
16
|
+
// every entry here was checked against a real installation, and the check that gates
|
|
17
|
+
// admission is the cwd one — a harness whose stdio server does not start in the project
|
|
18
|
+
// directory cannot use the `auto` marker at all, because one global entry would then
|
|
19
|
+
// resolve every folder to the same wrong place, silently.
|
|
20
|
+
//
|
|
21
|
+
// Verified so far: Claude Code, Codex, Grok (RTSC-780), then Cursor, OpenCode and Gemini
|
|
22
|
+
// CLI (RTSC-793), each confirmed by wiring a probe server that logs process.cwd() and
|
|
23
|
+
// running the harness from two different directories. Windsurf and VS Code are NOT
|
|
24
|
+
// candidates: they are editors, not agent harnesses, and were dropped from RTSC-780's
|
|
25
|
+
// original map rather than left sitting in it forever.
|
|
20
26
|
import { spawnSync } from "node:child_process";
|
|
21
27
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
28
|
import { homedir } from "node:os";
|
|
@@ -147,22 +153,186 @@ function tomlHarness(id, label, path, bin) {
|
|
|
147
153
|
},
|
|
148
154
|
};
|
|
149
155
|
}
|
|
150
|
-
// ---
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
156
|
+
// --- JSONC (Cursor) ---------------------------------------------------------
|
|
157
|
+
// `~/.cursor/mcp.json` is named .json and is NOT parsed as JSON: cursor-agent reads a
|
|
158
|
+
// file containing `// comments` without complaint (verified against a real install,
|
|
159
|
+
// RTSC-793). So the same rule the TOML splice follows applies here — this is the user's
|
|
160
|
+
// own config, and a JSON.parse/JSON.stringify round-trip would silently delete every
|
|
161
|
+
// comment in it and reflow the rest.
|
|
162
|
+
//
|
|
163
|
+
// Hence a textual splice, and hence the small JSONC scanner below rather than a parser
|
|
164
|
+
// dependency. It only ever has to find one member and replace its value; everything it
|
|
165
|
+
// does not understand it steps over untouched, which is exactly the property we want
|
|
166
|
+
// from something editing a file we did not write.
|
|
167
|
+
/** Step over whitespace and JSONC comments, returning the next index that is neither. */
|
|
168
|
+
function skipTrivia(text, i) {
|
|
169
|
+
for (;;) {
|
|
170
|
+
while (i < text.length && /\s/.test(text[i]))
|
|
171
|
+
i++;
|
|
172
|
+
if (text.startsWith("//", i)) {
|
|
173
|
+
const nl = text.indexOf("\n", i);
|
|
174
|
+
i = nl === -1 ? text.length : nl + 1;
|
|
175
|
+
continue;
|
|
158
176
|
}
|
|
159
|
-
|
|
160
|
-
|
|
177
|
+
if (text.startsWith("/*", i)) {
|
|
178
|
+
const end = text.indexOf("*/", i + 2);
|
|
179
|
+
i = end === -1 ? text.length : end + 2;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
return i;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** `i` points at the opening quote. Returns the index just past the closing quote. */
|
|
186
|
+
function endOfString(text, i) {
|
|
187
|
+
i++;
|
|
188
|
+
while (i < text.length) {
|
|
189
|
+
if (text[i] === "\\")
|
|
190
|
+
i += 2;
|
|
191
|
+
else if (text[i] === '"')
|
|
192
|
+
return i + 1;
|
|
193
|
+
else
|
|
194
|
+
i++;
|
|
195
|
+
}
|
|
196
|
+
return i;
|
|
197
|
+
}
|
|
198
|
+
/** `i` points at the first character of a value. Returns the index just past it.
|
|
199
|
+
* Nesting is tracked so an object value containing commas ends in the right place. */
|
|
200
|
+
function endOfValue(text, i) {
|
|
201
|
+
i = skipTrivia(text, i);
|
|
202
|
+
if (text[i] === '"')
|
|
203
|
+
return endOfString(text, i);
|
|
204
|
+
if (text[i] === "{" || text[i] === "[") {
|
|
205
|
+
let depth = 0;
|
|
206
|
+
while (i < text.length) {
|
|
207
|
+
const c = text[i];
|
|
208
|
+
if (c === '"') {
|
|
209
|
+
i = endOfString(text, i);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (c === "/" && (text[i + 1] === "/" || text[i + 1] === "*")) {
|
|
213
|
+
i = skipTrivia(text, i);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (c === "{" || c === "[")
|
|
217
|
+
depth++;
|
|
218
|
+
else if (c === "}" || c === "]") {
|
|
219
|
+
depth--;
|
|
220
|
+
if (depth === 0)
|
|
221
|
+
return i + 1;
|
|
222
|
+
}
|
|
223
|
+
i++;
|
|
161
224
|
}
|
|
225
|
+
return i;
|
|
162
226
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
return
|
|
227
|
+
while (i < text.length && !/[,}\]\s]/.test(text[i]))
|
|
228
|
+
i++;
|
|
229
|
+
return i;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* The members of the object whose `{` is at `open`, with the span of each value.
|
|
233
|
+
*
|
|
234
|
+
* `advance` is load-bearing, not defensive tidiness. `endOfValue` stops AT its
|
|
235
|
+
* terminator set, so on a character already in that set (a stray `]`) it returns the
|
|
236
|
+
* index it was handed, and a loop that trusted it would spin forever. That input is not
|
|
237
|
+
* exotic: this parses a file a human hand-edits, and a dropped bracket is the ordinary
|
|
238
|
+
* way one gets malformed. `retasc setup` hanging with no output, taking `bind` and the
|
|
239
|
+
* whole of onboarding with it, is a far worse failure than any misparse — so every path
|
|
240
|
+
* through this loop moves at least one character, and a document we cannot understand
|
|
241
|
+
* ends as a document we scanned to the end of.
|
|
242
|
+
*/
|
|
243
|
+
function membersOf(text, open) {
|
|
244
|
+
const members = [];
|
|
245
|
+
let i = open + 1;
|
|
246
|
+
const advance = (next) => (next > i ? next : i + 1);
|
|
247
|
+
for (;;) {
|
|
248
|
+
i = skipTrivia(text, i);
|
|
249
|
+
if (i >= text.length || text[i] === "}")
|
|
250
|
+
return { members };
|
|
251
|
+
if (text[i] === ",") {
|
|
252
|
+
i++;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (text[i] !== '"') {
|
|
256
|
+
i = advance(endOfValue(text, i));
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const start = i;
|
|
260
|
+
const keyEnd = endOfString(text, i);
|
|
261
|
+
const name = text.slice(start + 1, keyEnd - 1);
|
|
262
|
+
const j = skipTrivia(text, keyEnd);
|
|
263
|
+
if (text[j] !== ":") {
|
|
264
|
+
i = advance(keyEnd);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const valueStart = skipTrivia(text, j + 1);
|
|
268
|
+
const valueEnd = endOfValue(text, valueStart);
|
|
269
|
+
members.push({ name, start, valueStart, valueEnd });
|
|
270
|
+
i = advance(valueEnd);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/** The first top-level `{`, or -1 if this document has no root object. */
|
|
274
|
+
function rootObject(text) {
|
|
275
|
+
const i = skipTrivia(text, 0);
|
|
276
|
+
return text[i] === "{" ? i : -1;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Put `value` at `mcpServers.<name>`, leaving every comment, every other server and
|
|
280
|
+
* every unknown key exactly where they were.
|
|
281
|
+
*
|
|
282
|
+
* Three cases, in the order they are cheapest to be sure about: no usable document at
|
|
283
|
+
* all (write a fresh one), a document with no `mcpServers` (insert the whole object),
|
|
284
|
+
* and a document that already has one (replace our member's value, or insert it).
|
|
285
|
+
*/
|
|
286
|
+
export function spliceJsonServer(text, name, value) {
|
|
287
|
+
const indent = (s, by) => s.split("\n").join(`\n${by}`);
|
|
288
|
+
const root = rootObject(text);
|
|
289
|
+
if (root === -1) {
|
|
290
|
+
return `{\n "mcpServers": {\n ${JSON.stringify(name)}: ${indent(value, " ")}\n }\n}\n`;
|
|
291
|
+
}
|
|
292
|
+
const { members } = membersOf(text, root);
|
|
293
|
+
const servers = members.find((m) => m.name === "mcpServers");
|
|
294
|
+
if (!servers) {
|
|
295
|
+
const block = `"mcpServers": {\n ${JSON.stringify(name)}: ${indent(value, " ")}\n }`;
|
|
296
|
+
// A trailing comma is only correct if something follows; an empty root gets neither.
|
|
297
|
+
const sep = members.length ? ",\n " : "\n ";
|
|
298
|
+
const head = members.length ? text.slice(0, members[0].start) : text.slice(0, root + 1);
|
|
299
|
+
const tail = members.length ? text.slice(members[0].start) : text.slice(root + 1);
|
|
300
|
+
return members.length
|
|
301
|
+
? `${head}${block}${sep}${tail}`
|
|
302
|
+
: `${head}${sep}${block}\n${tail}`;
|
|
303
|
+
}
|
|
304
|
+
// `mcpServers` present but not an OBJECT. Scanning on would treat the rest of the
|
|
305
|
+
// document as its members and splice our entry one character into a number or a
|
|
306
|
+
// string, writing a corrupt file over a config we were handed intact — the one
|
|
307
|
+
// outcome this whole textual approach exists to prevent. We own this key, and a
|
|
308
|
+
// non-object here is already meaningless to every harness that reads it, so replace
|
|
309
|
+
// the value wholesale and leave the rest of the document alone.
|
|
310
|
+
if (text[servers.valueStart] !== "{") {
|
|
311
|
+
const fresh = `{\n ${JSON.stringify(name)}: ${indent(value, " ")}\n }`;
|
|
312
|
+
return text.slice(0, servers.valueStart) + fresh + text.slice(servers.valueEnd);
|
|
313
|
+
}
|
|
314
|
+
const inner = membersOf(text, servers.valueStart);
|
|
315
|
+
const mine = inner.members.find((m) => m.name === name);
|
|
316
|
+
if (mine) {
|
|
317
|
+
return text.slice(0, mine.valueStart) + indent(value, " ") + text.slice(mine.valueEnd);
|
|
318
|
+
}
|
|
319
|
+
const at = servers.valueStart + 1;
|
|
320
|
+
const insert = `\n ${JSON.stringify(name)}: ${indent(value, " ")}${inner.members.length ? "," : ""}`;
|
|
321
|
+
return text.slice(0, at) + insert + text.slice(at);
|
|
322
|
+
}
|
|
323
|
+
/** True when `mcpServers.<name>` is already present — the receipt's "updated" vs "new". */
|
|
324
|
+
export function hasJsonServer(text, name) {
|
|
325
|
+
const root = rootObject(text);
|
|
326
|
+
if (root === -1)
|
|
327
|
+
return false;
|
|
328
|
+
const servers = membersOf(text, root).members.find((m) => m.name === "mcpServers");
|
|
329
|
+
if (!servers || text[servers.valueStart] !== "{")
|
|
330
|
+
return false;
|
|
331
|
+
return membersOf(text, servers.valueStart).members.some((m) => m.name === name);
|
|
332
|
+
}
|
|
333
|
+
/** Our entry, in the shape Cursor reads: command + args + env. */
|
|
334
|
+
export function jsonServerValue(entry) {
|
|
335
|
+
return JSON.stringify({ command: entry.command, args: entry.args, env: entry.env }, null, 2);
|
|
166
336
|
}
|
|
167
337
|
// --- Claude Code ------------------------------------------------------------
|
|
168
338
|
/**
|
|
@@ -217,11 +387,139 @@ const claudeCode = {
|
|
|
217
387
|
return { ok: true, path: "Claude Code (user scope)", replaced: had.status === 0 };
|
|
218
388
|
},
|
|
219
389
|
};
|
|
390
|
+
// --- Cursor -----------------------------------------------------------------
|
|
391
|
+
/**
|
|
392
|
+
* `cursor-agent`, NOT `cursor`.
|
|
393
|
+
*
|
|
394
|
+
* They are two different binaries and only one of them is a harness: `cursor` is the
|
|
395
|
+
* VS Code-style IDE launcher (`--diff`, `--merge`, `--goto`), while `cursor-agent` is
|
|
396
|
+
* the agent, and `cursor-agent mcp login` names `.cursor/mcp.json` or `~/.cursor/mcp.json`
|
|
397
|
+
* as where it reads servers from. Detecting on `cursor` would report a harness we had
|
|
398
|
+
* not wired anything usable into.
|
|
399
|
+
*
|
|
400
|
+
* The one adapter here that writes its own file: `cursor-agent mcp` offers login, list,
|
|
401
|
+
* enable and disable, but no `add`.
|
|
402
|
+
*/
|
|
403
|
+
const cursor = {
|
|
404
|
+
id: "cursor",
|
|
405
|
+
label: "Cursor",
|
|
406
|
+
configPath: () => join(home(), ".cursor", "mcp.json"),
|
|
407
|
+
detect: () => existsSync(join(home(), ".cursor")) || onPath("cursor-agent"),
|
|
408
|
+
install(entry) {
|
|
409
|
+
const p = join(home(), ".cursor", "mcp.json");
|
|
410
|
+
const before = readIfExists(p);
|
|
411
|
+
try {
|
|
412
|
+
writeConfig(p, spliceJsonServer(before, SERVER_NAME, jsonServerValue(entry)));
|
|
413
|
+
}
|
|
414
|
+
catch (e) {
|
|
415
|
+
return { ok: false, reason: String(e?.message ?? e) };
|
|
416
|
+
}
|
|
417
|
+
return { ok: true, path: p, replaced: hasJsonServer(before, SERVER_NAME) };
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
// --- harnesses with their own `mcp add` -------------------------------------
|
|
421
|
+
/**
|
|
422
|
+
* OpenCode and Gemini both ship the thing Claude Code ships and Codex, Grok and Cursor
|
|
423
|
+
* do not: a supported command for this. Each was checked against a real install
|
|
424
|
+
* (RTSC-793) for the three properties that decide whether calling it beats writing the
|
|
425
|
+
* file ourselves, and both have all three: it preserves comments and key order, it
|
|
426
|
+
* leaves other servers and unknown keys alone, and re-adding the same name REPLACES
|
|
427
|
+
* rather than failing (so `runSetup` on every bind stays idempotent).
|
|
428
|
+
*
|
|
429
|
+
* Given that, shelling out is strictly better than a serializer of ours: their config
|
|
430
|
+
* format is theirs to change, and a format change breaks our writer silently while
|
|
431
|
+
* their own command keeps working.
|
|
432
|
+
*
|
|
433
|
+
* `replaced` is read from the file rather than from what the command prints, so the
|
|
434
|
+
* receipt does not depend on anybody's wording staying put.
|
|
435
|
+
*/
|
|
436
|
+
function addCommandHarness(opts) {
|
|
437
|
+
return {
|
|
438
|
+
id: opts.id,
|
|
439
|
+
label: opts.label,
|
|
440
|
+
configPath: opts.configPath,
|
|
441
|
+
// Gated on RETASC_HOME for the same reason Claude Code is: this shells out to a
|
|
442
|
+
// command that resolves the REAL home, so a test home has to stop it at DETECTION.
|
|
443
|
+
// An undetected harness is never installed into.
|
|
444
|
+
detect: () => !process.env.RETASC_HOME && onPath(opts.bin),
|
|
445
|
+
install(entry) {
|
|
446
|
+
const before = readIfExists(opts.configPath());
|
|
447
|
+
const r = spawnSync(opts.bin, opts.args(entry), { encoding: "utf8" });
|
|
448
|
+
if (r.error)
|
|
449
|
+
return { ok: false, reason: r.error.message };
|
|
450
|
+
if (r.status !== 0) {
|
|
451
|
+
const msg = (r.stderr || r.stdout || "").trim().split("\n").pop() || `exited ${r.status}`;
|
|
452
|
+
return { ok: false, reason: msg };
|
|
453
|
+
}
|
|
454
|
+
return {
|
|
455
|
+
ok: true,
|
|
456
|
+
path: opts.configPath(),
|
|
457
|
+
replaced: before.includes(`"${SERVER_NAME}"`),
|
|
458
|
+
};
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
/** `~/.config/opencode/`, XDG-aware — OpenCode honours XDG_CONFIG_HOME, so resolving it
|
|
463
|
+
* to `~/.config` unconditionally would name the wrong file in the receipt. */
|
|
464
|
+
function opencodeConfig() {
|
|
465
|
+
const xdg = process.env.XDG_CONFIG_HOME || join(home(), ".config");
|
|
466
|
+
return join(xdg, "opencode", "opencode.jsonc");
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* `opencode mcp add <name> --env K=V -- <command> <args...>`.
|
|
470
|
+
*
|
|
471
|
+
* The `--` is required: without a URL or a command after it, `add` refuses. Note the
|
|
472
|
+
* stored shape is unlike every other harness here — the key is `mcp` rather than
|
|
473
|
+
* `mcpServers`, it carries `"type": "local"`, `command` is ONE array of command plus
|
|
474
|
+
* args, and env is spelled `environment`. A generic mcpServers writer would produce a
|
|
475
|
+
* file OpenCode ignores, which is the other half of why this one shells out.
|
|
476
|
+
*/
|
|
477
|
+
const opencode = addCommandHarness({
|
|
478
|
+
id: "opencode",
|
|
479
|
+
label: "OpenCode",
|
|
480
|
+
bin: "opencode",
|
|
481
|
+
configPath: opencodeConfig,
|
|
482
|
+
args: (entry) => [
|
|
483
|
+
"mcp", "add", SERVER_NAME,
|
|
484
|
+
...Object.entries(entry.env).flatMap(([k, v]) => ["--env", `${k}=${v}`]),
|
|
485
|
+
"--", entry.command, ...entry.args,
|
|
486
|
+
],
|
|
487
|
+
});
|
|
488
|
+
/**
|
|
489
|
+
* `gemini mcp add -s user -t stdio -e K=V <name> <command> <args...>`.
|
|
490
|
+
*
|
|
491
|
+
* Scope `user`, for the reason the whole marker exists: one entry, correct in every
|
|
492
|
+
* folder. The default is `project`, which would write a `.gemini/` into whichever
|
|
493
|
+
* directory `retasc setup` happened to run in.
|
|
494
|
+
*
|
|
495
|
+
* Two Gemini quirks, neither ours to fix but both worth knowing before someone reports
|
|
496
|
+
* them as our bug. `gemini mcp list` reports a freshly added server as `Disabled` and
|
|
497
|
+
* `gemini mcp enable <name>` answers `Server not found` for it — yet the server does
|
|
498
|
+
* start and connect on a real run, so `list` is not a verification signal. And Gemini
|
|
499
|
+
* gates on trusted folders: in an untrusted directory it refuses to start and NO MCP
|
|
500
|
+
* server loads, which looks exactly like a failed install.
|
|
501
|
+
*/
|
|
502
|
+
const gemini = addCommandHarness({
|
|
503
|
+
id: "gemini",
|
|
504
|
+
label: "Gemini CLI",
|
|
505
|
+
bin: "gemini",
|
|
506
|
+
configPath: () => join(home(), ".gemini", "settings.json"),
|
|
507
|
+
args: (entry) => [
|
|
508
|
+
"mcp", "add",
|
|
509
|
+
"-s", "user",
|
|
510
|
+
"-t", "stdio",
|
|
511
|
+
...Object.entries(entry.env).flatMap(([k, v]) => ["-e", `${k}=${v}`]),
|
|
512
|
+
SERVER_NAME, entry.command, ...entry.args,
|
|
513
|
+
],
|
|
514
|
+
});
|
|
220
515
|
// --- the registry -----------------------------------------------------------
|
|
221
516
|
export const HARNESSES = [
|
|
222
517
|
claudeCode,
|
|
223
518
|
tomlHarness("codex", "Codex", () => join(home(), ".codex", "config.toml"), "codex"),
|
|
224
519
|
tomlHarness("grok", "Grok", () => join(home(), ".grok", "config.toml"), "grok"),
|
|
520
|
+
cursor,
|
|
521
|
+
opencode,
|
|
522
|
+
gemini,
|
|
225
523
|
];
|
|
226
524
|
/** Every harness actually present on this machine. */
|
|
227
525
|
export function detectHarnesses(list = HARNESSES) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retasc/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.39.0",
|
|
4
4
|
"description": "Retasc CLI — the issue tracker AI agents pull work from. Sign in with GitHub or Google, create projects, mint agent API keys, and wire your agent to the Retasc MCP server in one command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|