@retasc/cli 1.8.0 → 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.
package/dist/api.js CHANGED
@@ -25,6 +25,12 @@ const fns = {
25
25
  billingStatus: makeFunctionReference("manage:billingStatus"),
26
26
  chargeHistory: makeFunctionReference("manage:chargeHistory"),
27
27
  orgPayments: makeFunctionReference("manage:orgPayments"),
28
+ // Imported-identity claim (RTSC-433/473), wired for `join` by RTSC-492 and shared with
29
+ // RTSC-477's standalone `retasc identity`. Member-gated already — nothing was widened
30
+ // server-side for the CLI, and nothing should be.
31
+ claimableGhosts: makeFunctionReference("ghosts:claimableGhosts"),
32
+ claimGhost: makeFunctionReference("ghosts:claimGhost"),
33
+ dismissGhostPrompt: makeFunctionReference("ghosts:dismissGhostPrompt"),
28
34
  };
29
35
  function client() {
30
36
  const cfg = loadConfig();
@@ -66,6 +72,20 @@ export function formatError(e) {
66
72
  .trim();
67
73
  return { message };
68
74
  }
75
+ /**
76
+ * Fail the same way the backend does (RTSC-492).
77
+ *
78
+ * `formatError` truncates a bare Error to its FIRST line, so a multi-line message loses
79
+ * everything after the diagnosis — including the command that fixes it. Some of the stops
80
+ * the CLI has to make are its own (an org with no projects the caller may not create),
81
+ * with no server call to produce a `userError`, and they deserve the same code + message +
82
+ * hint rendering as one that came off the wire.
83
+ */
84
+ export function cliError(code, message, hint) {
85
+ const e = new Error(`${code}: ${message}`);
86
+ e.data = { code, message, hint };
87
+ throw e;
88
+ }
69
89
  // Does this error mean "the access token is missing/expired", i.e. a refresh
70
90
  // might fix it? The access-token JWT lives ~1h, so any long-lived login trips
71
91
  // this. Two shapes surface: our server functions throw `UNAUTHENTICATED …`
@@ -139,4 +159,7 @@ export const api = {
139
159
  billingStatus: (args) => withAuth(() => client().query(fns.billingStatus, args)),
140
160
  chargeHistory: (args) => withAuth(() => client().query(fns.chargeHistory, args)),
141
161
  orgPayments: (args) => withAuth(() => client().action(fns.orgPayments, args)),
162
+ claimableGhosts: (args) => withAuth(() => client().query(fns.claimableGhosts, args)),
163
+ claimGhost: (args) => withAuth(() => client().mutation(fns.claimGhost, args)),
164
+ dismissGhostPrompt: (args) => withAuth(() => client().mutation(fns.dismissGhostPrompt, args)),
142
165
  };
@@ -1,15 +1,18 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { stdin, stdout } from "node:process";
3
- import { api } from "../api.js";
3
+ import { api, cliError } from "../api.js";
4
4
  import { deviceLogin } from "../auth.js";
5
5
  import { loadConfig } from "../config.js";
6
6
  import { installMarker } from "./mcp.js";
7
7
  import { readLocalBinding, resolveBinding } from "../lib/binding.js";
8
8
  import { getBinding, setBinding, newWorkspaceId } from "../lib/keystore.js";
9
- function isInteractive() {
9
+ import { resolveLauncher, launcherNote, selfCommand } from "../lib/launcher.js";
10
+ import { clean } from "../lib/text.js";
11
+ import { VERSION } from "../version.js";
12
+ export function isInteractive() {
10
13
  return Boolean(stdin.isTTY && stdout.isTTY);
11
14
  }
12
- async function ask(question) {
15
+ export async function ask(question) {
13
16
  const rl = createInterface({ input: stdin, output: stdout });
14
17
  try {
15
18
  // readline's question() NEVER settles when stdin hits EOF (Ctrl-D, or a
@@ -24,7 +27,7 @@ async function ask(question) {
24
27
  rl.close();
25
28
  }
26
29
  }
27
- async function confirm(question, assumeYes) {
30
+ export async function confirm(question, assumeYes) {
28
31
  if (assumeYes)
29
32
  return true;
30
33
  if (!isInteractive())
@@ -33,8 +36,10 @@ async function confirm(question, assumeYes) {
33
36
  return a === "y" || a === "yes";
34
37
  }
35
38
  /**
36
- * Pick from a list interactively. Returns the chosen item, or undefined when the
37
- * user explicitly picks the trailing "create new" option.
39
+ * Render a numbered menu and read one answer, optionally with a trailing extra option.
40
+ *
41
+ * Returns the chosen item, or `undefined` when the trailing option was taken. When there
42
+ * is no trailing option, `undefined` is impossible — every valid answer names an item.
38
43
  *
39
44
  * Invalid input re-prompts rather than falling through: undefined used to mean
40
45
  * BOTH "chose create-new" and "typed garbage", so a typo like `1)` silently
@@ -44,26 +49,55 @@ async function confirm(question, assumeYes) {
44
49
  * "4.0", which would route those straight to create-new — the same
45
50
  * non-canonical-input-hits-the-destructive-branch bug in a new coat.
46
51
  */
47
- export async function pick(label, items, render, askFn = ask) {
48
- if (!items.length)
49
- return undefined;
50
- console.log(`\n${label}:`);
52
+ async function choose(label, items, render, askFn, extra) {
53
+ // An empty label means the caller already printed its own heading, and a menu is being
54
+ // appended to it — used by the identity prompt, whose lede is two lines of its own.
55
+ if (label)
56
+ console.log(`\n${label}:`);
51
57
  items.forEach((it, i) => console.log(` ${i + 1}) ${render(it)}`));
52
- const createNew = items.length + 1;
53
- console.log(` ${createNew}) + create new`);
58
+ const extraSlot = items.length + 1;
59
+ if (extra)
60
+ console.log(` ${extraSlot}) ${extra}`);
61
+ const highest = extra ? extraSlot : items.length;
54
62
  for (let attempt = 0; attempt < 3; attempt++) {
55
63
  const a = await askFn("Choose a number: ");
56
64
  if (/^\d+$/.test(a)) {
57
65
  const n = Number(a);
58
66
  if (n >= 1 && n <= items.length)
59
67
  return items[n - 1];
60
- if (n === createNew)
68
+ if (extra && n === extraSlot)
61
69
  return undefined;
62
70
  }
63
- console.log(`Please enter a number between 1 and ${createNew}.`);
71
+ console.log(`Please enter a number between 1 and ${highest}.`);
64
72
  }
65
73
  throw new Error("no valid choice — aborting");
66
74
  }
75
+ /**
76
+ * Pick from a list interactively. Returns the chosen item, or undefined when the
77
+ * user explicitly picks the trailing "create new" option.
78
+ */
79
+ export async function pick(label, items, render, askFn = ask) {
80
+ if (!items.length)
81
+ return undefined;
82
+ return choose(label, items, render, askFn, "+ create new");
83
+ }
84
+ /**
85
+ * Pick from a list with NO create option (RTSC-492).
86
+ *
87
+ * `pick` appends "+ create new" unconditionally, which is right for `bind` — the person
88
+ * running it is setting up their own org and is its owner. It is wrong for someone who
89
+ * just accepted an invite: `createProject` is `requireOwner`, so offering the slot to a
90
+ * member is offering them a refusal. An option that can only fail is worse than no option,
91
+ * because it reads as permission.
92
+ *
93
+ * Never returns undefined, so the caller has no "or else" branch to get wrong. An empty
94
+ * list is the caller's problem to explain, not something to render as a menu of nothing.
95
+ */
96
+ export async function pickExisting(label, items, render, askFn = ask) {
97
+ if (!items.length)
98
+ throw new Error("nothing to choose from");
99
+ return (await choose(label, items, render, askFn, null));
100
+ }
67
101
  /**
68
102
  * The message to show when `bind` aborts mid-run, or null when there's nothing
69
103
  * to say. `bind` commits a new org server-side before the project step; if a
@@ -83,64 +117,225 @@ export function strandRecoveryHint(args) {
83
117
  `but has no project or key yet — re-run to finish:\n` +
84
118
  ` retasc bind --org-id ${orgId}`);
85
119
  }
86
- export async function bindAction(opts) {
87
- // RTSC-481 sign in first when there is no session at all.
88
- //
89
- // `withAuth` deliberately refuses to start a device flow for someone who was
90
- // never signed in: on an arbitrary call that would be a surprise browser prompt
91
- // under a misleading "session expired". That reasoning holds for every OTHER
92
- // command and is left alone. It does not hold here, because bind is the single
93
- // instruction the Dash hands a brand-new owner, and "run this, but run the other
94
- // one first" is not a single instruction. So the sign-in is explicit, announced,
95
- // and only when a human is present to authorize it.
96
- if (!loadConfig().token) {
97
- if (!isInteractive()) {
98
- throw new Error("Not signed in. Run `retasc login` first (no TTY here for the device flow).");
99
- }
100
- console.error("Not signed in yet authenticating with GitHub first…");
101
- await deviceLogin();
102
- }
103
- const cfg = loadConfig();
104
- const cwd = process.cwd();
105
- // --- loud on re-bind -------------------------------------------------------
120
+ /**
121
+ * "This folder is already bound replace it?" (RTSC-262/RTSC-91)
122
+ *
123
+ * Extracted from `bindAction` so `join` inherits it verbatim rather than growing a second,
124
+ * quieter re-bind path (RTSC-492). Silently rebinding someone's working folder is the one
125
+ * outcome neither command may produce, and two implementations of that rule is one too
126
+ * many.
127
+ *
128
+ * `proceed: false` means STOP either the caller declined, or the requested binding is
129
+ * already in place and there is nothing to do. The exit code for the non-interactive
130
+ * refusal is set here, at the point that knows why.
131
+ *
132
+ * Both callers run this before doing any work that commits state: `bind` before it can
133
+ * create an org, `join` before it mints a key. Neither runs it before the invite is
134
+ * redeemed, because joining an org is not a property of this folder.
135
+ */
136
+ export async function rebindGuard(args) {
106
137
  // markerOnly (a cloned repo's committed marker, or an entry the CLI can't
107
138
  // read a key from) never gates: there is no usable binding to "replace", and
108
139
  // minting a key under the existing marker id is exactly what bind is FOR.
109
- const existing = readLocalBinding(cwd);
110
- if (existing && !existing.markerOnly) {
111
- // Idempotent converge (RTSC-262): re-running bind with the SAME target is
112
- // success, not a refusal a provisioning script must be able to run
113
- // `retasc bind --org-id X --project-id Y` repeatedly without churning keys.
114
- if (existing.workspaceId && opts.orgId && opts.projectId) {
115
- const cur = getBinding(existing.workspaceId);
116
- if (cur && cur.orgId === opts.orgId && cur.projectId === opts.projectId) {
117
- console.log(`Already bound to the requested org/project (${cur.prefix ?? cur.projectId}). Nothing to do.`);
118
- return;
119
- }
140
+ const existing = readLocalBinding(args.cwd);
141
+ if (!existing || existing.markerOnly)
142
+ return { proceed: true, existing: existing ?? null };
143
+ // Idempotent converge (RTSC-262): re-running with the SAME target is success, not a
144
+ // refusal — a provisioning script must be able to run `retasc bind --org-id X
145
+ // --project-id Y` repeatedly without churning keys.
146
+ if (existing.workspaceId && args.orgId && args.projectId) {
147
+ const cur = getBinding(existing.workspaceId);
148
+ if (cur && cur.orgId === args.orgId && cur.projectId === args.projectId) {
149
+ console.log(`Already bound to the requested org/project (${cur.prefix ?? cur.projectId}). Nothing to do.`);
150
+ return { proceed: false, existing };
120
151
  }
121
- let where = "an existing Retasc binding";
122
- try {
123
- const b = await resolveBinding(existing.url || cfg.mcpUrl, existing.key);
124
- where = `org "${b.org.name}" / project ${b.project.prefix}`;
152
+ }
153
+ let where = "an existing Retasc binding";
154
+ try {
155
+ const b = await resolveBinding(existing.url || args.mcpUrl, existing.key);
156
+ where = `org "${clean(b.org.name)}" / project ${clean(b.project.prefix)}`;
157
+ }
158
+ catch {
159
+ /* key may be stale/revoked — still warn before replacing */
160
+ }
161
+ console.log(`This folder is already bound to ${where}.`);
162
+ if (await confirm("Replace it?", args.yes))
163
+ return { proceed: true, existing };
164
+ // RTSC-262: with no TTY, confirm() answers "no" on its own — and the
165
+ // widened binding lookup makes an existing binding the COMMON case. A
166
+ // provisioning script must not be told success (exit 0) for a no-op.
167
+ if (!isInteractive()) {
168
+ console.error("✗ refusing to replace the existing binding (a DIFFERENT org/project) non-interactively — pass --yes.");
169
+ process.exitCode = 1;
170
+ }
171
+ else {
172
+ console.log("Left unchanged.");
173
+ }
174
+ return { proceed: false, existing };
175
+ }
176
+ /**
177
+ * Everything from "which project" to a working folder: pick the project, make `retasc`
178
+ * durable, mint a key for that (org, project), write the binding, and wire the marker.
179
+ *
180
+ * Shared by `bind` and `join` (RTSC-492) rather than copied. The two commands differ only
181
+ * in how they arrive at an org and in whether the caller may create a project; the tail is
182
+ * the same act, and two copies of it would drift — the marker-writing half in particular
183
+ * has already had to be fixed once (RTSC-493) and must never need fixing twice.
184
+ *
185
+ * ORDER. The durable-`retasc` step runs BEFORE the mint, not as a side effect of writing
186
+ * the marker afterwards. A cold `npm i -g` takes tens of seconds and can fail outright; a
187
+ * key minted before it is a live credential stranded behind a step that may never finish.
188
+ * Resolving first also means the launcher is known to every message printed after it.
189
+ */
190
+ export async function completeWorkspaceSetup(args) {
191
+ const { orgId, opts, existing } = args;
192
+ const cfg = loadConfig();
193
+ const cwd = process.cwd();
194
+ const org = args.orgLabel ? `"${clean(args.orgLabel)}"` : "this org";
195
+ // --- resolve project (pick / flag / create) --------------------------------
196
+ let projectId = opts.projectId;
197
+ let prefix;
198
+ if (!projectId && opts.project && opts.prefix) {
199
+ if (!args.canCreateProject) {
200
+ cliError("FORBIDDEN", "Only an owner can create a project.", `Ask an owner to add one, then pass --project-id <id>.`);
125
201
  }
126
- catch {
127
- /* key may be stale/revoked — still warn before replacing */
202
+ const p = (await api.createProject({ orgId, name: opts.project, prefix: opts.prefix }));
203
+ projectId = p.projectId;
204
+ prefix = p.prefix;
205
+ console.log(`✓ Created project ${p.prefix}.`);
206
+ }
207
+ if (!projectId) {
208
+ const { projects } = (await api.listProjects({ orgId }));
209
+ const list = projects ?? [];
210
+ if (!args.canCreateProject) {
211
+ // A member cannot create one, so an empty org is a dead end here — and it has to say
212
+ // what to ask for, by name. A bare FORBIDDEN from `createProject` would be both
213
+ // wrong (they never asked to create anything) and unactionable.
214
+ if (!list.length) {
215
+ cliError("NO_PROJECTS", `Org ${org} has no projects yet, and only an owner can create one.`, `Ask an owner to add a project, then finish this folder with: ${selfCommand(VERSION)} bind --org-id ${orgId}`);
216
+ }
217
+ // One project is not a choice. Asking anyway would be the only question in the
218
+ // common invite path, for an answer that was never in doubt.
219
+ if (list.length === 1) {
220
+ projectId = list[0].id;
221
+ prefix = list[0].prefix;
222
+ }
223
+ else if (isInteractive()) {
224
+ const chosen = await pickExisting("Select a project", list, (p) => `${clean(p.prefix)} — ${clean(p.name)}`);
225
+ projectId = chosen.id;
226
+ prefix = chosen.prefix;
227
+ }
228
+ else {
229
+ cliError("AMBIGUOUS", `Org ${org} has ${list.length} projects, so one has to be named.`, `Pass --project-id <id> (or run interactively): ${list.map((p) => `${p.prefix}=${p.id}`).join(", ")}`);
230
+ }
128
231
  }
129
- console.log(`This folder is already bound to ${where}.`);
130
- if (!(await confirm("Replace it?", opts.yes))) {
131
- // RTSC-262: with no TTY, confirm() answers "no" on its own — and the
132
- // widened binding lookup makes an existing binding the COMMON case. A
133
- // provisioning script must not be told success (exit 0) for a no-op.
134
- if (!isInteractive()) {
135
- console.error("✗ refusing to replace the existing binding (a DIFFERENT org/project) non-interactively — pass --yes.");
136
- process.exitCode = 1;
232
+ else if (isInteractive()) {
233
+ const chosen = await pick("Select a project", list, (p) => `${clean(p.prefix)} — ${clean(p.name)}`);
234
+ if (chosen) {
235
+ projectId = chosen.id;
236
+ prefix = chosen.prefix;
137
237
  }
138
238
  else {
139
- console.log("Left unchanged.");
239
+ const name = await ask("New project name: ");
240
+ const pfx = (await ask("Project prefix (e.g. ACME): ")).toUpperCase();
241
+ if (!name || !pfx)
242
+ throw new Error("project name and prefix required");
243
+ const p = (await api.createProject({ orgId, name, prefix: pfx }));
244
+ projectId = p.projectId;
245
+ prefix = p.prefix;
246
+ console.log(`✓ Created project ${p.prefix}.`);
140
247
  }
141
- return;
142
248
  }
249
+ else if (list.length === 1) {
250
+ projectId = list[0].id;
251
+ prefix = list[0].prefix;
252
+ }
253
+ else {
254
+ throw new Error("no project selected — pass --project-id <id>, or --project <name> --prefix <PFX>.");
255
+ }
256
+ }
257
+ // --- make `retasc` durable BEFORE anything is committed (RTSC-493) ---------
258
+ // The marker names a command something else spawns on every agent start, so it has to
259
+ // name one proved to run on this machine. Resolved (and announced) here so the install
260
+ // can't land after a key exists, and so every later message knows what to tell the user
261
+ // to type.
262
+ const launcher = resolveLauncher({ version: VERSION });
263
+ const note = launcherNote(launcher);
264
+ if (note)
265
+ console.log(note);
266
+ // --- mint a key for THIS (org, project) and wire the watchdog into THIS folder
267
+ const minted = (await api.mintKey({
268
+ orgId,
269
+ projectId: projectId,
270
+ agentName: opts.agent,
271
+ runtime: opts.runtime ?? "claude-code",
272
+ keyName: prefix ? `${prefix} key` : undefined,
273
+ }));
274
+ console.log(`✓ Minted key for this workspace (${minted.key.slice(0, 14)}…).\n`);
275
+ // RTSC-92: the secret stays OUT of the repo. Store it in the home keystore
276
+ // keyed by a workspace id; reuse this folder's existing id (so a re-bind, or a
277
+ // teammate's committed marker, keeps the same id) or mint a fresh one.
278
+ const workspaceId = existing?.workspaceId ?? newWorkspaceId();
279
+ setBinding(workspaceId, {
280
+ orgId,
281
+ projectId: projectId,
282
+ key: minted.key,
283
+ url: cfg.mcpUrl,
284
+ prefix,
285
+ orgName: args.orgLabel,
286
+ boundPath: cwd,
287
+ createdAt: Date.now(),
288
+ });
289
+ args.onBound?.();
290
+ // Per-folder only (local scope), always watchdog. The marker carries only the
291
+ // workspace id — no secret — so ./.mcp.json is safe to commit.
292
+ installMarker({ workspaceId, scope: "local", launcher });
293
+ // Confirm the binding the same way the agent will see it.
294
+ try {
295
+ const b = await resolveBinding(cfg.mcpUrl, minted.key);
296
+ console.log(`\n✓ This folder is bound to org "${clean(b.org.name)}" / project ${clean(b.project.prefix)}.\n` +
297
+ ` Agents launched here can only ever read or write ${clean(b.project.prefix)}.`);
298
+ }
299
+ catch {
300
+ /* binding written; whoami confirmation is best-effort */
143
301
  }
302
+ }
303
+ /**
304
+ * Sign in first when there is no session at all (RTSC-481, extended to `join` by RTSC-492).
305
+ *
306
+ * `withAuth` deliberately refuses to start a device flow for someone who was
307
+ * never signed in: on an arbitrary call that would be a surprise browser prompt
308
+ * under a misleading "session expired". That reasoning holds for every OTHER
309
+ * command and is left alone. It does not hold for the two commands that are the WHOLE
310
+ * instruction someone is handed — `bind` for a new owner, `join` for an invited teammate —
311
+ * because "run this, but run the other one first" is not a single instruction. So the
312
+ * sign-in is explicit, announced, and only when a human is present to authorize it.
313
+ */
314
+ export async function ensureSignedIn() {
315
+ if (loadConfig().token)
316
+ return;
317
+ if (!isInteractive()) {
318
+ throw new Error("Not signed in. Run `retasc login` first (no TTY here for the device flow).");
319
+ }
320
+ console.error("Not signed in yet — authenticating with GitHub first…");
321
+ await deviceLogin();
322
+ }
323
+ export async function bindAction(opts) {
324
+ await ensureSignedIn();
325
+ const cfg = loadConfig();
326
+ // --- loud on re-bind -------------------------------------------------------
327
+ // Runs before the org step on purpose: `--org-name` commits an org server-side, and a
328
+ // run that is about to be refused must not leave one behind (RTSC-297).
329
+ const guard = await rebindGuard({
330
+ cwd: process.cwd(),
331
+ mcpUrl: cfg.mcpUrl,
332
+ orgId: opts.orgId,
333
+ projectId: opts.projectId,
334
+ yes: opts.yes,
335
+ });
336
+ if (!guard.proceed)
337
+ return;
338
+ const existing = guard.existing;
144
339
  // --- resolve org (pick / flag / create) ------------------------------------
145
340
  // Track whether THIS run created the org (vs. selected a pre-existing one or
146
341
  // took one via --org-id). An org is created and committed server-side BEFORE
@@ -187,79 +382,16 @@ export async function bindAction(opts) {
187
382
  // the org this run, name it and print the resume command (RTSC-297).
188
383
  let bindingWritten = false;
189
384
  try {
190
- // --- resolve project (pick / flag / create) --------------------------------
191
- let projectId = opts.projectId;
192
- let prefix;
193
- if (!projectId && opts.project && opts.prefix) {
194
- const p = (await api.createProject({ orgId: orgId, name: opts.project, prefix: opts.prefix }));
195
- projectId = p.projectId;
196
- prefix = p.prefix;
197
- console.log(`✓ Created project ${p.prefix}.`);
198
- }
199
- if (!projectId) {
200
- const { projects } = (await api.listProjects({ orgId: orgId }));
201
- const list = projects ?? [];
202
- if (isInteractive()) {
203
- const chosen = await pick("Select a project", list, (p) => `${p.prefix} — ${p.name}`);
204
- if (chosen) {
205
- projectId = chosen.id;
206
- prefix = chosen.prefix;
207
- }
208
- else {
209
- const name = await ask("New project name: ");
210
- const pfx = (await ask("Project prefix (e.g. ACME): ")).toUpperCase();
211
- if (!name || !pfx)
212
- throw new Error("project name and prefix required");
213
- const p = (await api.createProject({ orgId: orgId, name, prefix: pfx }));
214
- projectId = p.projectId;
215
- prefix = p.prefix;
216
- console.log(`✓ Created project ${p.prefix}.`);
217
- }
218
- }
219
- else if (list.length === 1) {
220
- projectId = list[0].id;
221
- prefix = list[0].prefix;
222
- }
223
- else {
224
- throw new Error("no project selected — pass --project-id <id>, or --project <name> --prefix <PFX>.");
225
- }
226
- }
227
- // --- mint a key for THIS (org, project) and wire the watchdog into THIS folder
228
- const minted = (await api.mintKey({
229
- orgId: orgId,
230
- projectId: projectId,
231
- agentName: opts.agent,
232
- runtime: opts.runtime ?? "claude-code",
233
- keyName: prefix ? `${prefix} key` : undefined,
234
- }));
235
- console.log(`✓ Minted key for this workspace (${minted.key.slice(0, 14)}…).\n`);
236
- // RTSC-92: the secret stays OUT of the repo. Store it in the home keystore
237
- // keyed by a workspace id; reuse this folder's existing id (so a re-bind, or a
238
- // teammate's committed marker, keeps the same id) or mint a fresh one.
239
- const workspaceId = existing?.workspaceId ?? newWorkspaceId();
240
- setBinding(workspaceId, {
385
+ await completeWorkspaceSetup({
241
386
  orgId: orgId,
242
- projectId: projectId,
243
- key: minted.key,
244
- url: cfg.mcpUrl,
245
- prefix,
246
- orgName: undefined,
247
- boundPath: cwd,
248
- createdAt: Date.now(),
387
+ opts,
388
+ existing,
389
+ // `bind` is the OWNER's command — creating a project is the point of its picker.
390
+ canCreateProject: true,
391
+ onBound: () => {
392
+ bindingWritten = true; // strand window closed — the org is now reachable
393
+ },
249
394
  });
250
- bindingWritten = true; // strand window closed — the org is now reachable
251
- // Per-folder only (local scope), always watchdog. The marker carries only the
252
- // workspace id — no secret — so ./.mcp.json is safe to commit.
253
- installMarker({ workspaceId, scope: "local" });
254
- // Confirm the binding the same way the agent will see it.
255
- try {
256
- const b = await resolveBinding(cfg.mcpUrl, minted.key);
257
- console.log(`\n✓ This folder is bound to org "${b.org.name}" / project ${b.project.prefix}.\n` +
258
- ` Agents launched here can only ever read or write ${b.project.prefix}.`);
259
- }
260
- catch {
261
- /* binding written; whoami confirmation is best-effort */
262
- }
263
395
  }
264
396
  catch (err) {
265
397
  const hint = strandRecoveryHint({ createdOrgThisRun, orgId, bindingWritten });
@@ -2,6 +2,7 @@ import { loadConfig } from "../config.js";
2
2
  import { claudeConfigPath, isNetworkError, readGlobalBinding, readLocalBinding, readShadowedBinding, resolveBinding, sameIdentity, } from "../lib/binding.js";
3
3
  import { getBinding } from "../lib/keystore.js";
4
4
  import { runsOk } from "../lib/launcher.js";
5
+ import { clean } from "../lib/text.js";
5
6
  // RTSC-91 (DESIGN §13): `retasc doctor` — confirm THIS folder is correctly and
6
7
  // safely bound. The question a human actually has is "which org/project does
7
8
  // this folder talk to?", so the healthy answer is ONE line naming them.
@@ -20,7 +21,8 @@ const bad = (m) => console.log(` ✗ ${m}`);
20
21
  // Server- and config-derived strings end up inside doctor's verdict lines; a
21
22
  // hostile endpoint (reachable via a config-supplied legacy url) must not be
22
23
  // able to smuggle ANSI escapes into the very output people trust for ✓/✗.
23
- const clean = (s) => String(s).replace(/[\x00-\x1f\x7f]/g, " ");
24
+ // Shared with join/bind see lib/text.ts for why anything off the wire is sanitized
25
+ // before it reaches a terminal (RTSC-492).
24
26
  /**
25
27
  * RTSC-493: can the registered entry actually be STARTED on this machine?
26
28
  *
@@ -43,6 +45,39 @@ export function launcherVerdict(local, probe = runsOk) {
43
45
  shown: clean([local.command, ...probeArgs].join(" ")),
44
46
  };
45
47
  }
48
+ /**
49
+ * RTSC-498: macOS is the only platform this CLI is tested on. That was true and written
50
+ * down nowhere, which left someone on Linux or Windows unable to tell their own mistake
51
+ * from a bug from a platform we never targeted.
52
+ *
53
+ * It says so HERE and not in the README because the README is read by everyone, and for
54
+ * the majority on macOS a standing disclaimer about platforms they aren't using is pure
55
+ * noise. `doctor` is the one surface that already knows which machine it's on, so the
56
+ * people it concerns are exactly the people who see it. On darwin this returns null and
57
+ * nothing is printed.
58
+ *
59
+ * Deliberately NOT a ✗ or a ! — this is context, not a fault in the folder being checked,
60
+ * and it must not read as "your setup is broken".
61
+ */
62
+ export function platformNote(platform = process.platform) {
63
+ if (platform === "darwin")
64
+ return null;
65
+ // Windows and Linux are the two we've actually committed to reaching. Everything else
66
+ // (freebsd, aix, sunos…) is untested AND unplanned, so it must not be told its support
67
+ // is "on the list" — that would be exactly the unearned promise this note exists to
68
+ // avoid making. It still gets the note; it just doesn't get the roadmap sentence.
69
+ const planned = platform === "win32" ? "Windows" : platform === "linux" ? "Linux" : null;
70
+ // `clean` for the same reason every other interpolation in this file uses it: nothing
71
+ // reaches a verdict line able to smuggle ANSI escapes. `process.platform` is a Node
72
+ // build-time constant today, but this is an exported function with a caller-supplied
73
+ // parameter, and the safe version costs nothing.
74
+ const name = planned ?? clean(platform);
75
+ const note = `Note: ${name} is not a platform we test. macOS is the only one we support today.\n` +
76
+ ` Much of the CLI is plain Node and should work fine here, but nothing on this\n` +
77
+ ` platform is exercised, so a failure isn't a regression against a promise we\n` +
78
+ ` made.`;
79
+ return planned ? `${note} Proper ${planned} support is on the list, as bandwidth allows.` : note;
80
+ }
46
81
  function checkLauncher(local) {
47
82
  const v = launcherVerdict(local);
48
83
  if (!v)
@@ -164,4 +199,9 @@ export async function doctorAction() {
164
199
  ` own, so issues can land in the wrong project.\n` +
165
200
  ` Fix: claude mcp remove -s user retasc`);
166
201
  }
202
+ // 3) Ambient context, not a verdict on this folder — so it trails the checks
203
+ // rather than framing them, and on macOS it prints nothing at all.
204
+ const note = platformNote();
205
+ if (note)
206
+ console.log(`\n${note}`);
167
207
  }
@@ -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,7 +1,7 @@
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";
4
+ import { resolveLauncher, launcherNote, portableLauncher, } from "../lib/launcher.js";
5
5
  import { VERSION } from "../version.js";
6
6
  export const SERVER_NAME = "retasc";
7
7
  /** Normalize a user-supplied scope string. `user` (global) is refused and
@@ -211,8 +211,10 @@ export function installMarker(opts) {
211
211
  const scope = opts.scope ?? "local";
212
212
  // RTSC-493: resolve BEFORE writing anything. The marker names a command something else
213
213
  // will spawn later, so it has to name one that has been proved to run on this machine.
214
- const resolved = resolveLauncher({ version: VERSION, install: opts.install });
215
- const note = launcherNote(resolved);
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);
216
218
  if (note)
217
219
  console.log(note);
218
220
  // `local` scope lands in this machine's own Claude config, where the absolute path is
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { installMcp, normalizeScope } from "./commands/mcp.js";
6
6
  import { installGate } from "./commands/gate.js";
7
7
  import { claimAction } from "./commands/claim.js";
8
8
  import { bindAction } from "./commands/bind.js";
9
+ import { joinAction } from "./commands/join.js";
9
10
  import { doctorAction } from "./commands/doctor.js";
10
11
  import { billingAction } from "./commands/billing.js";
11
12
  import { isNetworkError, readLocalBinding, resolveBinding } from "./lib/binding.js";
@@ -320,7 +321,11 @@ members
320
321
  const res = (await api.createInvite({ orgId: opts.orgId, expiresInDays: opts.expiresDays }));
321
322
  console.log(`✓ Invite code: ${res.code}`);
322
323
  console.log(` Expires ${new Date(res.expiresAt).toISOString().slice(0, 10)}. Single-use — shown once.`);
323
- 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}`);
324
329
  }
325
330
  catch (e) {
326
331
  fail(e);
@@ -354,25 +359,23 @@ members
354
359
  fail(e);
355
360
  }
356
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.
357
367
  program
358
368
  .command("join")
359
- .description("Redeem an invite code to join an org (as the signed-in GitHub user).")
360
- .argument("<code>", "The invite code you were given")
361
- .action(async (code) => {
362
- requireLogin();
363
- try {
364
- const res = (await api.acceptInvite({ code }));
365
- const where = res.slug ? ` "${res.slug}"` : "";
366
- if (res.alreadyMember) {
367
- console.log(`• You're already a member of org${where} (${res.role}). Nothing to do.`);
368
- }
369
- else {
370
- console.log(`✓ Joined org${where} as ${res.role}. It'll show up in \`retasc whoami\`.`);
371
- }
372
- }
373
- catch (e) {
374
- fail(e);
375
- }
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);
376
379
  });
377
380
  // --- mcp wiring ------------------------------------------------------------
378
381
  const mcp = program.command("mcp").description("Wire the Retasc MCP server into your agent.");
@@ -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
+ }
@@ -78,6 +78,24 @@ export function portableLauncher(r, version) {
78
78
  return r.launcher;
79
79
  return { command: "npx", args: ["-y", `${PKG}@${version}`] };
80
80
  }
81
+ /**
82
+ * How to spell "run this CLI again", for a message printed BEFORE anything has been made
83
+ * durable (RTSC-492).
84
+ *
85
+ * `join` can stop for reasons that have nothing to do with us — an org with no projects,
86
+ * a mint that failed — and every one of those has to name the command that resumes. Until
87
+ * `resolveLauncher` has run there may be no `retasc` on this machine at all, so telling
88
+ * someone to type `retasc bind …` would be telling them to type a command that does not
89
+ * exist. That is the same defect as a marker naming a missing binary, just aimed at the
90
+ * human instead of the agent.
91
+ *
92
+ * Decided from argv rather than by probing: it costs no subprocess, and it names the form
93
+ * they DEMONSTRABLY have — they just used it. npx unpacks the package into its own `_npx`
94
+ * cache and puts nothing on PATH, so a script path under that cache is proof of an npx run.
95
+ */
96
+ export function selfCommand(version, argv1 = process.argv[1] ?? "") {
97
+ return /[\\/]_npx[\\/]/.test(argv1) ? `npx -y ${PKG}@${version}` : "retasc";
98
+ }
81
99
  /** Install (or upgrade to) an exact version globally. Returns null on success. */
82
100
  function installGlobal(version) {
83
101
  // A cold global install takes seconds with no output of its own. Silence here reads as
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Strip control characters from a string before printing it (RTSC-261, RTSC-492).
3
+ *
4
+ * Anything that came off the wire is untrusted output: it reaches `console.log` in a
5
+ * terminal that acts on escape sequences, so a value carrying `\x1b[…` can move the cursor,
6
+ * clear the line, or overwrite text the user is reading. That matters most where the text
7
+ * sits next to a decision — a name in a confirm prompt could be dressed up to look like the
8
+ * prompt itself.
9
+ *
10
+ * The riskiest strings in the product are the ones this exists for: an imported member's
11
+ * display name is chosen in ClickUp/Jira/Asana by someone who is not our user, carried
12
+ * across by a migration verbatim, and then shown to a human about to make an irreversible
13
+ * choice about it.
14
+ *
15
+ * Mirrors `sanitize` in `convex/lib/userError.ts` and the inline cleanup in `formatError`.
16
+ * The three are one rule; this is the copy the CLI's own printing paths share.
17
+ */
18
+ export function clean(s) {
19
+ // eslint-disable-next-line no-control-regex
20
+ return String(s).replace(/[\x00-\x1f\x7f\u0085\u2028\u2029]/g, " ");
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Retasc CLI — sign in with GitHub, 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": {