@retasc/cli 1.30.0 → 1.31.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 +43 -0
- package/dist/auth.js +142 -2
- package/dist/commands/bind.js +168 -7
- package/dist/commands/claim.js +55 -2
- package/dist/commands/mcp.js +36 -0
- package/dist/config.js +3 -0
- package/dist/index.js +15 -1
- package/dist/lib/binding.js +49 -0
- package/dist/lib/browserLogin.js +80 -0
- package/dist/lib/outcome.js +102 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,49 @@ 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.31.0 (2026-08-23)
|
|
10
|
+
|
|
11
|
+
- **RTSC-713** — your agent can set Retasc up for you. It runs `bind` itself now, so
|
|
12
|
+
nothing asks you to open a terminal and type a command: you paste one line into the
|
|
13
|
+
chat, click Approve in the browser that opens, and restart your agent. The command it
|
|
14
|
+
runs is `bind --json`, the door built for a machine to drive.
|
|
15
|
+
- **RTSC-713** — `bind --json` reports what it did and what it still needs, one JSON
|
|
16
|
+
object per line, and prints the approve URL the moment it exists rather than at the
|
|
17
|
+
end. Your agent posts that URL to you as a link, which is what makes this work on a
|
|
18
|
+
machine where the browser does not open by itself.
|
|
19
|
+
- **RTSC-713** — a setup that paused is no longer dressed as a crash. "Signed in, but you
|
|
20
|
+
have not said which project this folder is for" exits 0 and says exactly that; only a
|
|
21
|
+
real dead end exits non-zero. It used to exit 1 with `✗ UNAUTHENTICATED`, and an agent
|
|
22
|
+
reading that reasonably concluded setup had failed and stopped, one question short of
|
|
23
|
+
done, holding a session that had actually worked.
|
|
24
|
+
- **RTSC-713** — sign-in no longer refuses merely because nothing is attached to a
|
|
25
|
+
terminal. It asks whether a browser can be reached, which is the thing that actually
|
|
26
|
+
matters. SSH, containers and CI still refuse, and still refuse fast: over SSH the
|
|
27
|
+
approve link targets `127.0.0.1` on whichever machine opened it, so it could never
|
|
28
|
+
reach the CLI waiting on the remote host.
|
|
29
|
+
- **RTSC-713** — every outcome names the absolute folder it is about to connect, so a
|
|
30
|
+
wrong one can be caught before anything is written. A wrong folder has no symptom
|
|
31
|
+
otherwise: the agent still calls in and the Dash still looks healthy.
|
|
32
|
+
- **RTSC-691** — `retasc login` opens your browser instead of asking you to retype an
|
|
33
|
+
eight-character code. Click Approve once and you are signed in, and you pick GitHub,
|
|
34
|
+
Google or a passkey in the Dash where you are usually signed in already, instead of
|
|
35
|
+
answering that question in the terminal. The device flow is still there and still
|
|
36
|
+
works; it is the fallback now rather than the only door.
|
|
37
|
+
- **RTSC-691** — the code path stays for the places a browser round trip cannot work:
|
|
38
|
+
inside a container, and over SSH, where the redirect would target `127.0.0.1` on
|
|
39
|
+
whichever machine opened the link and so could never reach the CLI waiting on the
|
|
40
|
+
remote host. `RETASC_NO_BROWSER=1` forces it everywhere.
|
|
41
|
+
- **RTSC-691** — `retasc release <RTSC-NN> --claim-token <token>` exists. Two error
|
|
42
|
+
messages in `retasc claim` had been telling people to run it for a while, and it was
|
|
43
|
+
not a command. The token is the one printed when you claimed; the server fences on it,
|
|
44
|
+
which is what stops one agent releasing another's work. Releasing leaves your worktree
|
|
45
|
+
and branch alone — `retasc tidy` is what reaps those.
|
|
46
|
+
- **RTSC-691** — `retasc bind` prints "restart your agent" whether or not a human is
|
|
47
|
+
watching. It was suppressed when output was not a terminal, which is exactly when an
|
|
48
|
+
agent is the one reading it and the one that has to pass the message on.
|
|
49
|
+
- **RTSC-691** — `bind` clears the credential-less bootstrap entry that would otherwise
|
|
50
|
+
shadow the binding it just wrote, when the two land in different scopes.
|
|
51
|
+
|
|
9
52
|
## 1.30.0 (2026-08-20)
|
|
10
53
|
|
|
11
54
|
- **RTSC-681** — your agent can read a file you attached to an issue. Uploading has
|
package/dist/auth.js
CHANGED
|
@@ -3,6 +3,7 @@ import { ConvexHttpClient } from "convex/browser";
|
|
|
3
3
|
import { makeFunctionReference } from "convex/server";
|
|
4
4
|
import { loadConfig, patchConfig } from "./config.js";
|
|
5
5
|
import { ask, isInteractive } from "./lib/prompt.js";
|
|
6
|
+
import { startBrowserLogin } from "./lib/browserLogin.js";
|
|
6
7
|
// The GitHub OAuth App's PUBLIC client id (safe to ship — device flow needs no
|
|
7
8
|
// secret). Baked in so `retasc login` works out-of-the-box; override via env.
|
|
8
9
|
const GITHUB_CLIENT_ID = process.env.RETASC_GITHUB_CLIENT_ID ?? "Ov23linqRy875IU8OYTW";
|
|
@@ -29,6 +30,46 @@ async function postForm(url, body) {
|
|
|
29
30
|
return res.json();
|
|
30
31
|
}
|
|
31
32
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
33
|
+
/**
|
|
34
|
+
* Can this machine plausibly complete a browser round trip (RTSC-691)?
|
|
35
|
+
*
|
|
36
|
+
* Three ways the answer is no, and each of them has cost somebody time:
|
|
37
|
+
*
|
|
38
|
+
* • **No human.** A scripted or CI run has nobody to click, so waiting is waiting for
|
|
39
|
+
* nothing.
|
|
40
|
+
* • **SSH.** `isInteractive()` is true over SSH, which makes a TTY check exactly the
|
|
41
|
+
* wrong gate. The redirect goes to 127.0.0.1 on the machine whose browser opened it,
|
|
42
|
+
* so a human clicking on their laptop can never reach a listener on the remote host.
|
|
43
|
+
* Not slow — impossible.
|
|
44
|
+
* • **Explicitly opted out.** `RETASC_NO_BROWSER` is the documented switch (see
|
|
45
|
+
* `cli/src/config.ts`) and is honoured HERE too, not only inside `openBrowser`.
|
|
46
|
+
* Reading it in one place and not the other produced the worst outcome available: no
|
|
47
|
+
* browser opens AND the caller still waits out the timeout. Deliberately ONE variable
|
|
48
|
+
* — a second, undocumented knob is one nobody can find when they need it.
|
|
49
|
+
*/
|
|
50
|
+
export function canOpenBrowser(agentDriven = false) {
|
|
51
|
+
if (process.env.RETASC_NO_BROWSER)
|
|
52
|
+
return false;
|
|
53
|
+
if (process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT)
|
|
54
|
+
return false;
|
|
55
|
+
// RTSC-713 — CI is the case the TTY check used to cover by accident.
|
|
56
|
+
//
|
|
57
|
+
// `agentDriven` drops the TTY requirement, because an agent's shell has no TTY and
|
|
58
|
+
// that was the entire reason cold `bind` was unreachable to one. But "no TTY" was also
|
|
59
|
+
// the only thing holding this door shut in CI, where nobody can click, the listener
|
|
60
|
+
// blocks for its full timeout, and the device flow it falls through to cannot complete
|
|
61
|
+
// either. So the guard the TTY check was silently providing is now stated outright,
|
|
62
|
+
// and it applies on BOTH paths: an agent running inside CI is still CI.
|
|
63
|
+
if (process.env.CI)
|
|
64
|
+
return false;
|
|
65
|
+
// An agent is a human's hands, not a human's absence — the person is in the chat, and
|
|
66
|
+
// the URL this door prints is relayed to them there. The property that must still hold
|
|
67
|
+
// is that a browser on THIS machine can reach the loopback listener, which is what the
|
|
68
|
+
// SSH check above is for and why it is not relaxed here.
|
|
69
|
+
if (agentDriven)
|
|
70
|
+
return true;
|
|
71
|
+
return isInteractive();
|
|
72
|
+
}
|
|
32
73
|
/**
|
|
33
74
|
* Open a URL in the machine's browser, best effort (RTSC-676).
|
|
34
75
|
*
|
|
@@ -49,7 +90,11 @@ function openBrowser(url) {
|
|
|
49
90
|
const [cmd, args] = process.platform === "darwin"
|
|
50
91
|
? ["open", [url]]
|
|
51
92
|
: process.platform === "win32"
|
|
52
|
-
?
|
|
93
|
+
? // QUOTED: cmd.exe treats `&` as a command separator, and the sign-in URL is the
|
|
94
|
+
// first here to carry two query parameters (`?port=…&state=…`). Unquoted it
|
|
95
|
+
// opens a truncated link (the Dash then reports "that link is incomplete") and
|
|
96
|
+
// tries to run the remainder as a command.
|
|
97
|
+
["start", ['""', `"${url}"`]]
|
|
53
98
|
: ["xdg-open", [url]];
|
|
54
99
|
try {
|
|
55
100
|
const child = spawn(cmd, args, {
|
|
@@ -197,6 +242,42 @@ deps = {}) {
|
|
|
197
242
|
}
|
|
198
243
|
throw new Error("no valid choice — aborting");
|
|
199
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* Sign in through the browser and persist the session (RTSC-691).
|
|
247
|
+
*
|
|
248
|
+
* Prints the URL BEFORE waiting, unconditionally, for the same reason `announceCode`
|
|
249
|
+
* does: an auto-open that silently failed must leave the screen exactly as useful as it
|
|
250
|
+
* was. On a headless box the printed link is the whole path, and when an agent is
|
|
251
|
+
* driving, that printed line is what it relays to its human.
|
|
252
|
+
*/
|
|
253
|
+
async function browserLogin(deploymentUrl, dashUrl, onUrl) {
|
|
254
|
+
const { url, port, wait } = await startBrowserLogin(dashUrl);
|
|
255
|
+
// RTSC-713 — hand the URL to the caller BEFORE the wait, not only to the terminal.
|
|
256
|
+
//
|
|
257
|
+
// `startBrowserLogin` was already split in two so the URL exists before blocking; this
|
|
258
|
+
// is the other half of that split finally being used. An agent backgrounds this
|
|
259
|
+
// process and tails its output, so the URL reaching stdout here is what lets it post a
|
|
260
|
+
// clickable link into the chat while this call is still blocked on the click. Printed
|
|
261
|
+
// AND opened AND handed back: the browser popping up is the nice case, the link in the
|
|
262
|
+
// chat is the one that always works.
|
|
263
|
+
onUrl?.(url);
|
|
264
|
+
console.log(`\n Open: ${url}\n`);
|
|
265
|
+
console.log(" Waiting for you to approve it in the browser…");
|
|
266
|
+
openBrowser(url);
|
|
267
|
+
const { code } = await wait();
|
|
268
|
+
const convex = new ConvexHttpClient(deploymentUrl);
|
|
269
|
+
const res = await convex.action(signIn, {
|
|
270
|
+
provider: "cli-browser",
|
|
271
|
+
params: { code, port },
|
|
272
|
+
});
|
|
273
|
+
const tokens = res?.tokens;
|
|
274
|
+
if (!tokens?.token)
|
|
275
|
+
throw new Error("Sign-in did not return a session token.");
|
|
276
|
+
// No `loginProvider` recorded: this door does not name an upstream identity, and
|
|
277
|
+
// writing one would make the next device-flow default a guess rather than a memory of
|
|
278
|
+
// what actually worked.
|
|
279
|
+
patchConfig({ token: tokens.token, refreshToken: tokens.refreshToken });
|
|
280
|
+
}
|
|
200
281
|
/**
|
|
201
282
|
* Run a device-flow login and persist the resulting Convex Auth session to
|
|
202
283
|
* ~/.retasc/config.json. Prints the user code + verification URL.
|
|
@@ -204,8 +285,67 @@ deps = {}) {
|
|
|
204
285
|
* With no `provider`, asks which door when a human is present and falls back to
|
|
205
286
|
* GitHub when one isn't.
|
|
206
287
|
*/
|
|
207
|
-
export async function deviceLogin(provider) {
|
|
288
|
+
export async function deviceLogin(provider, opts) {
|
|
208
289
|
const cfg = loadConfig();
|
|
290
|
+
// RTSC-691 — try the browser round trip FIRST when a human is present.
|
|
291
|
+
//
|
|
292
|
+
// For them it removes the eight characters they had to retype, and skips the
|
|
293
|
+
// which-door question entirely: they pick GitHub, Google or a passkey in the Dash,
|
|
294
|
+
// where they are often already signed in.
|
|
295
|
+
//
|
|
296
|
+
// GATED, and the gate is load-bearing rather than cautious. This path opens a listener
|
|
297
|
+
// and BLOCKS until somebody clicks. Where nobody can click it blocks for the full
|
|
298
|
+
// timeout and then falls through, turning a fast, honest refusal into a long hang — in
|
|
299
|
+
// CI, in a container, and in the CLI's own tests.
|
|
300
|
+
//
|
|
301
|
+
// A TTY check alone is NOT that gate, which is the trap here: `isInteractive()` is TRUE
|
|
302
|
+
// over SSH. On a remote box there is usually no browser to open, `xdg-open` is absent
|
|
303
|
+
// and its failure is swallowed by design, and — decisively — the redirect targets
|
|
304
|
+
// 127.0.0.1 on whichever machine opened the link, so a human clicking on their laptop
|
|
305
|
+
// can never reach a listener on the server. See canOpenBrowser().
|
|
306
|
+
//
|
|
307
|
+
// It does not help an agent-driven `bind` either, which was the original hope. An
|
|
308
|
+
// agent running a blocking command does not see stdout until the process exits, so a
|
|
309
|
+
// URL printed at the top of a five-minute wait reaches nobody. Making cold `bind`
|
|
310
|
+
// agent-runnable needs a two-step shape — print and exit, then complete — which is a
|
|
311
|
+
// different change; see RTSC-678.
|
|
312
|
+
//
|
|
313
|
+
// Naming a provider (`--github` / `--google`) still means the device flow, because
|
|
314
|
+
// that flag is a statement about which upstream identity to use and this path does not
|
|
315
|
+
// take one.
|
|
316
|
+
// RTSC-713 — an agent with no browser door has nowhere to fall to, so say so NOW.
|
|
317
|
+
//
|
|
318
|
+
// Found by the test for this, which hung. The device flow below prints a code to be
|
|
319
|
+
// retyped and then asks which door; both need a TTY, and an agent has neither. Falling
|
|
320
|
+
// through left the process blocked on a prompt nobody could see, which is the worst
|
|
321
|
+
// available outcome: not a refusal an agent can relay, just silence until something
|
|
322
|
+
// times out. SSH, containers and CI all land here, and for all three the honest answer
|
|
323
|
+
// is immediate.
|
|
324
|
+
if (opts?.agentDriven && !canOpenBrowser(true)) {
|
|
325
|
+
throw new Error("no browser available on this machine, and signing in needs one. " +
|
|
326
|
+
"Over SSH or in a container the approve link cannot reach this host: sign in on a " +
|
|
327
|
+
"machine with a browser and bind there, or run `retasc login` yourself at a terminal.");
|
|
328
|
+
}
|
|
329
|
+
if (!provider && canOpenBrowser(opts?.agentDriven)) {
|
|
330
|
+
try {
|
|
331
|
+
await browserLogin(cfg.deploymentUrl, cfg.dashUrl, opts?.onUrl);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
catch (e) {
|
|
335
|
+
// Every failure here is recoverable by the door below, so say what happened and
|
|
336
|
+
// carry on rather than ending a sign-in that can still succeed. A container with
|
|
337
|
+
// no browser is the expected case, not an error.
|
|
338
|
+
const why = e instanceof Error ? e.message : String(e);
|
|
339
|
+
console.error(`Browser sign-in didn't complete (${why}). Falling back to a code.`);
|
|
340
|
+
// RTSC-713 — but an agent has no fallback to fall to. The device flow below prints
|
|
341
|
+
// a code for someone to retype and then asks which door, both of which need a TTY.
|
|
342
|
+
// Continuing would hang until timeout and then fail with a message about a prompt
|
|
343
|
+
// nobody saw. Rethrow so the caller reports SIGN_IN_FAILED, which is honest and
|
|
344
|
+
// immediate, instead of a wait that ends in a lie.
|
|
345
|
+
if (opts?.agentDriven)
|
|
346
|
+
throw e;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
209
349
|
const door = provider ?? (await chooseProvider({ remembered: cfg.loginProvider }));
|
|
210
350
|
const convex = new ConvexHttpClient(cfg.deploymentUrl);
|
|
211
351
|
// Exchange the upstream token for a Retasc (Convex Auth) session. The token is
|
package/dist/commands/bind.js
CHANGED
|
@@ -9,6 +9,7 @@ import { resolveLauncher, launcherNote, runsOk, selfCommand, versionStamp } from
|
|
|
9
9
|
import { ask, confirm, isInteractive } from "../lib/prompt.js";
|
|
10
10
|
import { clean } from "../lib/text.js";
|
|
11
11
|
import { card, DOT } from "../lib/card.js";
|
|
12
|
+
import { emit } from "../lib/outcome.js";
|
|
12
13
|
import { VERSION } from "../version.js";
|
|
13
14
|
// RTSC-508: `ask`/`confirm`/`isInteractive` now live in lib/prompt.ts so `auth.ts`
|
|
14
15
|
// can use them without closing an import cycle (bind → auth → bind). Re-exported
|
|
@@ -445,8 +446,14 @@ export async function completeWorkspaceSetup(args) {
|
|
|
445
446
|
//
|
|
446
447
|
// AFTER the confirmation, and after everything that can throw, so it is only ever
|
|
447
448
|
// printed by a run that actually finished. Nothing below it can fail.
|
|
449
|
+
// RTSC-691 — the restart line prints UNCONDITIONALLY. It was gated on a TTY, which is
|
|
450
|
+
// false in exactly the case that needs it most: when an agent runs `bind`, the agent is
|
|
451
|
+
// the only reader of this output, and it is the one that has to tell the human to
|
|
452
|
+
// restart. A setup that finished but never says so is indistinguishable from one that
|
|
453
|
+
// failed. The "what now" suggestions stay TTY-only — those are for a person browsing.
|
|
454
|
+
console.log(`\n${NEXT_STEP}\n`);
|
|
448
455
|
if (isInteractive())
|
|
449
|
-
console.log(
|
|
456
|
+
console.log(`${whatNow(pfx, emptyProject)}\n`);
|
|
450
457
|
}
|
|
451
458
|
/**
|
|
452
459
|
* The two sentences under `NEXT_STEP`, chosen by whether there is anything to pull yet
|
|
@@ -559,10 +566,20 @@ export const NEXT_STEP = "Start your agent in this folder, or restart it if it's
|
|
|
559
566
|
* because "run this, but run the other one first" is not a single instruction. So the
|
|
560
567
|
* sign-in is explicit, announced, and only when a human is present to authorize it.
|
|
561
568
|
*/
|
|
562
|
-
export async function ensureSignedIn() {
|
|
569
|
+
export async function ensureSignedIn(agent) {
|
|
563
570
|
if (loadConfig().token)
|
|
564
571
|
return;
|
|
565
|
-
|
|
572
|
+
// RTSC-713 — the no-TTY refusal now depends on whether a DOOR exists, not on whether
|
|
573
|
+
// this process has a terminal.
|
|
574
|
+
//
|
|
575
|
+
// The refusal was right about its own case and wrong about the one that matters here.
|
|
576
|
+
// Both doors need a human to act, so a process with nobody watching should stop fast.
|
|
577
|
+
// But an agent-driven run is not nobody watching: the human is in the chat, and the
|
|
578
|
+
// approve URL reaches them there. What actually has to be true is that a browser on
|
|
579
|
+
// this machine can reach the loopback listener, which is exactly what
|
|
580
|
+
// `canOpenBrowser()` decides. So ask it, instead of using "has a TTY" as a proxy for
|
|
581
|
+
// it — the proxy is what made cold `bind` unreachable to an agent.
|
|
582
|
+
if (!isInteractive() && !agent) {
|
|
566
583
|
// RTSC-672 — the stamp rides in the HINT, not the message. `formatError` keeps only
|
|
567
584
|
// the first line of a message (deliberately, to strip stack noise), so a second line
|
|
568
585
|
// here would be silently dropped — which is how a diagnostic aimed at silent failures
|
|
@@ -573,10 +590,47 @@ export async function ensureSignedIn() {
|
|
|
573
590
|
// announcing "GitHub" before the question would be wrong for the invited
|
|
574
591
|
// teammate who signed up through Google — the exact person `join` is for.
|
|
575
592
|
console.error("Not signed in yet — let's do that first.");
|
|
576
|
-
await deviceLogin();
|
|
593
|
+
await deviceLogin(undefined, agent ? { agentDriven: true, onUrl: agent.onUrl } : undefined);
|
|
577
594
|
}
|
|
578
595
|
export async function bindAction(opts) {
|
|
579
|
-
|
|
596
|
+
// RTSC-713 — `--json` is the signal that a machine is driving, and it turns three
|
|
597
|
+
// things on at once: NDJSON on stdout, the browser door without a TTY, and continuable
|
|
598
|
+
// outcomes instead of thrown errors. One flag rather than three, because they are not
|
|
599
|
+
// independently useful: structured output nobody can act on, or a door that leads to a
|
|
600
|
+
// thrown Error, is each worse than neither.
|
|
601
|
+
const agent = opts.json
|
|
602
|
+
? { onUrl: (url) => emit({ event: "approve_url", url }) }
|
|
603
|
+
: undefined;
|
|
604
|
+
const folder = process.cwd();
|
|
605
|
+
/** Continuable: say what is missing, exit 0. See lib/outcome.ts for why 0. */
|
|
606
|
+
const pause = (o) => {
|
|
607
|
+
if (agent)
|
|
608
|
+
emit({ event: "outcome", outcome: { ...o, continuable: true, folder } });
|
|
609
|
+
};
|
|
610
|
+
/** Terminal: nothing to be done from here, so exit non-zero and say so. */
|
|
611
|
+
const stop = (o) => {
|
|
612
|
+
if (agent)
|
|
613
|
+
emit({ event: "outcome", outcome: { ...o, continuable: false, folder } });
|
|
614
|
+
process.exitCode = 1;
|
|
615
|
+
};
|
|
616
|
+
try {
|
|
617
|
+
await ensureSignedIn(agent);
|
|
618
|
+
}
|
|
619
|
+
catch (e) {
|
|
620
|
+
if (!agent)
|
|
621
|
+
throw e;
|
|
622
|
+
// Sign-in was ATTEMPTED and did not complete. Distinct from arriving with no session,
|
|
623
|
+
// which is continuable — collapsing the two is what made the old refusal stop agents
|
|
624
|
+
// that had actually succeeded.
|
|
625
|
+
const why = e instanceof Error ? e.message : String(e);
|
|
626
|
+
stop({
|
|
627
|
+
state: /no browser|no door|RETASC_NO_BROWSER/i.test(why) ? "NO_SIGN_IN_DOOR" : "SIGN_IN_FAILED",
|
|
628
|
+
next: `Sign-in did not complete: ${why}. Tell your human what happened. ` +
|
|
629
|
+
`If this machine has no browser (a container, or SSH to a remote box), they can ` +
|
|
630
|
+
`sign in on a machine that does and bind there instead.`,
|
|
631
|
+
});
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
580
634
|
const cfg = loadConfig();
|
|
581
635
|
// --- loud on re-bind -------------------------------------------------------
|
|
582
636
|
// Runs before the org step on purpose: `--org-name` commits an org server-side, and a
|
|
@@ -611,6 +665,7 @@ export async function bindAction(opts) {
|
|
|
611
665
|
if (!orgId) {
|
|
612
666
|
const me = (await api.me());
|
|
613
667
|
const orgs = me.orgs ?? [];
|
|
668
|
+
const pendingInvites = me.pendingInvites ?? [];
|
|
614
669
|
if (isInteractive()) {
|
|
615
670
|
const chosen = await pick("Select an org", orgs, (o) => `${o.name} (${o.slug})`);
|
|
616
671
|
if (chosen) {
|
|
@@ -625,13 +680,94 @@ export async function bindAction(opts) {
|
|
|
625
680
|
console.log(`✓ Created org "${name}".`);
|
|
626
681
|
}
|
|
627
682
|
}
|
|
628
|
-
else if (orgs.length === 1) {
|
|
629
|
-
|
|
683
|
+
else if (orgs.length === 1 && (!agent || !pendingInvites.length)) {
|
|
684
|
+
// Unambiguous in a non-interactive run. The invite check is scoped to `agent` ON
|
|
685
|
+
// PURPOSE: a scripted `bind` with one org has bound to that org since RTSC-91, and
|
|
686
|
+
// making a pending invitation suddenly turn that into an error would break existing
|
|
687
|
+
// automation for a question a script cannot answer anyway. The agent CAN ask, so
|
|
688
|
+
// for it the invite outranks the count.
|
|
689
|
+
orgId = orgs[0].id;
|
|
690
|
+
}
|
|
691
|
+
else if (agent) {
|
|
692
|
+
// RTSC-713 — the handoff, and the state this whole issue exists for.
|
|
693
|
+
//
|
|
694
|
+
// Sign-in SUCCEEDED and the session is on disk. All that is missing is which org
|
|
695
|
+
// this folder is for, which is a question, not a failure. It used to be
|
|
696
|
+
// `throw new Error("no org selected — pass --org-id …")`, written for a human at a
|
|
697
|
+
// shell, and an agent reading a thrown Error correctly concluded the run had failed
|
|
698
|
+
// and stopped one question short of done.
|
|
699
|
+
//
|
|
700
|
+
// Invites are checked BEFORE the count, and outrank a single existing org: someone
|
|
701
|
+
// holding an invitation who also owns a personal org must still be offered the
|
|
702
|
+
// team, or `bind` quietly points this folder at the wrong workspace and the join
|
|
703
|
+
// they were sent never happens.
|
|
704
|
+
const signedInAs = me.user?.email ?? me.user?.name;
|
|
705
|
+
pause({
|
|
706
|
+
state: pendingInvites.length ? "NEEDS_JOIN_OR_CREATE" : "NEEDS_ORG",
|
|
707
|
+
signedInAs,
|
|
708
|
+
orgs,
|
|
709
|
+
pendingInvites: pendingInvites.length ? pendingInvites : undefined,
|
|
710
|
+
missing: ["--org-id or --org-name", "--project or --project-id"],
|
|
711
|
+
next: pendingInvites.length
|
|
712
|
+
? `Signed in as ${signedInAs}. They have been invited to ` +
|
|
713
|
+
`${pendingInvites.map((i) => `"${i.org}"`).join(", ")}. ASK whether they want to join ` +
|
|
714
|
+
`that team or start their own workspace — do not choose for them. Joining uses ` +
|
|
715
|
+
`\`join\`; starting fresh re-runs bind with --org-name. Getting this wrong creates a ` +
|
|
716
|
+
`second organization that cannot be deleted.`
|
|
717
|
+
: orgs.length === 0
|
|
718
|
+
? `Signed in as ${signedInAs}, with no organization yet. Ask what to call their ` +
|
|
719
|
+
`workspace and their first project, then re-run with --org-name, --project and ` +
|
|
720
|
+
`--prefix. Confirm the folder with them first: this will connect ${folder}.`
|
|
721
|
+
: `Signed in as ${signedInAs}, with ${orgs.length} organizations. Ask which one ` +
|
|
722
|
+
`THIS FOLDER is for and which project inside it, then re-run with --org-id and ` +
|
|
723
|
+
`--project-id. Confirm the folder with them: this will connect ${folder}. ` +
|
|
724
|
+
`If the folder plainly matches one of the projects and they pick another, say ` +
|
|
725
|
+
`so once, then do as they say.`,
|
|
726
|
+
});
|
|
727
|
+
return;
|
|
630
728
|
}
|
|
631
729
|
else {
|
|
632
730
|
throw new Error("no org selected — pass --org-id <id> or --org-name <name> (or run interactively).");
|
|
633
731
|
}
|
|
634
732
|
}
|
|
733
|
+
// RTSC-713 — settle the project BEFORE entering the strand window, when an agent is
|
|
734
|
+
// driving.
|
|
735
|
+
//
|
|
736
|
+
// Ordering, not tidiness. `completeWorkspaceSetup` resolves the project itself and
|
|
737
|
+
// ends in `cliError("AMBIGUOUS", …)` when it cannot, but by then this run may already
|
|
738
|
+
// have created an org — and an abort past that point strands one on the billing rail
|
|
739
|
+
// with no CLI delete (RTSC-297). Asking here means the pause happens while there is
|
|
740
|
+
// still nothing to strand.
|
|
741
|
+
//
|
|
742
|
+
// `firstProject()`, the from-scratch-or-import fork that mirrors the Dash's "where does
|
|
743
|
+
// your work come from?", is TTY-only and stays that way. Its question is now asked in
|
|
744
|
+
// chat instead, which is why the instruction below names the five trackers: without it
|
|
745
|
+
// someone arriving from Jira invents a project they did not want and leaves an empty
|
|
746
|
+
// one behind that cannot be deleted (RTSC-530).
|
|
747
|
+
if (agent && !opts.projectId && !(opts.project && opts.prefix)) {
|
|
748
|
+
const { projects } = (await api.listProjects({ orgId: orgId }));
|
|
749
|
+
const list = projects ?? [];
|
|
750
|
+
if (list.length === 1) {
|
|
751
|
+
opts = { ...opts, projectId: list[0].id }; // not a choice; asking would be noise
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
pause({
|
|
755
|
+
state: "NEEDS_PROJECT",
|
|
756
|
+
org: { id: orgId, name: opts.orgName ?? orgId },
|
|
757
|
+
projects: list,
|
|
758
|
+
missing: list.length ? ["--project-id"] : ["--project", "--prefix"],
|
|
759
|
+
next: list.length
|
|
760
|
+
? `That organization has ${list.length} projects. Ask which one THIS FOLDER is ` +
|
|
761
|
+
`for, then re-run with --project-id. Confirm the folder with them: this will ` +
|
|
762
|
+
`connect ${folder}.`
|
|
763
|
+
: `That organization has no projects yet. Ask what they are working on, and ` +
|
|
764
|
+
`whether they want to bring a backlog across from Linear, Jira, Asana, ClickUp ` +
|
|
765
|
+
`or Shortcut rather than start empty. Then re-run with --project and --prefix. ` +
|
|
766
|
+
`Confirm the folder with them: this will connect ${folder}.`,
|
|
767
|
+
});
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
635
771
|
// Everything from here to the binding write is the strand window: the org
|
|
636
772
|
// exists but the workspace isn't bound yet. If we abort in it after creating
|
|
637
773
|
// the org this run, name it and print the resume command (RTSC-297).
|
|
@@ -652,6 +788,31 @@ export async function bindAction(opts) {
|
|
|
652
788
|
const hint = strandRecoveryHint({ createdOrgThisRun, orgId, bindingWritten });
|
|
653
789
|
if (hint)
|
|
654
790
|
console.error(hint);
|
|
791
|
+
if (agent) {
|
|
792
|
+
// The prose hint above is written for a terminal. An agent needs the same facts as
|
|
793
|
+
// a state it can act on, and this one IS terminal: a stranded org needs a human
|
|
794
|
+
// decision, not another pass.
|
|
795
|
+
stop({
|
|
796
|
+
state: "REFUSED",
|
|
797
|
+
org: { id: orgId, name: opts.orgName ?? orgId },
|
|
798
|
+
next: `Setup failed after the organization was created: ` +
|
|
799
|
+
`${err instanceof Error ? err.message : String(err)}. ` +
|
|
800
|
+
`Tell your human, and pass on that the organization already exists, so a retry ` +
|
|
801
|
+
`must use --org-id ${orgId} rather than --org-name, or they end up with two.`,
|
|
802
|
+
});
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
655
805
|
throw err;
|
|
656
806
|
}
|
|
807
|
+
// Bound. The folder, the org and the project are all settled and on disk, and the one
|
|
808
|
+
// thing left is the restart — which the agent has to ask for, because a client reads
|
|
809
|
+
// its MCP config at startup and nothing here can do it for them.
|
|
810
|
+
pause({
|
|
811
|
+
state: "BOUND",
|
|
812
|
+
org: { id: orgId },
|
|
813
|
+
next: `Setup is complete and ${folder} is connected. TELL YOUR HUMAN it worked, name the ` +
|
|
814
|
+
`folder and the project, and ask them to restart you — your Retasc tools only load ` +
|
|
815
|
+
`when your client starts, so setup is not finished until they do. A resumed session ` +
|
|
816
|
+
`does not count; it has to be a new one. After the restart, call setup_status.`,
|
|
817
|
+
});
|
|
657
818
|
}
|
package/dist/commands/claim.js
CHANGED
|
@@ -109,7 +109,11 @@ export async function claimAction(opts) {
|
|
|
109
109
|
// point at the worktree instead of leaving the user stuck on the error.
|
|
110
110
|
if (requestedId && /ALREADY_CLAIMED/.test(msg) && makeWorktree) {
|
|
111
111
|
note(` ${requestedId} is held. If it's your session, resume in ${parentDir}/${repoName}-${requestedId.toLowerCase()}`);
|
|
112
|
-
|
|
112
|
+
// Deliberately does NOT suggest `release` here. A lease held by a DIFFERENT session
|
|
113
|
+
// is fenced: releasing it needs that session's claim token, which this caller does
|
|
114
|
+
// not have and should not have. Naming a command they cannot run is the dead end
|
|
115
|
+
// this hint exists to avoid. If it really is abandoned, the reclaimer frees it.
|
|
116
|
+
note(` If it isn't yours, wait for the lease to lapse — the server reclaims it automatically.`);
|
|
113
117
|
}
|
|
114
118
|
process.exit(1);
|
|
115
119
|
}
|
|
@@ -186,7 +190,11 @@ export async function claimAction(opts) {
|
|
|
186
190
|
note(` The branch already exists — attach without -b: git worktree add ${plan.path} ${plan.branch}`);
|
|
187
191
|
}
|
|
188
192
|
else {
|
|
189
|
-
|
|
193
|
+
// We hold this lease and its token, so name the command that actually works,
|
|
194
|
+
// token included — the caller has no other way to get it once this exits.
|
|
195
|
+
note(claim.claimToken
|
|
196
|
+
? ` Resolve the error and retry, or return it to the pool:\n retasc release ${issueId} --claim-token ${claim.claimToken}`
|
|
197
|
+
: ` Resolve the error and retry.`);
|
|
190
198
|
}
|
|
191
199
|
process.exit(1);
|
|
192
200
|
}
|
|
@@ -225,3 +233,48 @@ function finish(path, branch, issueId, title, claimToken, opts) {
|
|
|
225
233
|
note("");
|
|
226
234
|
note(` cd ${path}`);
|
|
227
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* `retasc release <RTSC-NN>` — hand a claimed issue back to the queue (RTSC-691).
|
|
238
|
+
*
|
|
239
|
+
* This command is here because two error paths in this same file already told people to
|
|
240
|
+
* run it (`claim.ts`, the "already held" and "worktree failed" hints) and it did not
|
|
241
|
+
* exist. A CLI that names a command it does not have is worse than one that says nothing:
|
|
242
|
+
* it sends somebody to a dead end at the exact moment they are already stuck.
|
|
243
|
+
*
|
|
244
|
+
* Deliberately does NOT touch git. The worktree and branch may hold real work, and this
|
|
245
|
+
* only returns the LEASE — `retasc tidy` is the command that reaps checkouts, and it
|
|
246
|
+
* refuses to remove anything dirty or unmerged. Keeping the two separate means giving
|
|
247
|
+
* work back is never the thing that loses it.
|
|
248
|
+
*/
|
|
249
|
+
export async function releaseAction(opts) {
|
|
250
|
+
const raw = opts.id ?? opts.issueArg;
|
|
251
|
+
if (!raw) {
|
|
252
|
+
throw new Error("Which issue? Pass one, e.g. `retasc release RTSC-42 --claim-token <token>`.");
|
|
253
|
+
}
|
|
254
|
+
const identifier = normalizeIssueId(raw);
|
|
255
|
+
if (!isValidIssueId(identifier)) {
|
|
256
|
+
throw new Error(`Not an issue id: ${raw}. Expected something like RTSC-42.`);
|
|
257
|
+
}
|
|
258
|
+
// The token is REQUIRED by the server and is not optional here either.
|
|
259
|
+
//
|
|
260
|
+
// `release_issue` is fenced: `assertHolds(issue, claimToken)` proves the caller is the
|
|
261
|
+
// session that actually holds the lease, which is the whole reason one agent cannot
|
|
262
|
+
// release another's work. The CLI does not persist claim tokens anywhere, so it cannot
|
|
263
|
+
// look one up — it prints the token when it claims, and this is where that print gets
|
|
264
|
+
// used. Refusing here, with the reason, beats sending a call the server will reject
|
|
265
|
+
// with a fence error the reader has no context for.
|
|
266
|
+
if (!opts.claimToken) {
|
|
267
|
+
throw new Error(`Releasing ${identifier} needs its claim token: \`retasc release ${identifier} --claim-token <token>\`.\n` +
|
|
268
|
+
` The token is printed when you claim, and is in the \`--json\` output of \`retasc claim\`.\n` +
|
|
269
|
+
` It proves you are the session holding the lease — without it the server cannot tell\n` +
|
|
270
|
+
` your release from someone else's, and refuses.`);
|
|
271
|
+
}
|
|
272
|
+
const conn = resolveMcpConn({ mcpJson: readMcpJson() });
|
|
273
|
+
await mcpCall(conn, "release_issue", {
|
|
274
|
+
identifier,
|
|
275
|
+
claimToken: opts.claimToken,
|
|
276
|
+
...(opts.note ? { note: opts.note } : {}),
|
|
277
|
+
});
|
|
278
|
+
console.log(`✓ Released ${identifier} back to the queue.`);
|
|
279
|
+
console.log(" Its worktree and branch are untouched — `retasc tidy` reaps those.");
|
|
280
|
+
}
|
package/dist/commands/mcp.js
CHANGED
|
@@ -3,6 +3,7 @@ import { readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { resolveLauncher, launcherNote, portableLauncher, } from "../lib/launcher.js";
|
|
5
5
|
import { VERSION } from "../version.js";
|
|
6
|
+
import { hasClaudeLocalPlaceholder } from "../lib/binding.js";
|
|
6
7
|
export const SERVER_NAME = "retasc";
|
|
7
8
|
/** Normalize a user-supplied scope string. `user` (global) is refused and
|
|
8
9
|
* downgraded to `local`, loudly — per-folder binding is the only right way. */
|
|
@@ -207,6 +208,37 @@ export function installMcp(opts) {
|
|
|
207
208
|
console.log(mcpConfigBlock(opts.url, opts.key));
|
|
208
209
|
console.log(`\nYour agent can now reach Retasc at ${opts.url}.`);
|
|
209
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* Clear a credential-less placeholder that would SHADOW the marker we are about to write
|
|
213
|
+
* (RTSC-691).
|
|
214
|
+
*
|
|
215
|
+
* Only ever removes an entry `isPlaceholderEntry` recognises — a bare URL with no
|
|
216
|
+
* credential — and only from the scope we are NOT writing to. Writing our own scope
|
|
217
|
+
* overwrites in place, so there is nothing to clean there.
|
|
218
|
+
*
|
|
219
|
+
* Removal goes through `claude mcp remove`, never by editing `~/.claude.json` ourselves.
|
|
220
|
+
* That file is rewritten constantly by a running Claude Code, so a read-modify-write from
|
|
221
|
+
* here would race it and could drop unrelated servers. Best-effort throughout: failing to
|
|
222
|
+
* tidy a placeholder must never fail a bind that otherwise succeeded, and `doctor`
|
|
223
|
+
* already reports a shadowed binding if one survives.
|
|
224
|
+
*/
|
|
225
|
+
function clearShadowingPlaceholder(scope) {
|
|
226
|
+
const dir = process.cwd();
|
|
227
|
+
// We wrote claude-local, so a folder placeholder is now harmless (local wins). We wrote
|
|
228
|
+
// the folder, so a claude-local placeholder would take every call — that is the one to
|
|
229
|
+
// clear.
|
|
230
|
+
if (scope === "local")
|
|
231
|
+
return;
|
|
232
|
+
if (!hasClaudeLocalPlaceholder(dir))
|
|
233
|
+
return;
|
|
234
|
+
const r = spawnSync("claude", ["mcp", "remove", "retasc", "-s", "local"], { encoding: "utf8" });
|
|
235
|
+
if (r.error || r.status !== 0) {
|
|
236
|
+
console.log(" Note: a credential-less `retasc` entry is still registered in Claude Code's local scope\n" +
|
|
237
|
+
" and will shadow this one. Remove it with `claude mcp remove retasc -s local`.");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
console.log("✓ Removed the placeholder `retasc` entry that would have shadowed this binding.");
|
|
241
|
+
}
|
|
210
242
|
/**
|
|
211
243
|
* RTSC-92: wire the SECRET-FREE watchdog marker into this folder. The key lives
|
|
212
244
|
* in the home keystore under `workspaceId`; this writes only the pointer (env
|
|
@@ -231,10 +263,14 @@ export function installMarker(opts) {
|
|
|
231
263
|
if (res.ok) {
|
|
232
264
|
if (!opts.quiet)
|
|
233
265
|
console.log(`✓ Registered Retasc watchdog (secret-free marker, scope: ${scope}).`);
|
|
266
|
+
clearShadowingPlaceholder(scope);
|
|
234
267
|
return { where: `Claude Code (${scope})`, wroteFile: false };
|
|
235
268
|
}
|
|
236
269
|
// Always ./.mcp.json, whatever scope was asked for, so always the shared form.
|
|
237
270
|
const path = writeProjectMcpJson(mcpMarkerEntry(opts.workspaceId, shared));
|
|
271
|
+
// We just wrote the folder marker, whatever scope was asked for, so a claude-local
|
|
272
|
+
// placeholder would now outrank it.
|
|
273
|
+
clearShadowingPlaceholder("project");
|
|
238
274
|
if (!opts.quiet) {
|
|
239
275
|
console.log(`✓ Wrote secret-free watchdog marker to ${path}`);
|
|
240
276
|
console.log(` ${fallbackNote(res)}`);
|
package/dist/config.js
CHANGED
|
@@ -5,9 +5,11 @@ import { randomUUID } from "node:crypto";
|
|
|
5
5
|
// Production defaults. Overridable via env for dev/testing.
|
|
6
6
|
// RETASC_DEPLOYMENT_URL — Convex deployment (.cloud) for management calls
|
|
7
7
|
// RETASC_MCP_URL — the MCP endpoint agents connect to
|
|
8
|
+
// RETASC_DASH_URL — the Dash, for the browser sign-in round trip (RTSC-691)
|
|
8
9
|
export const DEFAULTS = {
|
|
9
10
|
deploymentUrl: process.env.RETASC_DEPLOYMENT_URL ?? "https://unique-lyrebird-934.convex.cloud",
|
|
10
11
|
mcpUrl: process.env.RETASC_MCP_URL ?? "https://mcp.retasc.com/mcp",
|
|
12
|
+
dashUrl: process.env.RETASC_DASH_URL ?? "https://dash.retasc.com",
|
|
11
13
|
};
|
|
12
14
|
/** The dir config lives in. RETASC_DIR overrides it (tests, sandboxes) — same
|
|
13
15
|
* knob the keystore reads, so the two stay co-located. */
|
|
@@ -50,6 +52,7 @@ export function loadConfig() {
|
|
|
50
52
|
return {
|
|
51
53
|
deploymentUrl: stored.deploymentUrl ?? DEFAULTS.deploymentUrl,
|
|
52
54
|
mcpUrl: stored.mcpUrl ?? DEFAULTS.mcpUrl,
|
|
55
|
+
dashUrl: stored.dashUrl ?? DEFAULTS.dashUrl,
|
|
53
56
|
token: stored.token,
|
|
54
57
|
refreshToken: stored.refreshToken,
|
|
55
58
|
user: stored.user,
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { selfCommand, versionStamp } from "./lib/launcher.js";
|
|
|
5
5
|
import { loadConfig, patchConfig, saveConfig, configPath, isLoggedIn } from "./config.js";
|
|
6
6
|
import { installMcp, normalizeScope } from "./commands/mcp.js";
|
|
7
7
|
import { installGate, resolveGatePrefix } from "./commands/gate.js";
|
|
8
|
-
import { claimAction } from "./commands/claim.js";
|
|
8
|
+
import { claimAction, releaseAction } from "./commands/claim.js";
|
|
9
9
|
import { bindAction, setupFromToken } from "./commands/bind.js";
|
|
10
10
|
import { joinAction } from "./commands/join.js";
|
|
11
11
|
import { chooseInviteOrg, chooseInviteProjects } from "./commands/invite.js";
|
|
@@ -195,6 +195,13 @@ program
|
|
|
195
195
|
// RTSC-495 — the agent's door. Everything this command normally asks was already
|
|
196
196
|
// answered in the Dash, so the token stands in for all of it and nothing is prompted.
|
|
197
197
|
.option("--setup <token>", "Complete setup from a Dash setup code — no sign-in, no prompts")
|
|
198
|
+
// RTSC-713 — the OTHER agent door, and the one that needs no Dash trip first.
|
|
199
|
+
//
|
|
200
|
+
// Three behaviours, deliberately on one flag because none is useful alone: NDJSON
|
|
201
|
+
// outcomes on stdout, the browser door without a TTY, and continuable states reported
|
|
202
|
+
// at exit 0 instead of thrown. Structured output an agent cannot act on, or a door
|
|
203
|
+
// that leads to a thrown Error, is each worse than neither.
|
|
204
|
+
.option("--json", "Report machine-readable outcomes (for an agent driving setup)")
|
|
198
205
|
.action(async (opts) => {
|
|
199
206
|
// BEFORE requireLogin: the whole point is a machine that has never signed in. The
|
|
200
207
|
// token is the authorization, and asking for a session here would refuse every
|
|
@@ -593,6 +600,13 @@ program
|
|
|
593
600
|
// Normalize --only to the canonical uppercase id so it matches the branch ids
|
|
594
601
|
// scan() derives (rtsc-181/… → RTSC-181), same as `done` does for --id.
|
|
595
602
|
tidyAction({ ...opts, only: opts.only ? String(opts.only).toUpperCase() : undefined }).catch(fail));
|
|
603
|
+
program
|
|
604
|
+
.command("release [issue]")
|
|
605
|
+
.description("Hand a claimed issue back to the queue. Leaves your worktree and branch alone.")
|
|
606
|
+
.option("--id <RTSC-NN>", "The issue to release (or pass it positionally)")
|
|
607
|
+
.requiredOption("--claim-token <token>", "The claim token printed when you claimed it (the server fences on it)")
|
|
608
|
+
.option("--note <text>", "Handoff note for whoever picks it up next")
|
|
609
|
+
.action((issueArg, opts) => releaseAction({ issueArg, ...opts }).catch(fail));
|
|
596
610
|
program
|
|
597
611
|
.command("done")
|
|
598
612
|
.description("Mark the current issue (rtsc-NN/ branch, or --id) done and tear down its worktree+branch.")
|
package/dist/lib/binding.js
CHANGED
|
@@ -119,6 +119,55 @@ export function readShadowedBinding(dir) {
|
|
|
119
119
|
return undefined;
|
|
120
120
|
return parseServerEntry(folderEntry(dir), "folder");
|
|
121
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Is this a PLACEHOLDER entry — a bare URL with no credential of any kind (RTSC-691)?
|
|
124
|
+
*
|
|
125
|
+
* RTSC-678's flow starts by pointing the client at `https://mcp.retasc.com/mcp` with
|
|
126
|
+
* nothing else in it, so the server can answer the handshake with setup instructions.
|
|
127
|
+
* That entry is not a binding: it cannot authenticate, and every tool call through it
|
|
128
|
+
* fails. It exists to be replaced by the real one `bind` writes.
|
|
129
|
+
*
|
|
130
|
+
* It has to be recognised so it can be REMOVED, because scope precedence would otherwise
|
|
131
|
+
* make it win. `claude mcp add -t http` writes Claude-local scope by default; a `bind
|
|
132
|
+
* --scope project` writes `./.mcp.json`; and claude-local beats the folder marker at
|
|
133
|
+
* runtime (see the precedence note at the top of this file). Left in place, the finished
|
|
134
|
+
* setup would sit in a file nothing reads while the credential-less entry took every
|
|
135
|
+
* call — a setup that looks done and works nowhere.
|
|
136
|
+
*
|
|
137
|
+
* Deliberately narrow: a `url` and NO `command`, no env credential, no Authorization
|
|
138
|
+
* header. Anything carrying a credential, or spawning something, is somebody's real
|
|
139
|
+
* configuration and is never silently removed.
|
|
140
|
+
*/
|
|
141
|
+
export function isPlaceholderEntry(raw, knownUrls = PLACEHOLDER_URLS) {
|
|
142
|
+
if (!raw || typeof raw !== "object")
|
|
143
|
+
return false;
|
|
144
|
+
if (typeof raw.url !== "string" || raw.url.length === 0)
|
|
145
|
+
return false;
|
|
146
|
+
if (typeof raw.command === "string")
|
|
147
|
+
return false;
|
|
148
|
+
if (raw.env && Object.keys(raw.env).length > 0)
|
|
149
|
+
return false;
|
|
150
|
+
// ANY header, not just Authorization. A gateway that injects credentials under some
|
|
151
|
+
// other name is still somebody's real configuration.
|
|
152
|
+
if (raw.headers && Object.keys(raw.headers).length > 0)
|
|
153
|
+
return false;
|
|
154
|
+
// And it must be the bootstrap URL we ourselves tell people to paste. This is the
|
|
155
|
+
// clause that matters: Claude Code stores MCP OAuth tokens OUTSIDE the server entry,
|
|
156
|
+
// keyed by server name, so a fully authorized OAuth binding is byte-identical to a
|
|
157
|
+
// placeholder by shape alone. RTSC-678 — this issue's parent — is about adding exactly
|
|
158
|
+
// that OAuth door, so shape-only matching would have grown a way to silently delete a
|
|
159
|
+
// working binding at the moment we shipped one.
|
|
160
|
+
return knownUrls.includes(normalizeUrl(raw.url));
|
|
161
|
+
}
|
|
162
|
+
/** The bare URLs we hand out for bootstrapping, normalized. */
|
|
163
|
+
const PLACEHOLDER_URLS = ["https://mcp.retasc.com/mcp"];
|
|
164
|
+
function normalizeUrl(u) {
|
|
165
|
+
return u.trim().replace(/\/+$/, "").toLowerCase();
|
|
166
|
+
}
|
|
167
|
+
/** Is there a placeholder in Claude Code's LOCAL scope for this folder? */
|
|
168
|
+
export function hasClaudeLocalPlaceholder(dir) {
|
|
169
|
+
return isPlaceholderEntry(claudeLocalRetascEntry(dir));
|
|
170
|
+
}
|
|
122
171
|
/** A user-scope (global) Retasc server: top-level in Claude Code's config,
|
|
123
172
|
* NOT under projects[dir]. Illegal under DESIGN §13 override #1 — it applies
|
|
124
173
|
* to every folder that has no binding of its own, so issues can land in the
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
/** How long to wait for the human to click Approve. Generous: they may have to sign in
|
|
4
|
+
* to the Dash first, and possibly through GitHub or Google after that. */
|
|
5
|
+
const WAIT_MS = 5 * 60 * 1000;
|
|
6
|
+
/**
|
|
7
|
+
* Open a loopback listener and return the URL to send the browser to, plus a promise
|
|
8
|
+
* that resolves when the browser comes back.
|
|
9
|
+
*
|
|
10
|
+
* Split in two on purpose. The caller needs the URL BEFORE the wait starts, so it can
|
|
11
|
+
* print it — a printed URL is what makes this work when the browser fails to open, which
|
|
12
|
+
* is the common case on a headless Linux box where `xdg-open` is absent.
|
|
13
|
+
*/
|
|
14
|
+
export async function startBrowserLogin(dashUrl) {
|
|
15
|
+
const state = randomBytes(24).toString("hex");
|
|
16
|
+
let resolve;
|
|
17
|
+
let reject;
|
|
18
|
+
const settled = new Promise((res, rej) => {
|
|
19
|
+
resolve = res;
|
|
20
|
+
reject = rej;
|
|
21
|
+
});
|
|
22
|
+
// The timeout below rejects this promise whether or not anyone is awaiting it. A caller
|
|
23
|
+
// that takes `url`/`cancel` and never calls `wait()` would otherwise get an unhandled
|
|
24
|
+
// rejection, which is fatal on Node 15+. Attaching an inert handler at construction
|
|
25
|
+
// makes the promise safe to ignore; `wait()` still sees the real rejection.
|
|
26
|
+
settled.catch(() => { });
|
|
27
|
+
const server = createServer((req, res) => {
|
|
28
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
29
|
+
const code = url.searchParams.get("code");
|
|
30
|
+
const gotState = url.searchParams.get("state");
|
|
31
|
+
// The browser is a person's, so answer it in a way a person can read. Whatever
|
|
32
|
+
// happens here, the tab is finished with.
|
|
33
|
+
const reply = (status, body) => {
|
|
34
|
+
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
|
|
35
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>Retasc</title>` +
|
|
36
|
+
`<body style="font:16px system-ui;padding:3rem;max-width:32rem;margin:auto">${body}</body>`);
|
|
37
|
+
};
|
|
38
|
+
if (!code || gotState !== state) {
|
|
39
|
+
// A mismatched nonce means this redirect did not come from the flow we started.
|
|
40
|
+
// The code is not forwarded, so it expires unspent two minutes later.
|
|
41
|
+
reply(400, "<h1>Sign-in could not be verified</h1><p>Close this tab and run <code>retasc login</code> again.</p>");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
reply(200, "<h1>Signed in</h1><p>You can close this tab and go back to your terminal.</p>");
|
|
45
|
+
resolve({ code });
|
|
46
|
+
});
|
|
47
|
+
await new Promise((res, rej) => {
|
|
48
|
+
server.once("error", rej);
|
|
49
|
+
// Port 0: the OS picks a free one. Hard-coding a port would collide with a second
|
|
50
|
+
// `retasc login` and with whatever else the developer is running.
|
|
51
|
+
server.listen(0, "127.0.0.1", res);
|
|
52
|
+
});
|
|
53
|
+
const port = server.address().port;
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
reject(new Error(`TIMEOUT: no response from the browser within ${WAIT_MS / 60_000} minutes`));
|
|
56
|
+
}, WAIT_MS);
|
|
57
|
+
// Do not hold the process open on this timer alone.
|
|
58
|
+
timer.unref?.();
|
|
59
|
+
const close = () => {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
// `close()` stops accepting but leaves the browser's idle keep-alive socket open, and
|
|
62
|
+
// that handle holds the event loop until Node's 5s keepAliveTimeout fires — so the
|
|
63
|
+
// CLI would appear to hang for five seconds after a successful sign-in.
|
|
64
|
+
server.closeAllConnections?.();
|
|
65
|
+
server.close();
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
url: `${dashUrl.replace(/\/+$/, "")}/cli-auth?port=${port}&state=${encodeURIComponent(state)}`,
|
|
69
|
+
port,
|
|
70
|
+
wait: async () => {
|
|
71
|
+
try {
|
|
72
|
+
return await settled;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
close();
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
cancel: close,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `bind` reports when an AGENT is driving it (RTSC-713).
|
|
3
|
+
*
|
|
4
|
+
* # Why this exists
|
|
5
|
+
*
|
|
6
|
+
* An agent decides whether to keep going from three signals, and all three are
|
|
7
|
+
* structural rather than semantic: the exit code, which stream the output came on, and
|
|
8
|
+
* whether the text reads like a fault. Before this module, "signed in, session written,
|
|
9
|
+
* nothing named yet" fired all three:
|
|
10
|
+
*
|
|
11
|
+
* ✗ UNAUTHENTICATED: Not signed in. Run `npx -y @retasc/cli@1.30.0 login` first
|
|
12
|
+
* (no TTY here for the device flow).
|
|
13
|
+
*
|
|
14
|
+
* An agent that stops there is reading the signals correctly. The signals were wrong.
|
|
15
|
+
* Sign-in had succeeded, the session was on disk, and the only thing left was a question
|
|
16
|
+
* it could have asked its human in one sentence.
|
|
17
|
+
*
|
|
18
|
+
* So the rule this module enforces: **the exit code and the framing encode "can I
|
|
19
|
+
* continue?", not "did something unexpected happen?"** A state the flow can be resumed
|
|
20
|
+
* from exits 0 on stdout and never says error, however incomplete it is. Only a genuine
|
|
21
|
+
* dead end — nothing the agent or its human can do from here — exits non-zero.
|
|
22
|
+
*
|
|
23
|
+
* # Why not just word the instruction better
|
|
24
|
+
*
|
|
25
|
+
* Telling the agent "if you see UNAUTHENTICATED it is fine, keep going" asks a model to
|
|
26
|
+
* override the strongest stop signal it has on the strength of a sentence it read
|
|
27
|
+
* earlier. Sometimes it will and sometimes it will not, and which one is not something
|
|
28
|
+
* this codebase controls. Exiting 0 is deterministic. This is the same reasoning that
|
|
29
|
+
* put the claim nudge and the setup instructions in the server response rather than in
|
|
30
|
+
* CLAUDE.md: a mechanism beats advice.
|
|
31
|
+
*
|
|
32
|
+
* # Why the states live HERE and not in the server's instruction string
|
|
33
|
+
*
|
|
34
|
+
* The obvious alternative was a lookup table in `convex/lib/setupInstructions.ts`: "if
|
|
35
|
+
* the CLI says X do this, if it says Y do that." That is a second copy of this list,
|
|
36
|
+
* living in a different language, in a different deploy cycle, maintained by whoever
|
|
37
|
+
* touches it next. They diverge, and the divergence is invisible until an agent acts on
|
|
38
|
+
* a state that no longer exists.
|
|
39
|
+
*
|
|
40
|
+
* This repo has already paid for that once. `setupInstructions.ts` opens by explaining
|
|
41
|
+
* it was lifted out of `http.ts` "because it now has TWO readers and they must never
|
|
42
|
+
* diverge", and RTSC-694 was an entire issue spent collapsing one setup command into one
|
|
43
|
+
* place. So: the CLI owns its states and each one carries its own next step, and the
|
|
44
|
+
* server instruction says only "run this, act on what it tells you." Adding a state
|
|
45
|
+
* costs one change, in this file, and no server string can go stale.
|
|
46
|
+
*
|
|
47
|
+
* There is a test that greps for these code strings under `convex/`. If one ever appears
|
|
48
|
+
* there, the split has been broken.
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* Every terminal outcome of an agent-driven `bind`, in two classes.
|
|
52
|
+
*
|
|
53
|
+
* CONTINUABLE — the flow is alive and wants something. Exit 0, stdout, no fault
|
|
54
|
+
* vocabulary. The agent reads it, asks its human the one question that applies, and runs
|
|
55
|
+
* the next pass.
|
|
56
|
+
*
|
|
57
|
+
* TERMINAL — nothing the agent or its human can do inside this flow. Exit non-zero and
|
|
58
|
+
* say so plainly, because stopping is genuinely the right move.
|
|
59
|
+
*
|
|
60
|
+
* Note where NEEDS_SIGN_IN sits. Reaching a command with no session, having not yet
|
|
61
|
+
* tried to get one, is CONTINUABLE: the answer is to sign in, not to give up. Only a
|
|
62
|
+
* sign-in that was attempted and failed is terminal. Collapsing those two was the
|
|
63
|
+
* original bug.
|
|
64
|
+
*/
|
|
65
|
+
export const CONTINUABLE_STATES = [
|
|
66
|
+
/** No session, and no attempt has been made yet. The browser door is available. */
|
|
67
|
+
"NEEDS_SIGN_IN",
|
|
68
|
+
/** Signed in. Zero orgs, or more than one, so which org this folder is for is unknown. */
|
|
69
|
+
"NEEDS_ORG",
|
|
70
|
+
/** Org settled. Zero projects, or more than one, so which project is unknown. */
|
|
71
|
+
"NEEDS_PROJECT",
|
|
72
|
+
/**
|
|
73
|
+
* Signed in, and holding an invitation. The choice is join-or-create and it is the
|
|
74
|
+
* human's to make.
|
|
75
|
+
*
|
|
76
|
+
* NOT called PENDING_INVITE, deliberately: `convex/setupStatus.ts` already has a state
|
|
77
|
+
* by that name and it means something else — a membership offer the server is
|
|
78
|
+
* surfacing, rather than the reason `bind` stopped. Two different things sharing one
|
|
79
|
+
* identifier across the boundary is precisely the confusion this file's split exists to
|
|
80
|
+
* prevent, so this one is named for the decision it is asking for.
|
|
81
|
+
*/
|
|
82
|
+
"NEEDS_JOIN_OR_CREATE",
|
|
83
|
+
/** Everything named and written. The folder is bound; the client must restart. */
|
|
84
|
+
"BOUND",
|
|
85
|
+
];
|
|
86
|
+
export const TERMINAL_STATES = [
|
|
87
|
+
/** Sign-in was attempted and did not complete: declined, timed out, or rejected. */
|
|
88
|
+
"SIGN_IN_FAILED",
|
|
89
|
+
/** No door exists: no browser reachable and no TTY. SSH and CI land here. */
|
|
90
|
+
"NO_SIGN_IN_DOOR",
|
|
91
|
+
/** This folder is already bound and the run was refused rather than silently re-pointed. */
|
|
92
|
+
"ALREADY_BOUND",
|
|
93
|
+
/** The credential was refused: revoked key, suspended member, deleted workspace. */
|
|
94
|
+
"REFUSED",
|
|
95
|
+
];
|
|
96
|
+
export function isContinuable(state) {
|
|
97
|
+
return CONTINUABLE_STATES.includes(state);
|
|
98
|
+
}
|
|
99
|
+
/** stdout, one line, no trailing prose. Never stderr: see the module header. */
|
|
100
|
+
export function emit(e) {
|
|
101
|
+
process.stdout.write(JSON.stringify(e) + "\n");
|
|
102
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retasc/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.31.0",
|
|
4
4
|
"description": "Retasc CLI \u2014 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": {
|