@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.
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,14 +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
+ import { deviceLogin } from "../auth.js";
4
5
  import { loadConfig } from "../config.js";
5
6
  import { installMarker } from "./mcp.js";
6
7
  import { readLocalBinding, resolveBinding } from "../lib/binding.js";
7
8
  import { getBinding, setBinding, newWorkspaceId } from "../lib/keystore.js";
8
- 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() {
9
13
  return Boolean(stdin.isTTY && stdout.isTTY);
10
14
  }
11
- async function ask(question) {
15
+ export async function ask(question) {
12
16
  const rl = createInterface({ input: stdin, output: stdout });
13
17
  try {
14
18
  // readline's question() NEVER settles when stdin hits EOF (Ctrl-D, or a
@@ -23,7 +27,7 @@ async function ask(question) {
23
27
  rl.close();
24
28
  }
25
29
  }
26
- async function confirm(question, assumeYes) {
30
+ export async function confirm(question, assumeYes) {
27
31
  if (assumeYes)
28
32
  return true;
29
33
  if (!isInteractive())
@@ -32,8 +36,10 @@ async function confirm(question, assumeYes) {
32
36
  return a === "y" || a === "yes";
33
37
  }
34
38
  /**
35
- * Pick from a list interactively. Returns the chosen item, or undefined when the
36
- * 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.
37
43
  *
38
44
  * Invalid input re-prompts rather than falling through: undefined used to mean
39
45
  * BOTH "chose create-new" and "typed garbage", so a typo like `1)` silently
@@ -43,26 +49,55 @@ async function confirm(question, assumeYes) {
43
49
  * "4.0", which would route those straight to create-new — the same
44
50
  * non-canonical-input-hits-the-destructive-branch bug in a new coat.
45
51
  */
46
- export async function pick(label, items, render, askFn = ask) {
47
- if (!items.length)
48
- return undefined;
49
- 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}:`);
50
57
  items.forEach((it, i) => console.log(` ${i + 1}) ${render(it)}`));
51
- const createNew = items.length + 1;
52
- 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;
53
62
  for (let attempt = 0; attempt < 3; attempt++) {
54
63
  const a = await askFn("Choose a number: ");
55
64
  if (/^\d+$/.test(a)) {
56
65
  const n = Number(a);
57
66
  if (n >= 1 && n <= items.length)
58
67
  return items[n - 1];
59
- if (n === createNew)
68
+ if (extra && n === extraSlot)
60
69
  return undefined;
61
70
  }
62
- console.log(`Please enter a number between 1 and ${createNew}.`);
71
+ console.log(`Please enter a number between 1 and ${highest}.`);
63
72
  }
64
73
  throw new Error("no valid choice — aborting");
65
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
+ }
66
101
  /**
67
102
  * The message to show when `bind` aborts mid-run, or null when there's nothing
68
103
  * to say. `bind` commits a new org server-side before the project step; if a
@@ -82,48 +117,225 @@ export function strandRecoveryHint(args) {
82
117
  `but has no project or key yet — re-run to finish:\n` +
83
118
  ` retasc bind --org-id ${orgId}`);
84
119
  }
85
- export async function bindAction(opts) {
86
- const cfg = loadConfig();
87
- const cwd = process.cwd();
88
- // --- 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) {
89
137
  // markerOnly (a cloned repo's committed marker, or an entry the CLI can't
90
138
  // read a key from) never gates: there is no usable binding to "replace", and
91
139
  // minting a key under the existing marker id is exactly what bind is FOR.
92
- const existing = readLocalBinding(cwd);
93
- if (existing && !existing.markerOnly) {
94
- // Idempotent converge (RTSC-262): re-running bind with the SAME target is
95
- // success, not a refusal a provisioning script must be able to run
96
- // `retasc bind --org-id X --project-id Y` repeatedly without churning keys.
97
- if (existing.workspaceId && opts.orgId && opts.projectId) {
98
- const cur = getBinding(existing.workspaceId);
99
- if (cur && cur.orgId === opts.orgId && cur.projectId === opts.projectId) {
100
- console.log(`Already bound to the requested org/project (${cur.prefix ?? cur.projectId}). Nothing to do.`);
101
- return;
102
- }
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 };
103
151
  }
104
- let where = "an existing Retasc binding";
105
- try {
106
- const b = await resolveBinding(existing.url || cfg.mcpUrl, existing.key);
107
- 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>.`);
108
201
  }
109
- catch {
110
- /* 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
+ }
111
231
  }
112
- console.log(`This folder is already bound to ${where}.`);
113
- if (!(await confirm("Replace it?", opts.yes))) {
114
- // RTSC-262: with no TTY, confirm() answers "no" on its own — and the
115
- // widened binding lookup makes an existing binding the COMMON case. A
116
- // provisioning script must not be told success (exit 0) for a no-op.
117
- if (!isInteractive()) {
118
- console.error("✗ refusing to replace the existing binding (a DIFFERENT org/project) non-interactively — pass --yes.");
119
- 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;
120
237
  }
121
238
  else {
122
- 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}.`);
123
247
  }
124
- return;
125
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)}.`);
126
298
  }
299
+ catch {
300
+ /* binding written; whoami confirmation is best-effort */
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;
127
339
  // --- resolve org (pick / flag / create) ------------------------------------
128
340
  // Track whether THIS run created the org (vs. selected a pre-existing one or
129
341
  // took one via --org-id). An org is created and committed server-side BEFORE
@@ -170,79 +382,16 @@ export async function bindAction(opts) {
170
382
  // the org this run, name it and print the resume command (RTSC-297).
171
383
  let bindingWritten = false;
172
384
  try {
173
- // --- resolve project (pick / flag / create) --------------------------------
174
- let projectId = opts.projectId;
175
- let prefix;
176
- if (!projectId && opts.project && opts.prefix) {
177
- const p = (await api.createProject({ orgId: orgId, name: opts.project, prefix: opts.prefix }));
178
- projectId = p.projectId;
179
- prefix = p.prefix;
180
- console.log(`✓ Created project ${p.prefix}.`);
181
- }
182
- if (!projectId) {
183
- const { projects } = (await api.listProjects({ orgId: orgId }));
184
- const list = projects ?? [];
185
- if (isInteractive()) {
186
- const chosen = await pick("Select a project", list, (p) => `${p.prefix} — ${p.name}`);
187
- if (chosen) {
188
- projectId = chosen.id;
189
- prefix = chosen.prefix;
190
- }
191
- else {
192
- const name = await ask("New project name: ");
193
- const pfx = (await ask("Project prefix (e.g. ACME): ")).toUpperCase();
194
- if (!name || !pfx)
195
- throw new Error("project name and prefix required");
196
- const p = (await api.createProject({ orgId: orgId, name, prefix: pfx }));
197
- projectId = p.projectId;
198
- prefix = p.prefix;
199
- console.log(`✓ Created project ${p.prefix}.`);
200
- }
201
- }
202
- else if (list.length === 1) {
203
- projectId = list[0].id;
204
- prefix = list[0].prefix;
205
- }
206
- else {
207
- throw new Error("no project selected — pass --project-id <id>, or --project <name> --prefix <PFX>.");
208
- }
209
- }
210
- // --- mint a key for THIS (org, project) and wire the watchdog into THIS folder
211
- const minted = (await api.mintKey({
212
- orgId: orgId,
213
- projectId: projectId,
214
- agentName: opts.agent,
215
- runtime: opts.runtime ?? "claude-code",
216
- keyName: prefix ? `${prefix} key` : undefined,
217
- }));
218
- console.log(`✓ Minted key for this workspace (${minted.key.slice(0, 14)}…).\n`);
219
- // RTSC-92: the secret stays OUT of the repo. Store it in the home keystore
220
- // keyed by a workspace id; reuse this folder's existing id (so a re-bind, or a
221
- // teammate's committed marker, keeps the same id) or mint a fresh one.
222
- const workspaceId = existing?.workspaceId ?? newWorkspaceId();
223
- setBinding(workspaceId, {
385
+ await completeWorkspaceSetup({
224
386
  orgId: orgId,
225
- projectId: projectId,
226
- key: minted.key,
227
- url: cfg.mcpUrl,
228
- prefix,
229
- orgName: undefined,
230
- boundPath: cwd,
231
- 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
+ },
232
394
  });
233
- bindingWritten = true; // strand window closed — the org is now reachable
234
- // Per-folder only (local scope), always watchdog. The marker carries only the
235
- // workspace id — no secret — so ./.mcp.json is safe to commit.
236
- installMarker({ workspaceId, scope: "local" });
237
- // Confirm the binding the same way the agent will see it.
238
- try {
239
- const b = await resolveBinding(cfg.mcpUrl, minted.key);
240
- console.log(`\n✓ This folder is bound to org "${b.org.name}" / project ${b.project.prefix}.\n` +
241
- ` Agents launched here can only ever read or write ${b.project.prefix}.`);
242
- }
243
- catch {
244
- /* binding written; whoami confirmation is best-effort */
245
- }
246
395
  }
247
396
  catch (err) {
248
397
  const hint = strandRecoveryHint({ createdOrgThisRun, orgId, bindingWritten });
@@ -193,6 +193,9 @@ export async function claimAction(opts) {
193
193
  note(`✓ Claimed ${issueId}${title ? ` — ${title}` : ""}`);
194
194
  note(` worktree: ${plan.path}`);
195
195
  note(` branch: ${plan.branch}`);
196
+ // RTSC-446: a fresh worktree checks out tracked files only — deps/artifacts are absent.
197
+ note(` Fresh worktree: only tracked files. Run the repo's usual setup (what docs/CI`);
198
+ note(` run on a fresh checkout) before your first build or test.`);
196
199
  finish(plan.path, plan.branch, issueId, title, claim.claimToken, opts);
197
200
  }
198
201
  /** Emit machine output / drop into the worktree per the chosen flags. */
@@ -1,6 +1,8 @@
1
1
  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
+ import { runsOk } from "../lib/launcher.js";
5
+ import { clean } from "../lib/text.js";
4
6
  // RTSC-91 (DESIGN §13): `retasc doctor` — confirm THIS folder is correctly and
5
7
  // safely bound. The question a human actually has is "which org/project does
6
8
  // this folder talk to?", so the healthy answer is ONE line naming them.
@@ -19,7 +21,76 @@ const bad = (m) => console.log(` ✗ ${m}`);
19
21
  // Server- and config-derived strings end up inside doctor's verdict lines; a
20
22
  // hostile endpoint (reachable via a config-supplied legacy url) must not be
21
23
  // able to smuggle ANSI escapes into the very output people trust for ✓/✗.
22
- 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).
26
+ /**
27
+ * RTSC-493: can the registered entry actually be STARTED on this machine?
28
+ *
29
+ * Every other check here asks whether the binding is right. This one asks whether it can
30
+ * run at all, which is a different failure and an invisible one: the entry names a
31
+ * command, something else spawns it later, and when that command isn't there the only
32
+ * symptom is an agent with no Retasc tools. Nothing points back here.
33
+ *
34
+ * Folders bound before the fix carry a bare `retasc` that npx never installed, and
35
+ * re-binding is what repairs them, so this is the surface that has to say so.
36
+ */
37
+ export function launcherVerdict(local, probe = runsOk) {
38
+ if (!local.command)
39
+ return null; // HTTP transport spawns nothing
40
+ // The stored args end in `mcp-proxy` (the subcommand). Probe the LAUNCHER, so drop it.
41
+ const args = local.args ?? [];
42
+ const probeArgs = args[args.length - 1] === "mcp-proxy" ? args.slice(0, -1) : args;
43
+ return {
44
+ startable: Boolean(probe(local.command, probeArgs)),
45
+ shown: clean([local.command, ...probeArgs].join(" ")),
46
+ };
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
+ }
81
+ function checkLauncher(local) {
82
+ const v = launcherVerdict(local);
83
+ if (!v)
84
+ return;
85
+ if (v.startable) {
86
+ ok(`your agent can start Retasc (${v.shown}).`);
87
+ return;
88
+ }
89
+ bad(`your agent CANNOT start Retasc — this folder is registered to run "${v.shown}",\n` +
90
+ ` which doesn't run on this machine. The binding itself is fine; the command is missing.\n` +
91
+ ` This is what a bind through npx used to leave behind.\n` +
92
+ ` Fix: npm install -g @retasc/cli then re-run \`retasc bind\` here.`);
93
+ }
23
94
  export async function doctorAction() {
24
95
  const cfg = loadConfig();
25
96
  const cwd = process.cwd();
@@ -69,6 +140,10 @@ export async function doctorAction() {
69
140
  bad(`key not accepted by the server: ${msg}. Re-run \`retasc bind\`.`);
70
141
  }
71
142
  }
143
+ // Directly under the headline, because "bound to org X" reads as an all-clear and
144
+ // this is the one way it can be true and the folder still not work (RTSC-493).
145
+ // Local and independent of the server, so it runs even when the network is down.
146
+ checkLauncher(local);
72
147
  // Caveats below the headline — each is a real risk, none is the common case.
73
148
  if (local.workspaceId) {
74
149
  // The id resolved, but if it was bound at a different folder, this marker
@@ -124,4 +199,9 @@ export async function doctorAction() {
124
199
  ` own, so issues can land in the wrong project.\n` +
125
200
  ` Fix: claude mcp remove -s user retasc`);
126
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}`);
127
207
  }