@retasc/cli 1.25.0 → 1.27.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,33 @@ 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.27.0 (2026-08-19)
10
+
11
+ - **RTSC-673** — `bind` ends by telling you what to do, not by dumping config. It used to
12
+ close on fourteen lines of MCP JSON plus "(Claude Code CLI not detected)": a block
13
+ instructing you to paste something we had just written to disk for you, and a note that
14
+ reads like a fault on a screen whose whole content is that everything worked. Between
15
+ them they buried the one thing a new owner needed. Now it prints a receipt card — org,
16
+ project, where the key went, what landed in your repo — and then says to start your
17
+ agent. The closing lines follow the project: an empty one invites you to describe what
18
+ you're building, a project with work in it tells you to say "next issue". The JSON still
19
+ prints when the marker could not be written anywhere, which is the case it exists for.
20
+
21
+ ## 1.26.0 (2026-08-19)
22
+
23
+ - **RTSC-670** — `retasc bind` can sign you in again. It has opened with the device flow
24
+ since 1.20-era, but a leftover session guard in the command wrapper exited first, so on
25
+ a machine that had never signed in — the only state a new owner is ever in — `bind`
26
+ printed "Not signed in" and stopped. Owner onboarding dead-ended at its first
27
+ documented command, on every release for three weeks. The guard was correct when it was
28
+ written and became redundant the moment `bind` grew its own sign-in; nothing noticed
29
+ because no test ran the CLI with no session. Four now do.
30
+ - **RTSC-671** — errors name the command you actually ran. Told to `npx @retasc/cli bind`
31
+ and then answered with "Run `retasc login` first", you were pointed at a binary that
32
+ invocation never installs — a recoverable stumble turned into a dead end. Every "run X
33
+ first" message now renders `npx -y @retasc/cli@<version> …` or `retasc …` to match how
34
+ you started it.
35
+
9
36
  ## 1.25.0 (2026-08-19)
10
37
 
11
38
  - **RTSC-669** — the invite project picker takes several projects. 1.24.0 shipped it
package/dist/api.js CHANGED
@@ -2,6 +2,8 @@ import { ConvexHttpClient } from "convex/browser";
2
2
  import { makeFunctionReference } from "convex/server";
3
3
  import { loadConfig } from "./config.js";
4
4
  import { refreshSession, deviceLogin } from "./auth.js";
5
+ import { selfCommand } from "./lib/launcher.js";
6
+ import { VERSION } from "./version.js";
5
7
  // Typed-ish references to the public management functions in convex/manage.ts.
6
8
  // The CLI is a standalone package, so we reference functions by name rather than
7
9
  // importing the parent's generated api.
@@ -163,7 +165,7 @@ async function withAuth(call) {
163
165
  if (await refreshSession())
164
166
  return await call();
165
167
  if (!process.stdout.isTTY) {
166
- throw new Error("Session expired and could not refresh. Run `retasc login`.");
168
+ throw new Error(`Session expired and could not refresh. Run \`${selfCommand(VERSION)} login\`.`);
167
169
  }
168
170
  console.error("Session expired — re-authenticating with GitHub…");
169
171
  await deviceLogin();
@@ -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,43 @@ 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)}`);
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
+ return empty
481
+ ? ` ${prefix} is empty, so start by talking with the agent. Tell it what you're\n` +
482
+ ` building and it will file the work as it goes.\n\n` +
483
+ ` Once there's a backlog, saying "next issue" is how you pull from it.`
484
+ : ` There's already work in ${prefix}. Say "next issue" and your agent takes\n` +
485
+ ` the top unblocked one and starts.`;
419
486
  }
