@retasc/cli 1.26.0 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,30 @@ release commits and the issues they reference.
6
6
 
7
7
  Dates are the npm publish date. Each entry names the RTSC issue behind it.
8
8
 
9
+ ## 1.28.0 (2026-08-19)
10
+
11
+ - **RTSC-676** — signing in opens your browser. It used to print a URL and an
12
+ eight-character code and leave you to switch windows, navigate, and retype it. Now the
13
+ page opens for you, pre-filled when the provider supplies RFC 8628's complete URL. The
14
+ URL and code are still printed first and always: there is no browser inside a container
15
+ or over SSH, which is where `bind` runs most, so the browser is a convenience layered
16
+ on top and never the only way through. A missing `xdg-open` changes nothing.
17
+ `RETASC_NO_BROWSER=1` turns it off.
18
+ - **RTSC-676** — the finish screen's closing lines are flush left, and it ends with a
19
+ blank line instead of welding the shell prompt to the last thing you read.
20
+
21
+ ## 1.27.0 (2026-08-19)
22
+
23
+ - **RTSC-673** — `bind` ends by telling you what to do, not by dumping config. It used to
24
+ close on fourteen lines of MCP JSON plus "(Claude Code CLI not detected)": a block
25
+ instructing you to paste something we had just written to disk for you, and a note that
26
+ reads like a fault on a screen whose whole content is that everything worked. Between
27
+ them they buried the one thing a new owner needed. Now it prints a receipt card — org,
28
+ project, where the key went, what landed in your repo — and then says to start your
29
+ agent. The closing lines follow the project: an empty one invites you to describe what
30
+ you're building, a project with work in it tells you to say "next issue". The JSON still
31
+ prints when the marker could not be written anywhere, which is the case it exists for.
32
+
9
33
  ## 1.26.0 (2026-08-19)
10
34
 
11
35
  - **RTSC-670** — `retasc bind` can sign you in again. It has opened with the device flow
package/dist/auth.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { spawn } from "node:child_process";
1
2
  import { ConvexHttpClient } from "convex/browser";
2
3
  import { makeFunctionReference } from "convex/server";
3
4
  import { loadConfig, patchConfig } from "./config.js";
@@ -28,11 +29,61 @@ async function postForm(url, body) {
28
29
  return res.json();
29
30
  }
