@retasc/cli 1.49.1 → 1.50.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,23 @@ 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.50.0 (2026-09-11)
10
+
11
+ - **RTSC-901** — `retasc plan`: which subscription pays for each of your agents, listed
12
+ and set one line per (agent, harness). Retasc cannot read this anywhere — no transcript
13
+ field, telemetry attribute or environment variable carries it, and the only machine
14
+ source sits behind the human's own keychain — so it is asked for once and kept.
15
+ The options are READ FROM THE SERVER on every run, never compiled in: a tier a vendor
16
+ ships this morning is on the list this afternoon, on the build you already have.
17
+ - **RTSC-901** — `bind` asks the same question at setup, but only when `--runtime` was
18
+ STATED. That flag defaults to `claude-code`, which is a fine label for a row and the
19
+ wrong subject for this question: `retasc setup` wires every harness on the machine, so
20
+ asking "which Claude plan?" of someone setting up Codex records a fact about the wrong
21
+ product. Left unstated, the question falls to the first MCP handshake, which knows
22
+ which harness actually connected.
23
+ - A non-interactive run answers nothing rather than defaulting. A plan nobody chose reads
24
+ on the Agents page exactly like one they did.
25
+
9
26
  ## 1.49.1 (2026-09-09)
10
27
 
11
28
  - **RTSC-879** — the proxy sends NO `Authorization` header when it has no key, instead of
