@retasc/cli 1.12.0 → 1.13.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/index.js CHANGED
@@ -11,6 +11,7 @@ import { identityAction } from "./commands/identity.js";
11
11
  import { doctorAction } from "./commands/doctor.js";
12
12
  import { billingAction } from "./commands/billing.js";
13
13
  import { isNetworkError, readLocalBinding, resolveBinding } from "./lib/binding.js";
14
+ import { whoamiView, orgCreatedView, projectCreatedView, keyListView, inviteListView, } from "./lib/format.js";
14
15
  import { tidyAction, doneAction } from "./commands/tidy.js";
15
16
  import { runProxy } from "./proxy.js";
16
17
  import { deviceLogin } from "./auth.js";
@@ -77,7 +78,8 @@ program
77
78
  program
78
79
  .command("whoami")
79
80
  .description("Show THIS folder's org/project binding, plus the signed-in user and their orgs.")
80
- .action(async () => {
81
+ .option("--json", "Emit the raw payload instead of the summary.")
82
+ .action(async (opts) => {
81
83
  // RTSC-91 (§13): lead with the binding for the folder you're in — the same
82
84
  // "you are in org X / project Y" heads-up the agent gets — so a human can
83
85
  // confirm scope before any work. Resolved from the local key, server-enforced.
@@ -111,7 +113,9 @@ program
111
113
  }
112
114
  try {
113
115
  const me = await api.me();
114
- console.log(JSON.stringify(me, null, 2));
116
+ // RTSC-521: the binding block above is untouched — it was already the good half of
117
+ // this command. Only the payload dump becomes a summary.
118
+ console.log(opts.json ? JSON.stringify(me, null, 2) : whoamiView(me));
115
119
  }
116
120
  catch (e) {
117
121
  fail(e);
@@ -200,11 +204,12 @@ org
200
204
  .command("create")
201
205
  .requiredOption("--name <name>")
202
206
  .option("--slug <slug>")
207
+ .option("--json", "Emit the raw payload instead of the summary.")
203
208
  .action(async (opts) => {
204
209
  requireLogin();
205
210
  try {
206
- const res = await api.createOrg({ name: opts.name, slug: opts.slug });
207
- console.log(JSON.stringify(res, null, 2));
211
+ const res = (await api.createOrg({ name: opts.name, slug: opts.slug }));
212
+ console.log(opts.json ? JSON.stringify(res, null, 2) : orgCreatedView(res, opts.name));
208
213
  }
209
214
  catch (e) {
210
215
  fail(e);
@@ -216,11 +221,16 @@ project
216
221
  .requiredOption("--org-id <id>")
217
222
  .requiredOption("--name <name>")
218
223
  .requiredOption("--prefix <PREFIX>")
224
+ .option("--json", "Emit the raw payload instead of the summary.")
219
225
  .action(async (opts) => {
220
226
  requireLogin();
221
227
  try {
222
- const res = await api.createProject({ orgId: opts.orgId, name: opts.name, prefix: opts.prefix });
223
- console.log(JSON.stringify(res, null, 2));
228
+ const res = (await api.createProject({
229
+ orgId: opts.orgId,
230
+ name: opts.name,
231
+ prefix: opts.prefix,
232
+ }));
233
+ console.log(opts.json ? JSON.stringify(res, null, 2) : projectCreatedView(res, opts.name));
224
234
  }
225
235
  catch (e) {
226
236
  fail(e);
@@ -279,12 +289,14 @@ key
279
289
  });
280
290
  key
281
291
  .command("list")
292
+ .description("List an org's agent keys, newest first.")
282
293
  .requiredOption("--org-id <id>")
294
+ .option("--json", "Emit the raw payload instead of the table.")
283
295
  .action(async (opts) => {
284
296
  requireLogin();
285
297
  try {
286
- const res = await api.listKeys({ orgId: opts.orgId });
287
- console.log(JSON.stringify(res, null, 2));
298
+ const res = (await api.listKeys({ orgId: opts.orgId }));
299
+ console.log(opts.json ? JSON.stringify(res, null, 2) : keyListView(res));
288
300
  }
289
301
  catch (e) {
290
302
  fail(e);
@@ -347,11 +359,12 @@ members
347
359
  .command("list")
348
360
  .description("List an org's invites and their status (owner only).")
349
361
  .requiredOption("--org-id <id>")
362
+ .option("--json", "Emit the raw payload instead of the table.")
350
363
  .action(async (opts) => {
351
364
  requireLogin();
352
365
  try {
353
- const res = await api.listInvites({ orgId: opts.orgId });
354
- console.log(JSON.stringify(res, null, 2));
366
+ const res = (await api.listInvites({ orgId: opts.orgId }));
367
+ console.log(opts.json ? JSON.stringify(res, null, 2) : inviteListView(res));
355
368
  }
356
369
  catch (e) {
357
370
  fail(e);
@@ -0,0 +1,157 @@
1
+ import { clean } from "./text.js";
2
+ // RTSC-521 — the CLI's human-readable views.
3
+ //
4
+ // Five commands used to answer a person with `JSON.stringify(res, null, 2)`, including
5
+ // `whoami`, which is the command someone runs to find out who they are. The one fact they
6
+ // wanted was a bracket to parse, next to a Convex document id that means nothing to them.
7
+ //
8
+ // The "it's good for the agent" defence does not apply: agents never see CLI stdout. They
9
+ // talk to MCP directly, and `whoami` exists there as its own tool. Nothing was reading
10
+ // these payloads but a human.
11
+ //
12
+ // PURE, and returning strings rather than printing them, for two reasons. Tests can pin the
13
+ // wording without scraping stdout, and every one of these values came off the wire — so
14
+ // `clean()` belongs at the point of formatting, once, rather than at each interpolation
15
+ // where a later line can forget it.
16
+ //
17
+ // The shape follows `commands/billing.ts`, which already got this right: an aligned summary
18
+ // by default, `--json` for the raw payload. It is one pattern, not two.
19
+ /** Width of the label column. Matches billing.ts's `row()` so the CLI reads as one tool. */
20
+ const LABEL = 13;
21
+ /** One `label value` line. Multi-line values stay hung under the value column. */
22
+ export function labelled(rows) {
23
+ return rows
24
+ .map(([label, value]) => {
25
+ const [head, ...rest] = String(value).split("\n");
26
+ const pad = " ".repeat(LABEL);
27
+ return [`${label.padEnd(LABEL)}${head}`, ...rest.map((r) => `${pad}${r}`)].join("\n");
28
+ })
29
+ .join("\n");
30
+ }
31
+ /**
32
+ * An aligned table, or a plain sentence when there is nothing in it.
33
+ *
34
+ * `empty` is required rather than optional: a list command that prints a bare header row
35
+ * and nothing else reads like a failure, and every caller has something truer to say.
36
+ */
37
+ export function table(headers, rows, empty) {
38
+ if (rows.length === 0)
39
+ return empty;
40
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
41
+ // The last column is never padded — trailing spaces are invisible until someone copies
42
+ // a line out of their terminal and finds them.
43
+ const line = (cells) => cells.map((c, i) => (i === cells.length - 1 ? c : c.padEnd(widths[i]))).join(" ").trimEnd();
44
+ return [line(headers), ...rows.map(line)].join("\n");
45
+ }
46
+ /**
47
+ * How long ago, in the coarsest unit that is still true.
48
+ *
49
+ * A raw epoch is the specific thing this issue exists to remove: a revoked key reading as
50
+ * `1785660358922` is a number someone skims past, and "revoked" is the fact.
51
+ */
52
+ export function ago(ms, now = Date.now()) {
53
+ if (ms == null)
54
+ return "never";
55
+ const s = Math.max(0, Math.round((now - ms) / 1000));
56
+ if (s < 60)
57
+ return "just now";
58
+ const m = Math.round(s / 60);
59
+ if (m < 60)
60
+ return `${m}m ago`;
61
+ const h = Math.round(m / 60);
62
+ if (h < 24)
63
+ return `${h}h ago`;
64
+ return `${Math.round(h / 24)}d ago`;
65
+ }
66
+ /** A date a human can read, for a deadline rather than an elapsed time. */
67
+ export function day(ms) {
68
+ return ms == null ? "—" : new Date(ms).toISOString().slice(0, 10);
69
+ }
70
+ /**
71
+ * Who you are, and where you can act.
72
+ *
73
+ * The user id is dropped: nothing a person types accepts it. The org id is KEPT, because
74
+ * the very next command genuinely wants it (`retasc bind --org-id …`, `members invite
75
+ * --org-id …`) — the same reason the Dash surfaces it on the Settings page.
76
+ */
77
+ export function whoamiView(me) {
78
+ const u = me.user ?? {};
79
+ const who = [u.name, u.email && `<${u.email}>`].filter(Boolean).map(clean).join(" ");
80
+ const orgs = me.orgs ?? [];
81
+ return labelled([
82
+ ["Signed in", who || "unknown"],
83
+ [
84
+ "Orgs",
85
+ // Said in words. An empty array printed as `[]` is the exact moment this command
86
+ // stops answering the question it was asked.
87
+ orgs.length === 0
88
+ ? "none"
89
+ : orgs
90
+ .map((o) => {
91
+ const bits = [clean(o.name)];
92
+ if (o.slug)
93
+ bits.push(`(${clean(o.slug)})`);
94
+ const tail = [o.role && clean(o.role), o.deleting && "DELETING"].filter(Boolean);
95
+ // `o.id` is Convex-generated and so not attacker-influenced today. Cleaned
96
+ // anyway: this function's job is that nothing reaches a terminal unsanitised,
97
+ // and one raw interpolation is how the next field added here gets missed.
98
+ return `${bits.join(" ")}${tail.length ? ` · ${tail.join(" · ")}` : ""}\n ${clean(o.id)}`;
99
+ })
100
+ .join("\n"),
101
+ ],
102
+ ]);
103
+ }
104
+ export function orgCreatedView(res, name) {
105
+ // `ownerMemberId` is deliberately not shown. It comes back in the payload and there is no
106
+ // command that takes it, so printing it is noise a reader has to decide to ignore.
107
+ return [
108
+ `✓ Created org "${clean(name)}"${res.slug ? ` (${clean(res.slug)})` : ""}.`,
109
+ "",
110
+ labelled([["Org id", clean(res.orgId ?? "—")]]),
111
+ "",
112
+ `Next: retasc bind --org-id ${clean(res.orgId ?? "<id>")}`,
113
+ ].join("\n");
114
+ }
115
+ export function projectCreatedView(res, name) {
116
+ return [
117
+ `✓ Created project "${clean(name)}"${res.prefix ? ` (${clean(res.prefix)})` : ""}.`,
118
+ "",
119
+ labelled([["Project id", clean(res.projectId ?? "—")]]),
120
+ "",
121
+ `Issues here will be numbered ${clean(res.prefix ?? "PREFIX")}-1, ${clean(res.prefix ?? "PREFIX")}-2, and so on.`,
122
+ ].join("\n");
123
+ }
124
+ /**
125
+ * The org's agent keys.
126
+ *
127
+ * Session keys (RTSC-50) are folded into a count rather than listed. They are auto-minted
128
+ * per session and can outnumber the real workspace keys many to one, which is why the Dash
129
+ * groups them too — a list where the manageable rows are lost among machine-minted children
130
+ * is a list nobody reads.
131
+ */
132
+ export function keyListView(rows, now = Date.now()) {
133
+ const workspace = rows.filter((k) => !k.parentKeyId);
134
+ const sessions = rows.length - workspace.length;
135
+ const body = table(["KEY", "PROJECT", "AGENT", "RUNTIME", "LAST USED"], workspace.map((k) => [
136
+ clean(k.displayPrefix ?? k.name ?? "—"),
137
+ clean(k.project ?? "—"),
138
+ clean(k.agent ?? "—"),
139
+ clean(k.runtime ?? "—"),
140
+ // State first: a revoked key that reads as a date is one someone counts as live.
141
+ k.revokedAt ? "revoked" : ago(k.lastUsedAt, now),
142
+ ]), "No keys yet. Mint one with `retasc key mint`.");
143
+ return sessions > 0
144
+ ? `${body}\n\n(${sessions} session key${sessions === 1 ? "" : "s"} not shown — auto-minted per agent session.)`
145
+ : body;
146
+ }
147
+ export function inviteListView(rows) {
148
+ return table(["CODE", "ROLE", "STATUS", "INVITED BY", "EXPIRES"], rows.map((i) => [
149
+ clean(i.displayPrefix ?? "—"),
150
+ clean(i.role ?? "—"),
151
+ clean(i.status ?? "—"),
152
+ clean(i.invitedBy ?? "—"),
153
+ // Only a LIVE invite has a deadline worth reading. On a spent one the date is
154
+ // still in the payload and still means nothing.
155
+ i.status === "pending" ? day(i.expiresAt) : "—",
156
+ ]), "No invites yet. Create one with `retasc members invite --org-id <id>`.");
157
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "Retasc CLI — sign in with GitHub, create projects, mint agent API keys, and wire your agent to the Retasc MCP server in one command.",
5
5
  "type": "module",
6
6
  "bin": {