@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 +24 -0
- package/dist/auth.js +55 -4
- package/dist/commands/bind.js +80 -10
- package/dist/commands/mcp.js +20 -5
- package/dist/lib/card.js +66 -0
- package/package.json +1 -1
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
|
-
/**
|
|
32
|
-
|
|
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) {
|
package/dist/commands/bind.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
package/dist/commands/mcp.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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(
|
|
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/lib/card.js
ADDED
|
@@ -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.
|
|
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": {
|