30
31
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
31
- /** Print the two lines a human has to act on, identically for both doors. */
32
- function announceCode(where, url, code) {
32
+ /**
33
+ * Open a URL in the machine's browser, best effort (RTSC-676).
34
+ *
35
+ * BEST EFFORT is the whole contract. There is no browser inside a container or over
36
+ * SSH, which is where `bind` most often runs, so this must never be the only way
37
+ * through and must never turn a failure of its own into a failure of sign-in. It is
38
+ * spawned detached and every outcome is swallowed: the printed URL and code below are
39
+ * the real path, and this only saves the person who does have a browser a trip through
40
+ * their address bar.
41
+ *
42
+ * `xdg-open` on Linux is frequently absent even under a desktop; that is a miss, not an
43
+ * error. `start` needs a shell on Windows, and its first quoted argument is the window
44
+ * TITLE, hence the empty string.
45
+ */
46
+ function openBrowser(url) {
47
+ if (process.env.RETASC_NO_BROWSER)
48
+ return;
49
+ const [cmd, args] = process.platform === "darwin"
50
+ ? ["open", [url]]
51
+ : process.platform === "win32"
52
+ ? ["start", ['""', url]]
53
+ : ["xdg-open", [url]];
54
+ try {
55
+ const child = spawn(cmd, args, {
56
+ stdio: "ignore",
57
+ detached: true,
58
+ shell: process.platform === "win32",
59
+ });
60
+ // Without this the CLI would wait on a browser that outlives it by hours.
61
+ child.unref();
62
+ // A missing `xdg-open` surfaces here rather than as a throw.
63
+ child.on("error", () => { });
64
+ }
65
+ catch {
66
+ /* no browser, no shell, no matter — the URL is printed below */
67
+ }
68
+ }
69
+ /**
70
+ * Print the two lines a human has to act on, identically for both doors, and open the
71
+ * browser for them if this machine has one.
72
+ *
73
+ * The lines are printed FIRST and unconditionally. Someone reading a terminal in a
74
+ * container has to see the URL whatever happened, and an auto-open that silently
75
+ * failed must leave the screen exactly as useful as it was before (RTSC-676).
76
+ *
77
+ * `complete` is RFC 8628's pre-filled URL when the provider sent one. It is opened in
78
+ * preference to the plain one, but never PRINTED in its place: the code stays on its own
79
+ * line so it can be read aloud, retyped on a phone, or pasted into a browser on another
80
+ * machine — none of which a link helps with.
81
+ */
82
+ function announceCode(where, url, code, complete) {
33
83
  console.log(`\n Open: ${url}`);
34
84
  console.log(` Enter code: ${code}\n`);
35
85
  console.log(` Waiting for ${where} authorization…`);
86
+ openBrowser(complete || url);
36
87
  }
37
88
  /** Run GitHub's device flow (talking to GitHub directly) and return its access token. */
38
89
  async function githubDeviceToken() {
@@ -46,7 +97,7 @@ async function githubDeviceToken() {
46
97
  if (!start.device_code) {
47
98
  throw new Error(`GitHub device-flow start failed: ${JSON.stringify(start)}`);
48
99
  }
49
- announceCode("GitHub", start.verification_uri, start.user_code);
100
+ announceCode("GitHub", start.verification_uri, start.user_code, start.verification_uri_complete);
50
101
  // Poll GitHub for the access token.
51
102
  let intervalMs = (start.interval || 5) * 1000;
52
103
  const deadline = Date.now() + start.expires_in * 1000;
@@ -80,7 +131,7 @@ async function githubDeviceToken() {
80
131
  */
81
132
  async function googleDeviceToken(convex) {
82
133
  const start = (await convex.action(googleDeviceStart, {}));
83
- announceCode("Google", start.verificationUrl, start.userCode);
134
+ announceCode("Google", start.verificationUrl, start.userCode, start.verificationUrlComplete);
84
135
  let intervalMs = (start.intervalSeconds || 5) * 1000;
85
136
  const deadline = Date.now() + start.expiresInSeconds * 1000;
86
137
  while (Date.now() < deadline) {
@@ -2,12 +2,13 @@ import { basename } from "node:path";
2
2
  import { api, cliError } from "../api.js";
3
3
  import { deviceLogin } from "../auth.js";
4
4
  import { loadConfig, patchConfig } from "../config.js";
5
- import { installMarker } from "./mcp.js";
5
+ import { installMarker, printMarkerBlock } from "./mcp.js";
6
6
  import { readLocalBinding, resolveBinding } from "../lib/binding.js";
7
7
  import { getBinding, setBinding, newWorkspaceId } from "../lib/keystore.js";
8
8
  import { resolveLauncher, launcherNote, runsOk, selfCommand } from "../lib/launcher.js";
9
9
  import { ask, confirm, isInteractive } from "../lib/prompt.js";
10
10
  import { clean } from "../lib/text.js";
11
+ import { card, DOT } from "../lib/card.js";
11
12
  import { VERSION } from "../version.js";
12
13
  // RTSC-508: `ask`/`confirm`/`isInteractive` now live in lib/prompt.ts so `auth.ts`
13
14
  // can use them without closing an import cycle (bind → auth → bind). Re-exported
@@ -217,7 +218,7 @@ async function nameAProject(orgId) {
217
218
  throw new Error("project name and prefix required");
218
219
  const p = (await api.createProject({ orgId, name, prefix: pfx }));
219
220
  console.log(`✓ Created project ${p.prefix}.`);
220
- return { projectId: p.projectId, prefix: p.prefix };
221
+ return { projectId: p.projectId, prefix: p.prefix, empty: true };
221
222
  }
222
223
  /**
223
224
  * "Where does your work come from?" — the first project, for an org that has none.
@@ -260,7 +261,8 @@ async function firstProject(orgId, orgLabel) {
260
261
  console.log("\nNo import. Let's make an empty project instead.");
261
262
  return nameAProject(orgId);
262
263
  }
263
- return done;
264
+ // An import that ran has work in it by definition — that is what it did.
265
+ return { ...done, empty: false };
264
266
  }
265
267
  /**
266
268
  * Everything from "which project" to a working folder: pick the project, make `retasc`
@@ -284,6 +286,11 @@ export async function completeWorkspaceSetup(args) {
284
286
  // --- resolve project (pick / flag / create) --------------------------------
285
287
  let projectId = opts.projectId;
286
288
  let prefix;
289
+ // RTSC-673 — does this project have anything in it yet? Decides which two sentences
290
+ // close the run. UNKNOWN defaults to "has work": a --project-id we never listed could
291
+ // be either, and telling someone with 200 issues that their project is empty is the
292
+ // worse half of that guess.
293
+ let emptyProject = false;
287
294
  if (!projectId && opts.project && opts.prefix) {
288
295
  if (!args.canCreateProject) {
289
296
  cliError("FORBIDDEN", "Only an owner or admin can create a project.", `Ask one of them to add one, then pass --project-id <id>.`);
@@ -291,10 +298,13 @@ export async function completeWorkspaceSetup(args) {
291
298
  const p = (await api.createProject({ orgId, name: opts.project, prefix: opts.prefix }));
292
299
  projectId = p.projectId;
293
300
  prefix = p.prefix;
301
+ emptyProject = true;
294
302
  console.log(`✓ Created project ${p.prefix}.`);
295
303
  }
296
304
  if (!projectId) {
297
305
  const { projects } = (await api.listProjects({ orgId }));
306
+ // `counter` is the last ISSUED number, so 0 means nothing was ever filed here —
307
+ // which is the only question RTSC-673 asks of it. It is NOT an open count.
298
308
  const list = projects ?? [];
299
309
  if (!args.canCreateProject) {
300
310
  // A member cannot create one, so an empty org is a dead end here — and it has to say
@@ -308,11 +318,13 @@ export async function completeWorkspaceSetup(args) {
308
318
  if (list.length === 1) {
309
319
  projectId = list[0].id;
310
320
  prefix = list[0].prefix;
321
+ emptyProject = projectIsEmpty(list[0]);
311
322
  }
312
323
  else if (isInteractive()) {
313
324
  const chosen = await pickExisting("Select a project", list, (p) => `${clean(p.prefix)} — ${clean(p.name)}`);
314
325
  projectId = chosen.id;
315
326
  prefix = chosen.prefix;
327
+ emptyProject = projectIsEmpty(chosen);
316
328
  }
317
329
  else {
318
330
  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(", ")}`);
@@ -330,17 +342,20 @@ export async function completeWorkspaceSetup(args) {
330
342
  const made = await firstProject(orgId, org);
331
343
  projectId = made.projectId;
332
344
  prefix = made.prefix;
345
+ emptyProject = made.empty;
333
346
  }
334
347
  else if (isInteractive()) {
335
348
  const chosen = await pick("Select a project", list, (p) => `${clean(p.prefix)} — ${clean(p.name)}`);
336
349
  if (chosen) {
337
350
  projectId = chosen.id;
338
351
  prefix = chosen.prefix;
352
+ emptyProject = projectIsEmpty(chosen);
339
353
  }
340
354
  else {
341
355
  const made = await nameAProject(orgId);
342
356
  projectId = made.projectId;
343
357
  prefix = made.prefix;
358
+ emptyProject = made.empty;
344
359
  }
345
360
  }
346
361
  else if (list.length === 1) {
@@ -376,7 +391,8 @@ export async function completeWorkspaceSetup(args) {
376
391
  runtime: opts.runtime ?? "claude-code",
377
392
  keyName: prefix ? `${prefix} key` : undefined,
378
393
  }));
379
- console.log(`✓ Minted key for this workspace (${minted.key.slice(0, 14)}…).\n`);
394
+ // The key is named in the receipt card below, not here — one mention, in the place
395
+ // that says where it went (RTSC-673).
380
396
  // RTSC-92: the secret stays OUT of the repo. Store it in the home keystore
381
397
  // keyed by a workspace id; reuse this folder's existing id (so a re-bind, or a
382
398
  // teammate's committed marker, keeps the same id) or mint a fresh one.
@@ -394,16 +410,31 @@ export async function completeWorkspaceSetup(args) {
394
410
  args.onBound?.();
395
411
  // Per-folder only (local scope), always watchdog. The marker carries only the
396
412
  // workspace id — no secret — so ./.mcp.json is safe to commit.
397
- installMarker({ workspaceId, scope: "local", launcher });
398
- // Confirm the binding the same way the agent will see it.
413
+ const marker = installMarker({ workspaceId, scope: "local", launcher, quiet: true });
414
+ // Confirm the binding the same way the agent will see it, and print it as the receipt
415
+ // (RTSC-673). Resolved rather than echoed from what we just sent: this is the last
416
+ // chance to notice a folder bound to something other than what was asked for, and a
417
+ // card built from our own inputs could never show that.
418
+ let bound = null;
399
419
  try {
400
420
  const b = await resolveBinding(cfg.mcpUrl, minted.key);
401
- console.log(`\n✓ This folder is bound to org "${clean(b.org.name)}" / project ${clean(b.project.prefix)}.\n` +
402
- ` Agents launched here can only ever read or write ${clean(b.project.prefix)}.`);
421
+ bound = { org: clean(b.org.name), prefix: clean(b.project.prefix), name: b.project.name };
403
422
  }
404
423
  catch {
405
- /* binding written; whoami confirmation is best-effort */
424
+ /* binding written; whoami confirmation is best-effort — fall back to what we sent */
406
425
  }
426
+ const orgLabel = bound?.org ?? clean(args.orgLabel ?? "");
427
+ const pfx = bound?.prefix ?? clean(prefix ?? "");
428
+ console.log("\n" +
429
+ card(`✓ This folder is bound to ${orgLabel} / ${pfx}`, [
430
+ { label: "Org", value: orgLabel },
431
+ { label: "Project", value: bound?.name ? `${pfx} ${DOT} ${bound.name}` : pfx },
432
+ { label: "Agent key", value: `${minted.key.slice(0, 14)}…`, note: "never leaves ~/.retasc" },
433
+ { label: "MCP wired", value: marker.where, note: "secret-free, safe to commit" },
434
+ ], `Agents launched here can only ever read or write ${pfx}.`));
435
+ // Only when we could write it nowhere. See printMarkerBlock.
436
+ if (!marker.wroteFile && marker.where === "")
437
+ printMarkerBlock(workspaceId, launcher);
407
438
  // RTSC-519 — say that setup FINISHED, and what to do with it.
408
439
  //
409
440
  // Here rather than in either command, because it is true of both and this is the tail
@@ -415,7 +446,46 @@ export async function completeWorkspaceSetup(args) {
415
446
  // AFTER the confirmation, and after everything that can throw, so it is only ever
416
447
  // printed by a run that actually finished. Nothing below it can fail.
417
448
  if (isInteractive())
418
- console.log(`\n${NEXT_STEP}`);
449
+ console.log(`\n${NEXT_STEP}\n\n${whatNow(pfx, emptyProject)}\n`);
450
+ }
451
+ /**
452
+ * The two sentences under `NEXT_STEP`, chosen by whether there is anything to pull yet
453
+ * (RTSC-673).
454
+ *
455
+ * `NEXT_STEP` says how to connect an agent. It cannot say what to ASK it, because the
456
+ * right first thing differs entirely: a project created ten seconds ago has an empty
457
+ * queue, and telling that person to say "next issue" sends them to a blank result as
458
+ * their first experience of the product. Telling the person who just imported 142 issues
459
+ * to "describe what you're building" wastes the work they already have.
460
+ *
461
+ * No count in either branch, deliberately. `projects.counter` is the last ISSUED number,
462
+ * so a project with 200 created and 195 closed still reads 200 — printing it as an open
463
+ * count would be a precise lie. The importer already reports its own totals during the
464
+ * import step, which is where a real number belongs. A true count here needs an
465
+ * open-issue query the CLI does not have yet.
466
+ *
467
+ * "0 issues" is never printed for the empty case either: zero is the expected state for
468
+ * a project someone just named, and stating it as a quantity reads like a failure.
469
+ */
470
+ export function projectIsEmpty(p) {
471
+ // `counter` is the last ISSUED number, never a count of open work: a project with 200
472
+ // filed and 195 closed still reads 200. So it answers exactly one question — has
473
+ // anything ever been filed here — which is the only one `whatNow` asks. Absent
474
+ // (an older payload, or a caller that never listed the project) reads as NOT empty:
475
+ // telling someone with 200 issues that their project is empty is the worse half of
476
+ // that guess.
477
+ return p.counter === 0;
478
+ }
479
+ export function whatNow(prefix, empty) {
480
+ // FLUSH LEFT (RTSC-676). Indented, these read as subordinate to the line above them
481
+ // when they are in fact continuing it — the eye takes the block for a nested detail
482
+ // rather than the instruction it is.
483
+ return empty
484
+ ? `${prefix} is empty, so start by talking with the agent. Tell it what you're\n` +
485
+ `building and it will file the work as it goes.\n\n` +
486
+ `Once there's a backlog, saying "next issue" is how you pull from it.`
487
+ : `There's already work in ${prefix}. Say "next issue" and your agent takes\n` +
488
+ `the top unblocked one and starts.`;
419
489
  }
420
490
  export async function setupFromToken(token, opts, deps = {}) {
421
491
  const cfg = loadConfig();
@@ -229,15 +229,30 @@ export function installMarker(opts) {
229
229
  const shared = portableLauncher(resolved, VERSION);
230
230
  const res = tryClaudeCliMarker(opts.workspaceId, scope, scope === "project" ? shared : resolved.launcher);
231
231
  if (res.ok) {
232
- console.log(`✓ Registered Retasc watchdog (secret-free marker, scope: ${scope}).`);
232
+ if (!opts.quiet)
233
+ console.log(`✓ Registered Retasc watchdog (secret-free marker, scope: ${scope}).`);
234
+ return { where: `Claude Code (${scope})`, wroteFile: false };
233
235
  }
234
- else {
235
- // Always ./.mcp.json, whatever scope was asked for, so always the shared form.
236
- const path = writeProjectMcpJson(mcpMarkerEntry(opts.workspaceId, shared));
236
+ // Always ./.mcp.json, whatever scope was asked for, so always the shared form.
237
+ const path = writeProjectMcpJson(mcpMarkerEntry(opts.workspaceId, shared));
238
+ if (!opts.quiet) {
237
239
  console.log(`✓ Wrote secret-free watchdog marker to ${path}`);
238
240
  console.log(` ${fallbackNote(res)}`);
239
241
  }
242
+ return { where: path, wroteFile: true };
243
+ }
244
+ /**
245
+ * The MCP config block, printed ONLY when we could not write it anywhere (RTSC-673).
246
+ *
247
+ * It exists for someone wiring a client we do not know how to configure, and that is a
248
+ * real case. It is not the case where we just wrote the identical content to
249
+ * `.mcp.json` for them — there it was fourteen lines instructing a person to perform a
250
+ * job already finished, at the end of the one screen that should have said what to do
251
+ * next. `retasc doctor` prints it on demand for anyone who does need it.
252
+ */
253
+ export function printMarkerBlock(workspaceId, launcher) {
254
+ const shared = portableLauncher(launcher ?? resolveLauncher({ version: VERSION, install: false }), VERSION);
240
255
  console.log("\nMarker block (any stdio MCP client) — no secret, safe to commit:\n");
241
- console.log(mcpMarkerConfigBlock(opts.workspaceId, shared));
256
+ console.log(mcpMarkerConfigBlock(workspaceId, shared));
242
257
  console.log("\nThe key lives in your home keystore (~/.retasc/bindings.json), not in the repo.");
243
258
  }
@@ -0,0 +1,66 @@
1
+ import { clean } from "./text.js";
2
+ // RTSC-673 — the receipt a finished `bind` prints.
3
+ //
4
+ // It replaces a tail that ended on fourteen lines of MCP config plus a note reading
5
+ // "(Claude Code CLI not detected)". Both were wrong at the moment they were read: the
6
+ // JSON told someone to paste a block we had just written to disk for them, and the note
7
+ // announced a detection miss on a screen whose whole content is that everything worked.
8
+ // Between them they buried the one thing a new owner needed, which is what to do next.
9
+ //
10
+ // A box rather than a list because this is a RECEIPT: bounded, glanced at once,
11
+ // confirming the four facts someone might reasonably doubt (which org, which project,
12
+ // where the secret went, what got written into their repo). The call to action stays
13
+ // OUTSIDE it, so the thing to act on is not one more row to scan past.
14
+ /** Inner width. 62 + borders fits an 80-column terminal with room to spare. */
15
+ const W = 62;
16
+ /**
17
+ * Does this terminal render box-drawing characters and `…` reliably?
18
+ *
19
+ * Windows `cmd.exe` is the one that does not, and it is the same distinction
20
+ * `lib/launcher.ts` already draws for spawning. A garbled box is worse than a plain
21
+ * one: it reads as corruption at the exact moment we are telling someone everything
22
+ * worked.
23
+ */
24
+ const FANCY = process.platform !== "win32";
25
+ const B = FANCY
26
+ ? { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│", ml: "├", mr: "┤", dot: "·", ell: "…" }
27
+ : { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|", ml: "+", mr: "+", dot: "-", ell: "..." };
28
+ /**
29
+ * Fit `s` to `n` columns, ellipsing rather than letting it blow the box open.
30
+ *
31
+ * Truncation, not padding alone: an org can be named anything, and a name one character
32
+ * over the width turns every following line into a ragged edge. Padding is the easy half
33
+ * and the half that looks fine in testing, because test fixtures are short.
34
+ */
35
+ function fit(s, n) {
36
+ const t = clean(s);
37
+ if (t.length <= n)
38
+ return t.padEnd(n);
39
+ return t.slice(0, Math.max(0, n - B.ell.length)) + B.ell;
40
+ }
41
+ /** One bordered card: a title line, labelled rows, then a footer line. */
42
+ export function card(title, rows, footer) {
43
+ const inner = W - 2; // one space of padding each side
44
+ const line = (s) => `${B.v} ${fit(s, inner)} ${B.v}`;
45
+ const rule = (l, r) => `${l}${B.h.repeat(W)}${r}`;
46
+ const LABEL = 12;
47
+ const VALUE = 20;
48
+ const out = [rule(B.tl, B.tr), line(title), rule(B.ml, B.mr)];
49
+ for (const r of rows) {
50
+ // A note sits in a third column, so the values stay in one scannable rail. Without a
51
+ // note the value simply runs on — padding it anyway would leave a trench of spaces
52
+ // in the middle of the card.
53
+ const body = r.note
54
+ ? `${fit(r.label, LABEL)}${fit(r.value, VALUE)}${r.note}`
55
+ : `${fit(r.label, LABEL)}${r.value}`;
56
+ out.push(line(body));
57
+ }
58
+ if (footer) {
59
+ out.push(rule(B.ml, B.mr));
60
+ out.push(line(footer));
61
+ }
62
+ out.push(rule(B.bl, B.br));
63
+ return out.join("\n");
64
+ }
65
+ /** The `·` the CLI already uses between a prefix and its name, ASCII-safe. */
66
+ export const DOT = B.dot;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.26.0",
3
+ "version": "1.28.0",
4
4
  "description": "Retasc CLI \u2014 the issue tracker AI agents pull work from. Sign in with GitHub or Google, create projects, mint agent API keys, and wire your agent to the Retasc MCP server in one command.",
5
5
  "type": "module",
6
6
  "bin": {