@retasc/cli 1.18.0 → 1.20.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,44 @@ 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.20.0 (2026-08-04)
10
+
11
+ - **RTSC-561** — the org gained an **admin** role, and the CLI stopped refusing it.
12
+ `retasc billing` gated on `role !== "owner"` client-side, so it would have refused an
13
+ admin locally for a call the server allows. Owner-only copy in `bind` and the command
14
+ help now names both roles.
15
+
16
+ ## 1.19.0 (2026-08-03)
17
+
18
+ - **RTSC-495** — `retasc bind --setup <code>` sets a folder up with no sign-in and nothing
19
+ to answer, so an agent can do it on behalf of someone who does not use a terminal.
20
+
21
+ The Dash's connect step now hands over a block you paste to your agent, with this command
22
+ inside it. Everything the interactive `bind` would ask was already answered in the
23
+ browser, and the code carries those answers across. An agent's shell is not a TTY, so the
24
+ ordinary path refuses it outright at the first prompt.
25
+
26
+ ```
27
+ npx @retasc/cli bind --setup rtscsetup_…
28
+ ```
29
+
30
+ The code is single-use and lives ten minutes. The alternative — pasting a real API key
31
+ into the prompt — needed no backend at all, but would have left a long-lived credential in
32
+ an agent's conversation history forever.
33
+
34
+ A folder that is already connected is refused rather than silently replaced, and the
35
+ refusal happens BEFORE the code is spent, so a mix-up costs nothing.
36
+
37
+ - **RTSC-532** — the agent says which folder it is in, and the key remembers.
38
+
39
+ Binding the wrong folder used to look exactly like success: the agent still called in, so
40
+ the Dash went green, while the folder you actually work in had no Retasc in it. The prompt
41
+ now asks the agent to report its full path and wait before it runs anything, and the
42
+ confirmation names the real path instead of "This folder".
43
+
44
+ The key it creates is named after that folder, so the Keys page shows which folder each
45
+ key belongs to (`client-a`) instead of naming them all after the project (`ENG key`).
46
+
9
47
  ## 1.18.0 (2026-08-02)
10
48
 
11
49
  - **RTSC-530** — setting up from scratch now asks where your work comes from, and imports it
package/dist/api.js CHANGED
@@ -43,6 +43,10 @@ const fns = {
43
43
  claimableGhosts: makeFunctionReference("ghosts:claimableGhosts"),
44
44
  claimGhost: makeFunctionReference("ghosts:claimGhost"),
45
45
  dismissGhostPrompt: makeFunctionReference("ghosts:dismissGhostPrompt"),
46
+ // RTSC-495 — the ONLY unauthenticated call the CLI makes. Redeemed by `bind --setup`
47
+ // on a machine that has never signed in, because the human answered everything in the
48
+ // Dash and it is her agent, not her, running this.
49
+ redeemSetupToken: makeFunctionReference("setupToken:redeemSetupToken"),
46
50
  };