package/dist/api.js CHANGED
@@ -16,6 +16,12 @@ const fns = {
16
16
  createProject: makeFunctionReference("manage:createProject"),
17
17
  renameProjectPrefix: makeFunctionReference("manage:renameProjectPrefix"),
18
18
  listKeys: makeFunctionReference("manage:listKeys"),
19
+ // RTSC-901 — the roster (for the agent rows and their plans) and the plan catalogue.
20
+ // The catalogue is fetched on every run and never cached to disk: a list cached in
21
+ // the client is the release-shaped list this issue exists to avoid.
22
+ listOrgMembers: makeFunctionReference("manage:listOrgMembers"),
23
+ listPlanCatalogue: makeFunctionReference("plans:listCatalogue"),
24
+ setMemberPlan: makeFunctionReference("plans:setMemberPlan"),
19
25
  mintKey: makeFunctionReference("manage:mintKey"),
20
26
  rotateKey: makeFunctionReference("manage:rotateKey"),
21
27
  revokeKey: makeFunctionReference("manage:revokeKey"),
@@ -210,6 +216,9 @@ export const api = {
210
216
  createProject: (args) => withAuth(() => client().mutation(fns.createProject, args)),
211
217
  renameProjectPrefix: (args) => withAuth(() => client().mutation(fns.renameProjectPrefix, args)),
212
218
  listKeys: (args) => withAuth(() => client().query(fns.listKeys, args)),
219
+ listOrgMembers: (args) => withAuth(() => client().query(fns.listOrgMembers, args)),
220
+ listPlanCatalogue: (args) => withAuth(() => client().query(fns.listPlanCatalogue, args)),
221
+ setMemberPlan: (args) => withAuth(() => client().mutation(fns.setMemberPlan, args)),
213
222
  mintKey: (args) => withAuth(() => client().action(fns.mintKey, args)),
214
223
  rotateKey: (args) => withAuth(() => client().action(fns.rotateKey, args)),
215
224
  revokeKey: (args) => withAuth(() => client().mutation(fns.revokeKey, args)),
@@ -604,6 +604,45 @@ export async function completeWorkspaceSetup(args) {
604
604
  // the only reader of this output, and it is the one that has to tell the human to
605
605
  // restart. A setup that finished but never says so is indistinguishable from one that
606
606
  // failed. The "what now" suggestions stay TTY-only — those are for a person browsing.
607
+ // RTSC-901 — which plan pays for this agent. Asked ONCE, here, because this is the
608
+ // moment the agent row exists and a person is already at the keyboard.
609
+ //
610
+ // Three things this must not do, all of them worse than not knowing: block a
611
+ // non-interactive run (`select` returns null and we move on), ask again when this
612
+ // human already answered for this runtime (the roster hands back an inherited plan,
613
+ // so `plan` is set and the row is skipped), or fail the bind. The bind is the
614
+ // critical path and the plan is a nicety on top of it, so ANY failure here is
615
+ // swallowed — a missing plan costs a line on the Agents page, a failed bind costs
616
+ // the whole setup.
617
+ //
618
+ // Placed before the receipt so the last thing on screen is still the next step.
619
+ //
620
+ // ONLY WHEN THE RUNTIME WAS STATED. `--runtime` defaults to claude-code, which is a
621
+ // fine LABEL for a row and a bad subject for this question: `retasc setup` wires
622
+ // every harness on the machine, so asking "which Claude plan?" of someone setting up
623
+ // Codex records a fact about the wrong product, and a wrong plan is worse than none.
624
+ // Unstated, the question goes to the first handshake instead — `setup_status` reaches
625
+ // the same `askHuman` payload with the harness that actually connected.
626
+ if (isInteractive() && opts.runtimeStated) {
627
+ try {
628
+ const [roster, catalogue] = await Promise.all([
629
+ api.listOrgMembers({ orgId }),
630
+ api.listPlanCatalogue({ orgId }),
631
+ ]);
632
+ const runtime = opts.runtime ?? "claude-code";
633
+ const unanswered = roster.filter((m) => m.isAgent && m.mine && m.canSetPlan && m.runtime === runtime && !m.plansByClass?.[m.planBucket]);
634
+ // Exactly one, or we do not know which this bind created — and asking about the
635
+ // wrong agent records a fact under the wrong name.
636
+ if (unanswered.length === 1) {
637
+ const { askAndSet } = await import("./plan.js");
638
+ const cat = catalogue;
639
+ await askAndSet(orgId, unanswered[0], cat.byClass, { otherId: cat.otherId, declinedId: cat.declinedId });
640
+ }
641
+ }
642
+ catch {
643
+ // Never let this cost the bind. `retasc plan` asks the same question later.
644
+ }
645
+ }
607
646
  console.log(`\n${NEXT_STEP}\n`);
608
647
  if (isInteractive())
609
648
  console.log(`${whatNow(pfx, emptyProject)}\n`);
@@ -0,0 +1,205 @@
1
+ import { api } from "../api.js";
2
+ import { ask, isInteractive, select } from "../lib/prompt.js";
3
+ // Every value below came off the wire, and `cli/src/lib/text.ts` is the repo's rule
4
+ // for that: an agent name or a plan label carrying \x1b[ can move the cursor, clear
5
+ // the line and rewrite the rows above it. That matters most HERE, because this
6
+ // command prints a list and then asks a question the reader answers by number, and
7
+ // `mintKey` stores `agentName` and `runtime` verbatim — so any member of the org can
8
+ // choose what lands on an owner's terminal.
9
+ import { clean } from "../lib/text.js";
10
+ // Nothing about plans is compiled in here — not the list, and not the two terminal
11
+ // answers either. `cli/src/lib/outcome.ts` records why the CLI cannot import
12
+ // `convex/lib`, and a mirrored copy of the vocabulary would be a second thing to keep
13
+ // in step. So the ids come from the catalogue payload and the runtime class comes off
14
+ // the roster row, both resolved server-side.
15
+ /** What an agent nobody has answered for prints. Not a plan, so not from the server. */
16
+ const NOT_SET = "Not set";
17
+ // RTSC-901 — `retasc plan`: which plan pays for each of your agents.
18
+ //
19
+ // Retasc cannot see this. No hook payload, transcript field or telemetry attribute
20
+ // carries the plan or the usage gauge, and the only machine source is an undocumented
21
+ // per-account endpoint that needs the human's own credential. Retasc does not ask an
22
+ // agent to read a credential store, so the fact is asked for and a person answers it.
23
+ //
24
+ // The options are READ FROM THE SERVER on every run, never compiled in. That is the
25
+ // whole design: a tier a vendor shipped this morning is on this list this afternoon,
26
+ // on the build you already have.
27
+ /** The `retasc plan` list, and the picker when an agent is named. */
28
+ export async function planAction(opts) {
29
+ const me = await api.me();
30
+ let orgId = opts.orgId;
31
+ if (!orgId) {
32
+ if (me.orgs.length === 0)
33
+ throw new Error("You're not a member of any org yet.");
34
+ if (me.orgs.length > 1) {
35
+ const list = me.orgs.map((o) => ` ${o.id} ${o.name}${o.slug ? ` (${o.slug})` : ""}`);
36
+ throw new Error(`Several orgs — pass --org-id <id>:\n${list.join("\n")}`);
37
+ }
38
+ orgId = me.orgs[0].id;
39
+ }
40
+ const [roster, catalogue] = await Promise.all([
41
+ api.listOrgMembers({ orgId: orgId }),
42
+ api.listPlanCatalogue({ orgId: orgId }),
43
+ ]);
44
+ // Yours first. An owner can set anyone's, but the question this command answers is
45
+ // almost always about the machine it is being run on.
46
+ const agents = roster
47
+ .filter((m) => m.isAgent)
48
+ .sort((a, b) => Number(b.mine) - Number(a.mine) || a.name.localeCompare(b.name));
49
+ if (agents.length === 0) {
50
+ console.log("No agents in this org yet. Run `retasc setup` to wire one up.");
51
+ return;
52
+ }
53
+ if (opts.json) {
54
+ console.log(JSON.stringify(agents.flatMap((m) => classesOf(m).map((cls) => ({
55
+ id: m.id,
56
+ name: m.name,
57
+ runtime: m.runtime,
58
+ runtimeClass: cls,
59
+ plan: m.plansByClass?.[cls] ?? null,
60
+ canSet: m.canSetPlan,
61
+ }))), null, 2));
62
+ return;
63
+ }
64
+ const chosen = opts.agent
65
+ ? agents.find((m) => m.id === opts.agent || m.name.toLowerCase().includes(opts.agent.toLowerCase()))
66
+ : null;
67
+ if (opts.agent && !chosen) {
68
+ throw new Error(`No agent matching "${clean(opts.agent)}". Run \`retasc plan\` to see them.`);
69
+ }
70
+ if (!chosen) {
71
+ // ONE LINE PER (agent, harness), which is the unit an answer fills and the unit
72
+ // the Dash's Agents page draws. Listing one line per MEMBER read the plan for the
73
+ // runtime the member was BOUND as and nothing else, so an agent running both
74
+ // Claude Code and Codex showed one of them here and the other only in the Dash —
75
+ // and the terminal could not set the one it did not show.
76
+ console.log("");
77
+ for (const m of agents) {
78
+ for (const cls of classesOf(m)) {
79
+ const own = m.plansByClass?.[cls] ?? null;
80
+ const plan = own?.label ?? NOT_SET;
81
+ const tag = own?.inherited ? " (from your other agent)" : "";
82
+ // Inlined rather than hoisted to a local: `plan901.test.ts` reads THIS file and
83
+ // requires every wire value to be cleaned at its print site, and a local hides
84
+ // which wire value the interpolation is carrying.
85
+ console.log(` ${clean(m.name).padEnd(28)} ${clean(classLabel(m, cls)).padEnd(14)} ${clean(plan)}${tag}`);
86
+ }
87
+ }
88
+ const unset = agents.reduce((n, m) => n + (m.canSetPlan ? classesOf(m).filter((c) => !m.plansByClass?.[c]).length : 0), 0);
89
+ console.log("");
90
+ if (unset > 0)
91
+ console.log(` ${unset} without a plan. Set one with \`retasc plan --agent <name>\`.`);
92
+ console.log(" You can also set these in the Dash under Agents.");
93
+ return;
94
+ }
95
+ if (!chosen.canSetPlan) {
96
+ throw new Error(`"${clean(chosen.name)}" is not yours to set. An owner or admin can set anyone's.`);
97
+ }
98
+ const cat = catalogue;
99
+ // A member can host several harnesses, and they are separate accounts with separate
100
+ // plans. Picking the agent is therefore not enough to know what is being answered —
101
+ // so when there is more than one, that is its own question, asked first.
102
+ const classes = classesOf(chosen);
103
+ let cls = classes[0];
104
+ if (classes.length > 1) {
105
+ const picked = await select(`\n${clean(chosen.name)} runs ${classes.length} harnesses. Which one?`, classes.map((c) => ({
106
+ id: c,
107
+ label: clean(classLabel(chosen, c)),
108
+ note: chosen.plansByClass?.[c] ? `Currently ${clean(chosen.plansByClass[c].label)}` : "No plan recorded",
109
+ })));
110
+ if (picked === null)
111
+ return;
112
+ cls = picked;
113
+ }
114
+ await askAndSet(orgId, {
115
+ ...chosen,
116
+ // The catalogue to offer. `unknown` is the bucket for a harness we cannot place,
117
+ // and it has no catalogue — so the picker is `other` and `declined`, which is
118
+ // the same answer the server gives for a null class.
119
+ planClass: cls === "unknown" ? null : cls,
120
+ runtimeName: classLabel(chosen, cls),
121
+ // Send the WIRE runtime for the member's own harness and the CLASS for a
122
+ // sibling. Both file under the same key, but the wire value is also recorded on
123
+ // an `other` answer as the restock signal's record of which harness said it, and
124
+ // "codex-mcp-client" teaches more there than "codex". For a sibling harness the
125
+ // roster does not tell us the wire value, so the class is the honest best.
126
+ runtime: cls === chosen.planBucket ? chosen.runtime : cls,
127
+ }, cat.byClass, { otherId: cat.otherId, declinedId: cat.declinedId });
128
+ }
129
+ /** The harness classes this agent should be answered for. `planClasses` is the
130
+ * server's set, built from the member's own session keys; the bound runtime is the
131
+ * fallback for a Dash loaded against an older backend. */
132
+ function classesOf(m) {
133
+ const list = m.planClasses ?? [];
134
+ return list.length > 0 ? list : [m.planBucket];
135
+ }
136
+ /** What to call one of an agent's harnesses. The member's own runtime keeps its full
137
+ * name ("Claude Code"); a sibling harness is named by its class, which is all the
138
+ * roster knows about it. */
139
+ function classLabel(m, cls) {
140
+ if (cls === m.planBucket && m.runtimeName)
141
+ return m.runtimeName;
142
+ return CLASS_NAMES[cls] ?? cls;
143
+ }
144
+ /** Class -> display name. Small and local: these are the eight buckets in
145
+ * `convex/lib/runtime.ts`, and the CLI cannot import it (see `lib/outcome.ts`). A
146
+ * class not here prints as itself, which is the same rule `runtimeLabel` follows. */
147
+ const CLASS_NAMES = {
148
+ claude: "Claude Code",
149
+ codex: "Codex",
150
+ opencode: "OpenCode",
151
+ gemini: "Gemini",
152
+ cursor: "Cursor",
153
+ grok: "Grok",
154
+ ci: "CI",
155
+ unknown: "Unreported",
156
+ };
157
+ /**
158
+ * Put the list to the human and record what they pick.
159
+ *
160
+ * Exported so `retasc setup` asks the identical question through the identical code.
161
+ * Two copies of a question drift into two questions.
162
+ */
163
+ export async function askAndSet(orgId, agent, byClass,
164
+ /** `otherId` / `declinedId`, straight from the catalogue payload. */
165
+ ids) {
166
+ if (!isInteractive())
167
+ return false;
168
+ const options = (agent.planClass && byClass[agent.planClass]) || [];
169
+ const name = clean(agent.runtimeName || "this runtime");
170
+ const who = clean(agent.name);
171
+ const choices = [
172
+ // The catalogue is ours, but it is still served: clean it like anything else.
173
+ ...options.map((o) => ({ id: o.planId, label: clean(o.label), note: o.note ? clean(o.note) : null })),
174
+ // Always last, always present. The catalogue is knowingly incomplete, so there has
175
+ // to be somewhere for the plan we have not heard of to go — and on a runtime we do
176
+ // not know at all, these two ARE the list.
177
+ { id: ids.otherId, label: "Something else", note: "A plan that is not listed" },
178
+ { id: ids.declinedId, label: "Not shared", note: "Nothing is recorded, and you will not be asked again" },
179
+ ];
180
+ const heading = options.length > 0
181
+ ? `\nWhich plan pays for ${who}'s ${name} usage?`
182
+ : `\nRetasc has no plan list for ${name} yet. Which plan pays for ${who}?`;
183
+ const picked = await select(heading, choices);
184
+ if (picked === null)
185
+ return false;
186
+ let label;
187
+ if (picked === ids.otherId) {
188
+ label = await ask("Plan name, as it is billed: ");
189
+ if (!label.trim()) {
190
+ console.log("Nothing typed, so nothing recorded. Run `retasc plan` when you know it.");
191
+ return false;
192
+ }
193
+ }
194
+ // `agent.runtime` is what the CALLER decided this answer is about: the member's wire
195
+ // runtime for its own harness, the class for a sibling. It used to be the member's
196
+ // bound runtime unconditionally, so choosing the Codex line wrote the Claude key —
197
+ // the answer vanished from the row that asked for it and appeared on one that had
198
+ // not. `planStorageClassOf` maps a class to itself, so both spellings file alike.
199
+ const res = await api.setMemberPlan({ orgId, memberId: agent.id, runtime: agent.runtime ?? "", planId: picked, label });
200
+ console.log(`\n ${who}: ${clean(res.label)}`);
201
+ // Both doors, every time. A person told once where to change something will not
202
+ // remember; a person told at the moment they answer knows they are not locked in.
203
+ console.log(" Change it any time with `retasc plan`, or in the Dash under Agents.\n");
204
+ return true;
205
+ }
package/dist/index.js CHANGED
@@ -231,7 +231,16 @@ program
231
231
  // at exit 0 instead of thrown. Structured output an agent cannot act on, or a door
232
232
  // that leads to a thrown Error, is each worse than neither.
233
233
  .option("--json", "Report machine-readable outcomes (for an agent driving setup)")
234
- .action(async (opts) => {
234
+ .action(async (opts, cmd) => {
235
+ // RTSC-901 — was the runtime STATED, or is it the default sitting there?
236
+ //
237
+ // `--runtime` labels the agent row and defaults to claude-code, which is right for
238
+ // the label: most binds are Claude Code and a row needs a name. It is NOT right as
239
+ // the subject of the plan question. `retasc setup` wires every harness on the
240
+ // machine, so the folder rarely has one provider, and asking "which Claude plan?"
241
+ // of someone setting up Codex records a fact about the wrong product. When the
242
+ // flag was not stated, the plan question is left to the handshake, which knows.
243
+ opts.runtimeStated = cmd.getOptionValueSource("runtime") === "cli";
235
244
  // BEFORE requireLogin: the whole point is a machine that has never signed in. The
236
245
  // token is the authorization, and asking for a session here would refuse every
237
246
  // caller this flag exists for.
@@ -596,6 +605,19 @@ program
596
605
  // RTSC-780 — the once-per-machine half of setup. `bind` runs this itself the first
597
606
  // time, so most people never type it; it exists on its own for the second harness
598
607
  // installed after a bind, and for re-running after an upgrade moved the launcher.
608
+ // RTSC-901 — which plan pays for each agent. Asked once at setup and changeable here
609
+ // forever after, which is the other half of the promise the setup receipt makes.
610
+ program
611
+ .command("plan")
612
+ .description("Show or set which plan pays for each of your agents.")
613
+ .option("--org-id <id>", "Org, when you're in more than one")
614
+ .option("--agent <nameOrId>", "Set the plan for one agent")
615
+ .option("--json", "Machine-readable")
616
+ .allowExcessArguments(false)
617
+ .action(async (opts) => {
618
+ const { planAction } = await import("./commands/plan.js");
619
+ await planAction(opts);
620
+ });
599
621
  program
600
622
  .command("setup")
601
623
  .description("Detect the MCP harnesses on this machine and wire Retasc into each, once.")
@@ -36,3 +36,43 @@ export async function confirm(question, assumeYes) {
36
36
  const a = (await ask(`${question} [y/N] `)).toLowerCase();
37
37
  return a === "y" || a === "yes";
38
38
  }
39
+ /**
40
+ * Ask a human to PICK, by number, never to type a name (RTSC-901).
41
+ *
42
+ * Typing is how a plan called "Max 20x" gets recorded as "max20", "Max20x" and
43
+ * "MAX 20X" across three machines and then fails to group. Numbers make the common
44
+ * answer mechanical and leave typing for the case that genuinely needs it, which is
45
+ * an option this list does not have.
46
+ *
47
+ * Returns the chosen `id`, or `null` when there is nobody to ask: a non-interactive
48
+ * run SKIPS the question rather than blocking a script or guessing an answer. A
49
+ * question nobody answered has to stay unanswered — that is the difference between
50
+ * "not asked" and a wrong fact recorded forever.
51
+ *
52
+ * Enter alone takes `defaultId` when one is given, so the common path is one keypress.
53
+ */
54
+ export async function select(question, choices, defaultId) {
55
+ if (!isInteractive() || choices.length === 0)
56
+ return null;
57
+ stdout.write(`${question}\n`);
58
+ for (let i = 0; i < choices.length; i++) {
59
+ const c = choices[i];
60
+ const mark = c.id === defaultId ? " (default)" : "";
61
+ stdout.write(` ${String(i + 1).padStart(2)}. ${c.label}${mark}\n`);
62
+ if (c.note)
63
+ stdout.write(` ${c.note}\n`);
64
+ }
65
+ // Three tries, then give up rather than loop at someone. A person who has typed
66
+ // nonsense three times is not going to type a number on the fourth, and a CLI that
67
+ // will not let go of the terminal is worse than an unanswered question.
68
+ for (let attempt = 0; attempt < 3; attempt++) {
69
+ const raw = await ask(defaultId ? `Number [${choices.findIndex((c) => c.id === defaultId) + 1}]: ` : "Number: ");
70
+ if (!raw && defaultId)
71
+ return defaultId;
72
+ const n = Number(raw);
73
+ if (Number.isInteger(n) && n >= 1 && n <= choices.length)
74
+ return choices[n - 1].id;
75
+ stdout.write(` Pick a number between 1 and ${choices.length}.\n`);
76
+ }
77
+ return null;
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.49.1",
3
+ "version": "1.50.0",
4
4
  "description": "Retasc CLI — 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": {