@retasc/cli 1.7.2 → 1.9.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.
@@ -0,0 +1,200 @@
1
+ import { api, formatError } from "../api.js";
2
+ import { loadConfig } from "../config.js";
3
+ import { parseInviteCode } from "../lib/invite.js";
4
+ import { selfCommand } from "../lib/launcher.js";
5
+ import { clean } from "../lib/text.js";
6
+ import { VERSION } from "../version.js";
7
+ import { ask, confirm, ensureSignedIn, isInteractive, pickExisting, rebindGuard, completeWorkspaceSetup, } from "./bind.js";
8
+ /** A ghost whose origin predates `sourceLabel`. Never invent a tool we don't know. */
9
+ const UNKNOWN_SOURCE = "another tool";
10
+ /**
11
+ * A stop that cannot loop forever.
12
+ *
13
+ * The loop is driven by a server list that shrinks on every claim, so it terminates on its
14
+ * own — but it is a `while (true)` around a network call, and a backend that ever returned
15
+ * an unchanged list would spin a human's terminal rather than fail. Well above any real
16
+ * number of migrated tools.
17
+ */
18
+ const MAX_IDENTITY_ROUNDS = 10;
19
+ /** How one row reads: the person, and the tool they were carried in from. */
20
+ function ghostRow(g) {
21
+ // BOTH fields are third-party text: the name was typed by someone in ClickUp/Jira/Asana
22
+ // and carried across verbatim, and `sourceLabel` is the adapter's own record of where.
23
+ // They print into a terminal that acts on escape sequences, right above a prompt.
24
+ return `${clean(g.name)} from ${clean(g.sourceLabel ?? UNKNOWN_SOURCE)}`;
25
+ }
26
+ /**
27
+ * "Which of these imported people is you?" — the CLI half of RTSC-433/473.
28
+ *
29
+ * Mirrors the Dash notice (`dash/src/components/GhostClaim.tsx`) deliberately, down to the
30
+ * confirm wording in `dash/src/lib/ghostPrompt.ts`. Two surfaces asking the same
31
+ * irreversible question in two different voices is how someone ends up answering the one
32
+ * they trust less.
33
+ *
34
+ * Never fatal. Everything here is about attribution; the bind that follows is what makes
35
+ * the folder work, and a failure to offer a claim must not cost someone their workspace.
36
+ */
37
+ export async function identityLoop(orgId, opts, orgLabel,
38
+ // Injected so the question itself can be pinned by tests. It is asked once per human per
39
+ // org and it is irreversible, so "we ran it live and it looked right" is not evidence —
40
+ // the live orgs available to us have all already answered, which exercises only the
41
+ // silent path. What has to be provable is the loud one.
42
+ deps = {}) {
43
+ const d = {
44
+ claimableGhosts: api.claimableGhosts,
45
+ claimGhost: api.claimGhost,
46
+ dismissGhostPrompt: api.dismissGhostPrompt,
47
+ askFn: ask,
48
+ confirmFn: confirm,
49
+ interactive: isInteractive,
50
+ ...deps,
51
+ };
52
+ // `--yes` may SKIP this question. It may never answer it: claiming pulls another human's
53
+ // authorship AND their dispatch lane onto your account, irreversibly, and there is no
54
+ // CLI path back. A scripted run has nobody to be wrong on behalf of.
55
+ if (!d.interactive() || opts.yes)
56
+ return;
57
+ for (let round = 0; round < MAX_IDENTITY_ROUNDS; round++) {
58
+ let ghosts;
59
+ try {
60
+ const res = await d.claimableGhosts({ orgId });
61
+ if (res.asked || !res.ghosts.length)
62
+ return;
63
+ ghosts = res.ghosts;
64
+ }
65
+ catch (e) {
66
+ const { message } = formatError(e);
67
+ console.error(` ! Couldn't check for imported history (${message}). Carrying on.`);
68
+ return;
69
+ }
70
+ console.log(round === 0
71
+ ? `\nWhen ${orgLabel ?? "this org"} migrated, some people were carried across.\n` +
72
+ "One of them may be you:"
73
+ : "\nAnything else here you recognise?");
74
+ // "Don't ask again" is not decoration. Declining writes `ghostPromptDismissedAt` on the
75
+ // member row and `claimGhost` refuses for good afterwards, org-wide — so a bare "none
76
+ // of these are me" would read as "none of these ClickUp ones" and quietly close the
77
+ // door on a second tool's placeholder too.
78
+ // Compared by REFERENCE below, not by a magic id value: a sentinel that is merely a
79
+ // row with an empty id would let any future ghost with a falsy id read as a decline —
80
+ // and a decline is permanent.
81
+ const NONE = { id: "", name: "None of these are me (don't ask again)", sourceLabel: null };
82
+ // No label: the heading above is the prompt, and `choose` would print a second one.
83
+ const chosen = await pickExisting("", [...ghosts, NONE], (g) => (g === NONE ? g.name : ghostRow(g)), d.askFn);
84
+ if (chosen === NONE) {
85
+ try {
86
+ await d.dismissGhostPrompt({ orgId });
87
+ }
88
+ catch (e) {
89
+ console.error(` ! ${formatError(e).message}`);
90
+ }
91
+ return;
92
+ }
93
+ // Confirm before claiming — it is irreversible and it moves dispatch routing, so it
94
+ // must not be a single keystroke on a row in a list. Same qualitative wording as the
95
+ // Dash: counting "12 issues, 4 comments" would mean an org-wide scan on exactly the
96
+ // orgs that just ingested a migration.
97
+ const from = clean(chosen.sourceLabel ?? UNKNOWN_SOURCE);
98
+ const question = `\n Link "${clean(chosen.name)}" to your account? Everything they wrote, commented on,\n` +
99
+ ` or were assigned in ${from} becomes yours. This can't be undone.`;
100
+ if (!(await d.confirmFn(question))) {
101
+ // NOT a dismissal. They declined THIS row, which is not the same as "none of these
102
+ // are me" — and dismissing is permanent, so it only ever happens when said outright.
103
+ console.log(" Left unlinked.");
104
+ continue;
105
+ }
106
+ try {
107
+ const res = await d.claimGhost({ orgId, memberId: chosen.id });
108
+ console.log(`✓ Linked ${clean(res.name)}.`);
109
+ }
110
+ catch (e) {
111
+ // SOURCE_ALREADY_CLAIMED, IMPORT_RUNNING, NOT_CLAIMABLE — all readable, all reachable
112
+ // by racing the Dash, and none of them a reason to abandon the setup.
113
+ const { code, message, hint } = formatError(e);
114
+ console.error(` ✗ ${code ? `${code}: ` : ""}${message}`);
115
+ if (hint)
116
+ console.error(` → ${hint}`);
117
+ return;
118
+ }
119
+ // Claiming one ClickUp identity removes EVERY remaining ClickUp row (`claimableGhosts`
120
+ // filters by spent source), so the next round can only ever offer a different tool.
121
+ }
122
+ }
123
+ export async function joinAction(link, opts) {
124
+ // Client-side, before anything else: the code is what the server matches, and someone
125
+ // who pasted the wrong thing should hear it here rather than as "not recognized".
126
+ const code = parseInviteCode(link);
127
+ await ensureSignedIn();
128
+ // --- 2. redeem ------------------------------------------------------------
129
+ // Any failure here stops the run. A dead invite means there is no org to bind to, and
130
+ // binding this folder to nothing would be worse than stopping.
131
+ const res = (await api.acceptInvite({ code }));
132
+ const orgLabel = res.slug ? clean(res.slug) : undefined;
133
+ const where = orgLabel ? ` "${orgLabel}"` : "";
134
+ if (res.alreadyMember) {
135
+ // Not a failure: the code stays unconsumed by design, so the person it was meant for
136
+ // can still use it. Say so and carry on into the folder half.
137
+ console.log(`• You're already a member of org${where} (${res.role}).`);
138
+ }
139
+ else {
140
+ console.log(`✓ Joined org${where} as a ${res.role}.`);
141
+ }
142
+ if (opts.bind === false) {
143
+ // `--no-bind` is redeem-only, exactly as `join` behaved before RTSC-492 — including
144
+ // asking nothing, so anything scripted against it sees the same output and the same
145
+ // exit code. The identity prompt still reaches them through the Dash.
146
+ return;
147
+ }
148
+ // --- 3. identity ----------------------------------------------------------
149
+ // BEFORE anything about this folder, including the re-bind guard. Ghosts are rows in
150
+ // `members`, which carries an `orgId` and no `projectId`: the question belongs to the org
151
+ // just joined, and this is the only time `join` asks it. Gating it behind the folder half
152
+ // would mean someone whose folder is already bound elsewhere — who declines the re-bind —
153
+ // silently never sees it, and their imported history stays stranded.
154
+ await identityLoop(res.orgId, opts, orgLabel);
155
+ // --- the folder half ------------------------------------------------------
156
+ const cfg = loadConfig();
157
+ const self = selfCommand(VERSION);
158
+ const resume = `${self} bind --org-id ${res.orgId}`;
159
+ const guard = await rebindGuard({
160
+ cwd: process.cwd(),
161
+ mcpUrl: cfg.mcpUrl,
162
+ orgId: res.orgId,
163
+ projectId: opts.projectId,
164
+ yes: opts.yes,
165
+ });
166
+ if (!guard.proceed) {
167
+ // The membership landed either way — never let a declined or no-op re-bind read as a
168
+ // failed join. `rebindGuard` has already said what it did with the folder, INCLUDING
169
+ // setting a non-zero exit code for the non-interactive refusal, so don't paper over
170
+ // that with a line that reads like everything is finished.
171
+ if (process.exitCode)
172
+ console.error(` You did join org${where}; only the folder was left alone.`);
173
+ else
174
+ console.log(` You're a member of org${where}. Nothing else to do here.`);
175
+ return;
176
+ }
177
+ try {
178
+ await completeWorkspaceSetup({
179
+ orgId: res.orgId,
180
+ orgLabel,
181
+ opts,
182
+ existing: guard.existing,
183
+ // `createProject` is `requireOwner`, and an invite only ever grants `member`. Offering
184
+ // "+ create new" here would be offering a refusal dressed as a choice.
185
+ canCreateProject: false,
186
+ });
187
+ }
188
+ catch (e) {
189
+ // Say what SUCCEEDED before reporting the failure, and name the command that resumes.
190
+ // The membership (and any identity claim) already landed and cannot be re-done: the
191
+ // invite is consumed now, so re-running `join` with the same link would report
192
+ // "already used" and read like the whole thing failed.
193
+ console.error(`\n You did join org${where} — only this folder's setup didn't finish.`);
194
+ console.error(` Resume with: ${resume}`);
195
+ throw e;
196
+ }
197
+ if (isInteractive()) {
198
+ console.log("\nStart your agent in this folder and it'll pull from the queue.");
199
+ }
200
+ }
@@ -1,6 +1,8 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { resolveLauncher, launcherNote, portableLauncher, } from "../lib/launcher.js";
5
+ import { VERSION } from "../version.js";
4
6
  export const SERVER_NAME = "retasc";