47
51
  function client() {
48
52
  const cfg = loadConfig();
@@ -51,6 +55,18 @@ function client() {
51
55
  c.setAuth(cfg.token);
52
56
  return c;
53
57
  }
58
+ /**
59
+ * A client that NEVER attaches a session (RTSC-495).
60
+ *
61
+ * `client()` attaches `cfg.token` whenever one exists, and a setup-token redeem must not
62
+ * depend on it either way: the machine usually has no session at all, and where it has a
63
+ * STALE one, sending it risks failing an auth check on a call that never needed identity.
64
+ * Separate function rather than a flag, so "this call is unauthenticated" is visible at
65
+ * the call site instead of buried in an argument.
66
+ */
67
+ function anonClient() {
68
+ return new ConvexHttpClient(loadConfig().deploymentUrl);
69
+ }
54
70
  /**
55
71
  * Read a caught backend error into the parts a command wants to show (RTSC-261).
56
72
  *
@@ -178,6 +194,9 @@ export const api = {
178
194
  runImport: (args) => withAuth(() => client().action(fns.runImport, args)),
179
195
  latestImport: (args) => withAuth(() => client().query(fns.latestImport, args)),
180
196
  importHistory: (args) => withAuth(() => client().query(fns.importHistory, args)),
197
+ // NOT wrapped in `withAuth`: there is no session to refresh, and its retry path would
198
+ // start a device flow — the exact interactive prompt this whole flow exists to avoid.
199
+ redeemSetupToken: (args) => anonClient().action(fns.redeemSetupToken, args),
181
200
  claimableGhosts: (args) => withAuth(() => client().query(fns.claimableGhosts, args)),
182
201
  claimGhost: (args) => withAuth(() => client().mutation(fns.claimGhost, args)),
183
202
  dismissGhostPrompt: (args) => withAuth(() => client().mutation(fns.dismissGhostPrompt, args)),
@@ -44,8 +44,10 @@ export async function billingAction(opts) {
44
44
  const org = me.orgs.find((o) => o.id === orgId);
45
45
  orgLabel = org ? `${org.name}${org.slug ? ` (${org.slug})` : ""}` : String(orgId);
46
46
  // Fail early with the actionable message rather than a raw FORBIDDEN from the server.
47
- if (org && org.role !== "owner") {
48
- throw new Error(`Billing is owner-only your role in ${orgLabel} is "${org.role}".`);
47
+ // Owner OR admin (RTSC-561) this mirrors `requireOwnerOrAdmin`, and a stale copy of
48
+ // the rule here would refuse an admin locally for a call the server would have allowed.
49
+ if (org && org.role !== "owner" && org.role !== "admin") {
50
+ throw new Error(`Billing needs the owner or admin role — yours in ${orgLabel} is "${org.role}".`);
49
51
  }
50
52
  const [status, charges] = await Promise.all([
51
53
  api.billingStatus({ orgId: orgId }),
@@ -1,3 +1,4 @@
1
+ import { basename } from "node:path";
1
2
  import { api, cliError } from "../api.js";
2
3
  import { deviceLogin } from "../auth.js";
3
4
  import { loadConfig, patchConfig } from "../config.js";
@@ -285,7 +286,7 @@ export async function completeWorkspaceSetup(args) {
285
286
  let prefix;
286
287
  if (!projectId && opts.project && opts.prefix) {
287
288
  if (!args.canCreateProject) {
288
- cliError("FORBIDDEN", "Only an owner can create a project.", `Ask an owner to add one, then pass --project-id <id>.`);
289
+ cliError("FORBIDDEN", "Only an owner or admin can create a project.", `Ask one of them to add one, then pass --project-id <id>.`);
289
290
  }
290
291
  const p = (await api.createProject({ orgId, name: opts.project, prefix: opts.prefix }));
291
292
  projectId = p.projectId;
@@ -300,7 +301,7 @@ export async function completeWorkspaceSetup(args) {
300
301
  // what to ask for, by name. A bare FORBIDDEN from `createProject` would be both
301
302
  // wrong (they never asked to create anything) and unactionable.
302
303
  if (!list.length) {
303
- cliError("NO_PROJECTS", `Org ${org} has no projects yet, and only an owner can create one.`, `Ask an owner to add a project, then finish this folder with: ${selfCommand(VERSION)} bind --org-id ${orgId}`);
304
+ cliError("NO_PROJECTS", `Org ${org} has no projects yet, and only an owner or admin can create one.`, `Ask one of them to add a project, then finish this folder with: ${selfCommand(VERSION)} bind --org-id ${orgId}`);
304
305
  }
305
306
  // One project is not a choice. Asking anyway would be the only question in the
306
307
  // common invite path, for an answer that was never in doubt.
@@ -416,6 +417,53 @@ export async function completeWorkspaceSetup(args) {
416
417
  if (isInteractive())
417
418
  console.log(`\n${NEXT_STEP}`);
418
419
  }
420
+ export async function setupFromToken(token, opts, deps = {}) {
421
+ const cfg = loadConfig();
422
+ const cwd = process.cwd();
423
+ const guard = deps.guard ?? rebindGuard;
424
+ const redeem = deps.redeem ??
425
+ ((t, label) => api.redeemSetupToken({ token: t, label }));
426
+ // BEFORE the redeem. `rebindGuard` prints its own explanation and, with no TTY,
427
+ // refuses with exit 1 unless `--yes` — which is exactly the required behaviour, so it
428
+ // is reused rather than restated. No org/project passed: its idempotent-converge
429
+ // shortcut needs ids we do not have yet, and cannot apply to a single-use token anyway.
430
+ const { proceed, existing } = await guard({ cwd, mcpUrl: cfg.mcpUrl, yes: opts.yes });
431
+ if (!proceed)
432
+ return;
433
+ // Resolve (and if needed install) `retasc` BEFORE the token is spent, so a machine that
434
+ // cannot get a working launcher fails while the code is still redeemable.
435
+ const install = await chooseInstall(opts.install);
436
+ const launcher = resolveLauncher({ version: VERSION, install });
437
+ const note = launcherNote(launcher);
438
+ if (note)
439
+ console.log(note);
440
+ // RTSC-532 — the LEAF name, never the full path. It names the key in the Dash, so it
441
+ // has to be recognisable ("client-a → ENG") without publishing where on her disk it
442
+ // sits. `basename` of the cwd is exactly that.
443
+ const redeemed = await redeem(token.trim(), basename(cwd));
444
+ const workspaceId = existing?.workspaceId ?? newWorkspaceId();
445
+ (deps.bind ?? setBinding)(workspaceId, {
446
+ orgId: redeemed.orgId,
447
+ projectId: redeemed.projectId,
448
+ key: redeemed.key,
449
+ url: cfg.mcpUrl,
450
+ prefix: redeemed.prefix,
451
+ orgName: redeemed.orgName,
452
+ boundPath: cwd,
453
+ createdAt: Date.now(),
454
+ });
455
+ (deps.marker ?? installMarker)({ workspaceId, scope: "local", launcher });
456
+ // RTSC-532 — name the PATH, not "this folder". A binding is folder → project, and
457
+ // until this line said which folder, a wrong one was undetectable: the agent still
458
+ // calls in, so the Dash shows success while the folder she actually works in has no
459
+ // Retasc tools. Her agent relays this sentence to her, so it is also the last chance
460
+ // to notice.
461
+ console.log(`✓ ${clean(cwd)} is connected to ${clean(redeemed.orgName)} / ${clean(redeemed.prefix)}.`);
462
+ // NOT gated on isInteractive(), unlike the interactive path's copy of this line. There
463
+ // is never a TTY here, and this is the one instruction the agent has to pass on — the
464
+ // Dash waits on a tool call that cannot happen until the restart does (RTSC-496).
465
+ console.log(`\n${NEXT_STEP}`);
466
+ }
419
467
  /**
420
468
  * What to do with a workspace that is now set up.
421
469
  *
@@ -223,8 +223,9 @@ export async function joinAction(link, opts) {
223
223
  orgLabel,
224
224
  opts,
225
225
  existing: guard.existing,
226
- // `createProject` is `requireOwner`, and an invite only ever grants `member`. Offering
227
- // "+ create new" here would be offering a refusal dressed as a choice.
226
+ // `createProject` is `requireOwnerOrAdmin`, and an invite only ever grants `member`
227
+ // (admin is granted by `setMemberRole` after joining, never by a bearer code
228
+ // RTSC-561). Offering "+ create new" here would be offering a refusal as a choice.
228
229
  canCreateProject: false,
229
230
  });
230
231
  }
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { loadConfig, patchConfig, saveConfig, configPath, isLoggedIn } from "./c
5
5
  import { installMcp, normalizeScope } from "./commands/mcp.js";
6
6
  import { installGate } from "./commands/gate.js";
7
7
  import { claimAction } from "./commands/claim.js";
8
- import { bindAction } from "./commands/bind.js";
8
+ import { bindAction, setupFromToken } from "./commands/bind.js";
9
9
  import { joinAction } from "./commands/join.js";
10
10
  import { identityAction } from "./commands/identity.js";
11
11
  import { importAction } from "./commands/import.js";
@@ -183,7 +183,22 @@ program
183
183
  // is present; this is how a scripted run, or a developer whose own agent runs setup,
184
184
  // says no up front instead of being installed onto.
185
185
  .option("--no-install", "Don't install `retasc` on this machine; wire the pinned npx launcher instead")
186
+ // RTSC-495 — the agent's door. Everything this command normally asks was already
187
+ // answered in the Dash, so the token stands in for all of it and nothing is prompted.
188
+ .option("--setup <token>", "Complete setup from a Dash setup code — no sign-in, no prompts")
186
189
  .action(async (opts) => {
190
+ // BEFORE requireLogin: the whole point is a machine that has never signed in. The
191
+ // token is the authorization, and asking for a session here would refuse every
192
+ // caller this flag exists for.
193
+ //
194
+ // `!== undefined`, not truthiness: `--setup ""` is a stated intent to use a setup
195
+ // code, and it must fail as a bad code. Falling through to the interactive path
196
+ // would answer it with "Not signed in", which is the one diagnosis that sends the
197
+ // reader looking in exactly the wrong place.
198
+ if (opts.setup !== undefined) {
199
+ await setupFromToken(opts.setup, opts).catch(fail);
200
+ return;
201
+ }
187
202
  requireLogin();
188
203
  await bindAction(opts).catch(fail);
189
204
  });
@@ -196,7 +211,7 @@ program
196
211
  });
197
212
  program
198
213
  .command("billing")
199
- .description("Show the org's billing: subscription, what's owed now, and the charge + on-chain payment history across every payment link ever used (owner only).")
214
+ .description("Show the org's billing: subscription, what's owed now, and the charge + on-chain payment history across every payment link ever used (owner or admin).")
200
215
  .option("--org-id <id>", "Which org (defaults to your only one).")
201
216
  .option("--json", "Emit the raw payload instead of the summary.")
202
217
  .action(async (opts) => {
@@ -341,7 +356,7 @@ key
341
356
  const members = program.command("members").description("Invite people to an org and manage invites.");
342
357
  members
343
358
  .command("invite")
344
- .description("Mint a single-use invite code for an org (owner only). Shown once.")
359
+ .description("Mint a single-use invite code for an org (owner or admin). Shown once.")
345
360
  .requiredOption("--org-id <id>")
346
361
  .option("--expires-days <n>", "Days until the code expires (1–30, default 7)", (v) => parseInt(v, 10))
347
362
  .action(async (opts) => {
@@ -362,7 +377,7 @@ members
362
377
  });
363
378
  members
364
379
  .command("list")
365
- .description("List an org's invites and their status (owner only).")
380
+ .description("List an org's invites and their status (owner or admin).")
366
381
  .requiredOption("--org-id <id>")
367
382
  .option("--json", "Emit the raw payload instead of the table.")
368
383
  .action(async (opts) => {
@@ -377,7 +392,7 @@ members
377
392
  });
378
393
  members
379
394
  .command("revoke")
380
- .description("Revoke an unused invite (owner only).")
395
+ .description("Revoke an unused invite (owner or admin).")
381
396
  .requiredOption("--invite-id <id>")
382
397
  .action(async (opts) => {
383
398
  requireLogin();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.18.0",
3
+ "version": "1.20.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": {