420
487
  export async function setupFromToken(token, opts, deps = {}) {
421
488
  const cfg = loadConfig();
@@ -493,7 +560,7 @@ export async function ensureSignedIn() {
493
560
  if (loadConfig().token)
494
561
  return;
495
562
  if (!isInteractive()) {
496
- throw new Error("Not signed in. Run `retasc login` first (no TTY here for the device flow).");
563
+ throw new Error(`Not signed in. Run \`${selfCommand(VERSION)} login\` first (no TTY here for the device flow).`);
497
564
  }
498
565
  // RTSC-508: don't name a provider here. `deviceLogin` asks which door, and
499
566
  // announcing "GitHub" before the question would be wrong for the invited
@@ -1,6 +1,8 @@
1
1
  import { api } from "../api.js";
2
2
  import { clean } from "../lib/text.js";
3
3
  import { isInteractive } from "./bind.js";
4
+ import { selfCommand } from "../lib/launcher.js";
5
+ import { VERSION } from "../version.js";
4
6
  import { identityLoop } from "./join.js";
5
7
  /**
6
8
  * Which org's placeholders to offer.
@@ -65,7 +67,7 @@ function report(outcome, orgLabel) {
65
67
  console.log("Noted. That covers those tools only, so a later import will ask again.");
66
68
  return;
67
69
  case "left":
68
- console.log("Nothing linked. Run `retasc identity` again whenever you want to look.");
70
+ console.log(`Nothing linked. Run \`${selfCommand(VERSION)} identity\` again whenever you want to look.`);
69
71
  return;
70
72
  case "skipped":
71
73
  // Only reachable non-interactively; the interactive guard below catches that first.
@@ -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
  }
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
3
  import { VERSION } from "./version.js";
4
+ import { selfCommand } from "./lib/launcher.js";
4
5
  import { loadConfig, patchConfig, saveConfig, configPath, isLoggedIn } from "./config.js";
5
6
  import { installMcp, normalizeScope } from "./commands/mcp.js";
6
7
  import { installGate, resolveGatePrefix } from "./commands/gate.js";
@@ -25,7 +26,10 @@ program
25
26
  .version(VERSION);
26
27
  function requireLogin() {
27
28
  if (!isLoggedIn()) {
28
- console.error("Not signed in. Run `retasc login` first.");
29
+ // RTSC-671 — `selfCommand` renders the form they actually used. The docs send a
30
+ // new owner through `npx @retasc/cli`, which never puts `retasc` on PATH, so naming
31
+ // it here answers a failure with a second `command not found`.
32
+ console.error(`Not signed in. Run \`${selfCommand(VERSION)} login\` first.`);
29
33
  process.exit(1);
30
34
  }
31
35
  }
@@ -87,7 +91,7 @@ program
87
91
  // confirm scope before any work. Resolved from the local key, server-enforced.
88
92
  const local = readLocalBinding(process.cwd());
89
93
  if (local?.markerOnly) {
90
- console.log("This folder → has a Retasc marker but no key on this machine. Run `retasc bind`.\n");
94
+ console.log(`This folder → has a Retasc marker but no key on this machine. Run \`${selfCommand(VERSION)} bind\`.\n`);
91
95
  }
92
96
  else if (local) {
93
97
  try {
@@ -107,10 +111,10 @@ program
107
111
  }
108
112
  }
109
113
  else {
110
- console.log("This folder → not bound. Run `retasc bind`.\n");
114
+ console.log(`This folder → not bound. Run \`${selfCommand(VERSION)} bind\`.\n`);
111
115
  }
112
116
  if (!isLoggedIn()) {
113
- console.log("Not signed in (management). Run `retasc login` to manage orgs/projects.");
117
+ console.log(`Not signed in (management). Run \`${selfCommand(VERSION)} login\` to manage orgs/projects.`);
114
118
  return;
115
119
  }
116
120
  try {
@@ -200,7 +204,16 @@ program
200
204
  await setupFromToken(opts.setup, opts).catch(fail);
201
205
  return;
202
206
  }
203
- requireLogin();
207
+ // NO requireLogin here (RTSC-670). `bindAction` opens with `ensureSignedIn`, which
208
+ // runs the device flow when there is no session — that IS the documented first step
209
+ // of owner onboarding, and it is the only state a new owner is ever in. This guard
210
+ // predated that (it was correct in 285ec74, when bindAction had no sign-in of its
211
+ // own) and `ensureSignedIn` arriving in 72182c3 left it in front of a door it
212
+ // permanently closed: every `bind` on a fresh machine exited 1 before reaching it.
213
+ //
214
+ // `ensureSignedIn` also refuses BETTER — it says why ("no TTY here for the device
215
+ // flow") where this said only that you weren't signed in, which is the diagnosis
216
+ // that sends someone looking in the wrong place.
204
217
  await bindAction(opts).catch(fail);
205
218
  });
206
219
  // `retasc doctor` — verify this folder's binding + flag any illegal global server.
@@ -527,7 +540,7 @@ gate
527
540
  if (r.source !== "--prefix")
528
541
  console.log(`Prefix ${r.prefix} — from ${r.source}.`);
529
542
  if (r.source === "global config") {
530
- console.log(" (This folder isn't bound — `retasc bind` makes the prefix folder-scoped.)");
543
+ console.log(` (This folder isn't bound — \`${selfCommand(VERSION)} bind\` makes the prefix folder-scoped.)`);
531
544
  }
532
545
  if (r.shadowedDefault) {
533
546
  console.log(` ⚠ Ignoring global default prefix ${r.shadowedDefault} — this folder's binding wins.`);
@@ -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.25.0",
3
+ "version": "1.27.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": {