5
7
  /** Normalize a user-supplied scope string. `user` (global) is refused and
6
8
  * downgraded to `local`, loudly — per-folder binding is the only right way. */
@@ -32,31 +34,31 @@ export function mcpConfigBlock(url, key) {
32
34
  * claimed issues' leases alive automatically. url+key go via env (not a header,
33
35
  * since this is a spawned command). Harness-agnostic: any stdio MCP client works.
34
36
  */
35
- export function mcpProxyEntry(url, key) {
37
+ export function mcpProxyEntry(url, key, launcher) {
36
38
  return {
37
- command: "retasc",
38
- args: ["mcp-proxy"],
39
+ command: launcher.command,
40
+ args: [...launcher.args, "mcp-proxy"],
39
41
  env: { RETASC_MCP_URL: url, RETASC_MCP_KEY: key },
40
42
  };
41
43
  }
42
44
  /** Copy-pasteable watchdog block for Codex / OpenCode / any stdio MCP client. */
43
- export function mcpProxyConfigBlock(url, key) {
44
- return JSON.stringify({ mcpServers: { [SERVER_NAME]: mcpProxyEntry(url, key) } }, null, 2);
45
+ export function mcpProxyConfigBlock(url, key, launcher) {
46
+ return JSON.stringify({ mcpServers: { [SERVER_NAME]: mcpProxyEntry(url, key, launcher) } }, null, 2);
45
47
  }
46
48
  /**
47
49
  * RTSC-92: the SECRET-FREE watchdog marker. No key — only the opaque workspace
48
50
  * id, which the proxy resolves to a key through the home keystore. Safe to
49
51
  * commit; doubles as the "this repo is a Retasc workspace" marker.
50
52
  */
51
- export function mcpMarkerEntry(workspaceId) {
53
+ export function mcpMarkerEntry(workspaceId, launcher) {
52
54
  return {
53
- command: "retasc",
54
- args: ["mcp-proxy"],
55
+ command: launcher.command,
56
+ args: [...launcher.args, "mcp-proxy"],
55
57
  env: { RETASC_WORKSPACE: workspaceId },
56
58
  };
57
59
  }
58
- export function mcpMarkerConfigBlock(workspaceId) {
59
- return JSON.stringify({ mcpServers: { [SERVER_NAME]: mcpMarkerEntry(workspaceId) } }, null, 2);
60
+ export function mcpMarkerConfigBlock(workspaceId, launcher) {
61
+ return JSON.stringify({ mcpServers: { [SERVER_NAME]: mcpMarkerEntry(workspaceId, launcher) } }, null, 2);
60
62
  }
61
63
  // --- `claude mcp add` argv builders (pure, exported for tests) -------------
62
64
  // RTSC-99: the stdio builders put SERVER_NAME *before* the variadic `--env`
@@ -75,7 +77,7 @@ export function buildHttpAddArgs(url, key, scope) {
75
77
  ];
76
78
  }
77
79
  /** Build argv for the stdio watchdog proxy (RTSC-44). */
78
- export function buildWatchdogAddArgs(url, key, scope) {
80
+ export function buildWatchdogAddArgs(url, key, scope, launcher) {
79
81
  return [
80
82
  "mcp", "add",
81
83
  SERVER_NAME,
@@ -83,28 +85,28 @@ export function buildWatchdogAddArgs(url, key, scope) {
83
85
  "--scope", scope,
84
86
  "--env", `RETASC_MCP_URL=${url}`,
85
87
  "--env", `RETASC_MCP_KEY=${key}`,
86
- "--", "retasc", "mcp-proxy",
88
+ "--", launcher.command, ...launcher.args, "mcp-proxy",
87
89
  ];
88
90
  }
89
91
  /** Build argv for the secret-free stdio marker (RTSC-92). */
90
- export function buildMarkerAddArgs(workspaceId, scope) {
92
+ export function buildMarkerAddArgs(workspaceId, scope, launcher) {
91
93
  return [
92
94
  "mcp", "add",
93
95
  SERVER_NAME,
94
96
  "--transport", "stdio",
95
97
  "--scope", scope,
96
98
  "--env", `RETASC_WORKSPACE=${workspaceId}`,
97
- "--", "retasc", "mcp-proxy",
99
+ "--", launcher.command, ...launcher.args, "mcp-proxy",
98
100
  ];
99
101
  }
100
102
  function tryClaudeCli(url, key, scope) {
101
103
  return runClaudeAdd(buildHttpAddArgs(url, key, scope));
102
104
  }
103
- function tryClaudeCliWatchdog(url, key, scope) {
104
- return runClaudeAdd(buildWatchdogAddArgs(url, key, scope));
105
+ function tryClaudeCliWatchdog(url, key, scope, launcher) {
106
+ return runClaudeAdd(buildWatchdogAddArgs(url, key, scope, launcher));
105
107
  }
106
- function tryClaudeCliMarker(workspaceId, scope) {
107
- return runClaudeAdd(buildMarkerAddArgs(workspaceId, scope));
108
+ function tryClaudeCliMarker(workspaceId, scope, launcher) {
109
+ return runClaudeAdd(buildMarkerAddArgs(workspaceId, scope, launcher));
108
110
  }
109
111
  function runClaudeAdd(args) {
110
112
  const r = spawnSync("claude", args, { encoding: "utf8" });
@@ -167,17 +169,23 @@ export function installMcp(opts) {
167
169
  const scope = opts.scope ?? "local";
168
170
  // Watchdog mode (RTSC-44): wire the stdio proxy so claims stay alive automatically.
169
171
  if (opts.watchdog) {
170
- const res = tryClaudeCliWatchdog(opts.url, opts.key, scope);
172
+ // Only the stdio forms spawn a command; the plain HTTP entry below carries a URL and
173
+ // needs nothing installed, so resolving is scoped to the branch that depends on it.
174
+ const resolved = resolveLauncher({ version: VERSION, install: opts.install });
175
+ const note = launcherNote(resolved);
176
+ if (note)
177
+ console.log(note);
178
+ const res = tryClaudeCliWatchdog(opts.url, opts.key, scope, resolved.launcher);
171
179
  if (res.ok) {
172
180
  console.log(`✓ Registered Retasc with the liveness watchdog (stdio proxy, scope: ${scope}).`);
173
181
  }
174
182
  else {
175
- const path = writeProjectMcpJson(mcpProxyEntry(opts.url, opts.key));
183
+ const path = writeProjectMcpJson(mcpProxyEntry(opts.url, opts.key, resolved.launcher));
176
184
  console.log(`✓ Wrote watchdog MCP config to ${path}`);
177
185
  console.log(` ${fallbackNote(res)}`);
178
186
  }
179
187
  console.log("\nWatchdog MCP config (Codex / OpenCode / any stdio MCP client):\n");
180
- console.log(mcpProxyConfigBlock(opts.url, opts.key));
188
+ console.log(mcpProxyConfigBlock(opts.url, opts.key, resolved.launcher));
181
189
  console.log("\nThe watchdog keeps your claims alive automatically — no per-claim heartbeats. If it ever isn't running you just fall back to the normal lease timeout (safe).");
182
190
  return;
183
191
  }
@@ -201,16 +209,30 @@ export function installMcp(opts) {
201
209
  */
202
210
  export function installMarker(opts) {
203
211
  const scope = opts.scope ?? "local";
204
- const res = tryClaudeCliMarker(opts.workspaceId, scope);
212
+ // RTSC-493: resolve BEFORE writing anything. The marker names a command something else
213
+ // will spawn later, so it has to name one that has been proved to run on this machine.
214
+ const resolved = opts.launcher ?? resolveLauncher({ version: VERSION, install: opts.install });
215
+ // Only announce a resolve we did ourselves — a caller that passed one in already printed
216
+ // its note at the point in ITS sequence where the install actually happened.
217
+ const note = opts.launcher ? null : launcherNote(resolved);
218
+ if (note)
219
+ console.log(note);
220
+ // `local` scope lands in this machine's own Claude config, where the absolute path is
221
+ // both correct and fastest. `project` scope, and the ./.mcp.json fallback below, are
222
+ // SHARED — a path under this user's home directory would leak their username into a
223
+ // committed file and break every teammate who clones it. See portableLauncher.
224
+ const shared = portableLauncher(resolved, VERSION);
225
+ const res = tryClaudeCliMarker(opts.workspaceId, scope, scope === "project" ? shared : resolved.launcher);
205
226
  if (res.ok) {
206
227
  console.log(`✓ Registered Retasc watchdog (secret-free marker, scope: ${scope}).`);
207
228
  }
208
229
  else {
209
- const path = writeProjectMcpJson(mcpMarkerEntry(opts.workspaceId));
230
+ // Always ./.mcp.json, whatever scope was asked for, so always the shared form.
231
+ const path = writeProjectMcpJson(mcpMarkerEntry(opts.workspaceId, shared));
210
232
  console.log(`✓ Wrote secret-free watchdog marker to ${path}`);
211
233
  console.log(` ${fallbackNote(res)}`);
212
234
  }
213
235
  console.log("\nMarker block (any stdio MCP client) — no secret, safe to commit:\n");
214
- console.log(mcpMarkerConfigBlock(opts.workspaceId));
236
+ console.log(mcpMarkerConfigBlock(opts.workspaceId, shared));
215
237
  console.log("\nThe key lives in your home keystore (~/.retasc/bindings.json), not in the repo.");
216
238
  }
package/dist/index.js CHANGED
@@ -1,13 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
- import { readFileSync } from "node:fs";
4
- import { fileURLToPath } from "node:url";
5
- import { dirname, join } from "node:path";
3
+ import { VERSION } from "./version.js";
6
4
  import { loadConfig, patchConfig, saveConfig, configPath, isLoggedIn } from "./config.js";
7
5
  import { installMcp, normalizeScope } from "./commands/mcp.js";
8
6
  import { installGate } from "./commands/gate.js";
9
7
  import { claimAction } from "./commands/claim.js";
10
8
  import { bindAction } from "./commands/bind.js";
9
+ import { joinAction } from "./commands/join.js";
11
10
  import { doctorAction } from "./commands/doctor.js";
12
11
  import { billingAction } from "./commands/billing.js";
13
12
  import { isNetworkError, readLocalBinding, resolveBinding } from "./lib/binding.js";
@@ -15,16 +14,11 @@ import { tidyAction, doneAction } from "./commands/tidy.js";
15
14
  import { runProxy } from "./proxy.js";
16
15
  import { deviceLogin } from "./auth.js";
17
16
  import { api, formatError } from "./api.js";
18
- // Single source of truth for the version: read package.json at runtime from the
19
- // compiled file's location (dist/index.js -> ../package.json). A JSON import won't
20
- // work here — tsconfig has rootDir "src", so importing ../package.json is outside
21
- // rootDir and fails tsc.
22
- const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"));
23
17
  const program = new Command();
24
18
  program
25
19
  .name("retasc")
26
20
  .description("Retasc — sign in, create projects, mint agent API keys, and wire your agent to the MCP server.")
27
- .version(pkg.version);
21
+ .version(VERSION);
28
22
  function requireLogin() {
29
23
  if (!isLoggedIn()) {
30
24
  console.error("Not signed in. Run `retasc login` first.");
@@ -327,7 +321,11 @@ members
327
321
  const res = (await api.createInvite({ orgId: opts.orgId, expiresInDays: opts.expiresDays }));
328
322
  console.log(`✓ Invite code: ${res.code}`);
329
323
  console.log(` Expires ${new Date(res.expiresAt).toISOString().slice(0, 10)}. Single-use — shown once.`);
330
- console.log(` Share it; they run: retasc login && retasc join ${res.code}`);
324
+ // RTSC-492: one command, and one that needs nothing installed first. `join` signs
325
+ // them in, binds the folder and wires their agent, so this must not tell them to run
326
+ // `retasc login` first — nor name a `retasc` binary they don't have yet.
327
+ console.log(` Share it. In the folder their agent works in, they run:`);
328
+ console.log(` npx @retasc/cli join ${res.code}`);
331
329
  }
332
330
  catch (e) {
333
331
  fail(e);
@@ -361,25 +359,23 @@ members
361
359
  fail(e);
362
360
  }
363
361
  });
362
+ // RTSC-492 — the WHOLE of an invited teammate's setup, in one command run from the folder
363
+ // their agent will work in: sign in, redeem, claim any imported history, pick the project,
364
+ // make `retasc` durable, mint a key, bind the folder and wire the MCP marker. It signs
365
+ // itself in (no `requireLogin` gate) because "run this, but run the other one first" is
366
+ // not a single instruction.
364
367
  program
365
368
  .command("join")
366
- .description("Redeem an invite code to join an org (as the signed-in GitHub user).")
367
- .argument("<code>", "The invite code you were given")
368
- .action(async (code) => {
369
- requireLogin();
370
- try {
371
- const res = (await api.acceptInvite({ code }));
372
- const where = res.slug ? ` "${res.slug}"` : "";
373
- if (res.alreadyMember) {
374
- console.log(`• You're already a member of org${where} (${res.role}). Nothing to do.`);
375
- }
376
- else {
377
- console.log(`✓ Joined org${where} as ${res.role}. It'll show up in \`retasc whoami\`.`);
378
- }
379
- }
380
- catch (e) {
381
- fail(e);
382
- }
369
+ .description("Join an org from an invite link and set this folder up completely one command.")
370
+ .argument("<link>", "The invite link you were given (or just the rtscinv_… code)")
371
+ .option("--no-bind", "Redeem only — don't set this folder up")
372
+ .option("--project-id <id>", "Which project to bind to (skips the picker)")
373
+ .option("--agent <name>", "Agent member name (default: auto)")
374
+ .option("--runtime <runtime>", "Agent runtime", "claude-code")
375
+ .option("-y, --yes", "Don't prompt to replace an existing binding, and skip the identity question")
376
+ .allowExcessArguments(false)
377
+ .action(async (link, opts) => {
378
+ await joinAction(link, opts).catch(fail);
383
379
  });
384
380
  // --- mcp wiring ------------------------------------------------------------
385
381
  const mcp = program.command("mcp").description("Wire the Retasc MCP server into your agent.");
@@ -26,13 +26,17 @@ function parseServerEntry(s, source) {
26
26
  if (!s)
27
27
  return undefined;
28
28
  // Canonical (RTSC-92): secret-free marker → resolve the key from the keystore.
29
+ // RTSC-493: the spawned command, when this is one of the stdio forms.
30
+ const spawn = typeof s.command === "string"
31
+ ? { command: s.command, args: Array.isArray(s.args) ? s.args.map(String) : [] }
32
+ : {};
29
33
  const workspaceId = s.env?.RETASC_WORKSPACE;
30
34
  if (workspaceId) {
31
35
  const entry = getBinding(workspaceId);
32
36
  if (entry)
33
- return { key: entry.key, url: entry.url, watchdog: true, workspaceId, source };
37
+ return { key: entry.key, url: entry.url, watchdog: true, workspaceId, source, ...spawn };
34
38
  // Marker present but no keystore entry (e.g. a cloned repo) → needs binding.
35
- return { key: "", url: "", watchdog: true, workspaceId, markerOnly: true, source };
39
+ return { key: "", url: "", watchdog: true, workspaceId, markerOnly: true, source, ...spawn };
36
40
  }
37
41
  // Legacy inline-key forms (pre-RTSC-92): key in env or the Authorization header.
38
42
  if (s.env?.RETASC_MCP_KEY) {
@@ -42,6 +46,7 @@ function parseServerEntry(s, source) {
42
46
  watchdog: true,
43
47
  legacy: true,
44
48
  source,
49
+ ...spawn,
45
50
  };
46
51
  }
47
52
  const auth = s.headers?.Authorization ?? s.headers?.authorization;
@@ -0,0 +1,38 @@
1
+ // RTSC-492 — turn whatever the person was handed into the code the server matches.
2
+ //
3
+ // An invite reaches someone as a LINK (`https://dash.retasc.com/join#rtscinv_…`), and a
4
+ // link is what they paste. `acceptInvite` matches an exact string, and it should stay that
5
+ // way: normalising a paste is a property of the edge that accepts typed input, not of the
6
+ // mutation that grants membership. So the unwrapping happens here.
7
+ /** Every invite code the product has ever minted starts with this. */
8
+ const INVITE_PREFIX = "rtscinv_";
9
+ /**
10
+ * The invite code inside a link, a bare code, or a code someone pasted with prose
11
+ * around it.
12
+ *
13
+ * One rule rather than a parser: find the first token that LOOKS like an invite code,
14
+ * anywhere in the input. That covers the fragment (`…/join#rtscinv_x`), a query string
15
+ * (`?code=rtscinv_x`), a path segment, an email client's `<…>` wrapper and a trailing
16
+ * period from a sentence, without needing to know which of those the sender used — and
17
+ * without the URL-shape guessing that gets one of them wrong.
18
+ *
19
+ * A token that isn't our shape is still passed through UNCHANGED as long as it can't be a
20
+ * link. A future code format then keeps working with no CLI release, and the server's
21
+ * "That invite code isn't recognized" stays the authority on what's valid. Something
22
+ * link-shaped with no code in it is the one case we refuse locally, because forwarding a
23
+ * URL as a code can only ever produce that same message with none of the diagnosis.
24
+ */
25
+ export function parseInviteCode(input) {
26
+ const raw = String(input ?? "").trim();
27
+ // Hex today, but accept the wider token charset a future format might use rather than
28
+ // pinning this to `[0-9a-f]{48}` and having to ship a release to read a new code.
29
+ const match = raw.match(new RegExp(`${INVITE_PREFIX}[A-Za-z0-9_-]+`));
30
+ if (match)
31
+ return match[0];
32
+ const bare = raw.replace(/^[<"'(]+|[>"'.,)]+$/g, "");
33
+ if (bare && !/[\s/:]/.test(bare))
34
+ return bare;
35
+ throw new Error(raw
36
+ ? "That doesn't contain an invite code. Paste the whole invite link, or just the `rtscinv_…` code."
37
+ : "No invite code given. Pass the invite link, or just the `rtscinv_…` code.");
38
+ }