@retasc/cli 1.9.0 → 1.10.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.
@@ -0,0 +1,96 @@
1
+ import { api } from "../api.js";
2
+ import { clean } from "../lib/text.js";
3
+ import { isInteractive } from "./bind.js";
4
+ import { identityLoop } from "./join.js";
5
+ /**
6
+ * Which org's placeholders to offer.
7
+ *
8
+ * Same rule as `retasc billing`: explicit `--org-id` wins, one membership needs no
9
+ * question, several must be named. Deliberately NOT the folder binding — that resolves
10
+ * through an agent key, and this is a question only a signed-in human can answer, so
11
+ * reading identity off the folder would name an org this person might not even be in.
12
+ */
13
+ export function resolveOrg(orgs, orgId) {
14
+ // Org names are human-typed and land in a terminal that acts on escape sequences, the
15
+ // same rule the ghost rows follow. `clean` here rather than at each interpolation so a
16
+ // later line added to this list cannot forget it.
17
+ const list = () => orgs.map((o) => ` ${o.id} ${clean(o.name)}${o.slug ? ` (${clean(o.slug)})` : ""}`);
18
+ if (orgId) {
19
+ const found = orgs.find((o) => o.id === orgId);
20
+ // REFUSE an id we can't see, rather than passing it through for the server to reject.
21
+ // The server does not reject it: `memberOf` returns null for a non-member AND for a
22
+ // suspended one, and `claimableGhosts` maps null to `{asked: true}` — the exact shape a
23
+ // permanent decline returns. Passing through would make this command answer "you've
24
+ // already said none of these are you" to someone who simply typed the wrong id, which
25
+ // is a false statement about their own history and the one thing it must never say.
26
+ if (!found) {
27
+ throw new Error(`You're not an active member of ${clean(orgId)} — or that isn't an org id.` +
28
+ (orgs.length ? `\nOrgs you can ask about:\n${list().join("\n")}` : ""));
29
+ }
30
+ return found;
31
+ }
32
+ if (orgs.length === 0)
33
+ throw new Error("You're not a member of any org yet.");
34
+ if (orgs.length > 1) {
35
+ throw new Error(`Several orgs — pass --org-id <id>:\n${list().join("\n")}`);
36
+ }
37
+ return orgs[0];
38
+ }
39
+ /** What to print once the loop is done. `join` prints nothing; this command must. */
40
+ function report(outcome, orgLabel) {
41
+ switch (outcome) {
42
+ case "claimed":
43
+ case "unavailable":
44
+ // Both already said their piece, line by line, as they happened.
45
+ return;
46
+ case "none":
47
+ // The common answer, and a genuinely good one — say it plainly rather than exiting
48
+ // silently, which reads like the command failed to run.
49
+ console.log(`Nothing waiting for you in ${orgLabel}.`);
50
+ console.log("Run this again after a migration — each import brings its own people across.");
51
+ return;
52
+ case "answered":
53
+ // The permanent decline. Worth its own wording: from in here it is indistinguishable
54
+ // from "none", and someone who declined during an earlier import and now has a real
55
+ // placeholder from a NEW one would otherwise read "nothing waiting" as the truth.
56
+ console.log(`You've already said none of the imported people in ${orgLabel} are you.`);
57
+ console.log("That answer covers the whole org, including later migrations.");
58
+ console.log("If a newer import did carry you across, an owner can link it from the Team page.");
59
+ return;
60
+ case "declined":
61
+ console.log("Noted — you won't be asked again.");
62
+ return;
63
+ case "left":
64
+ console.log("Nothing linked. Run `retasc identity` again whenever you want to look.");
65
+ return;
66
+ case "skipped":
67
+ // Only reachable non-interactively; the interactive guard below catches that first.
68
+ return;
69
+ }
70
+ }
71
+ export async function identityAction(opts, deps = {}) {
72
+ const d = {
73
+ me: () => api.me(),
74
+ // One implementation of the irreversible question, shared with `join`. Two surfaces
75
+ // asking it in two different voices is how someone ends up answering the one they trust
76
+ // less — the same reason its wording already mirrors the Dash notice.
77
+ loop: (orgId, orgLabel) => identityLoop(orgId, {}, orgLabel),
78
+ interactive: isInteractive,
79
+ ...deps,
80
+ };
81
+ // Refuse up front rather than exiting 0 in silence. `identityLoop` returns "skipped"
82
+ // without a TTY — correct inside `join`, where the folder setup is the point and the
83
+ // question is optional, and wrong here, where the question IS the command.
84
+ if (!d.interactive()) {
85
+ throw new Error("`retasc identity` needs an interactive terminal — linking an imported person is irreversible, so it always asks first.");
86
+ }
87
+ const me = await d.me();
88
+ const org = resolveOrg(me.orgs, opts.orgId);
89
+ // The NAME alone, not "name (slug)". It lands mid-sentence in both surfaces — "When Acme
90
+ // migrated…" and "Nothing waiting for you in Acme." The slug only earns its place in the
91
+ // several-orgs error, where it is there to be copied.
92
+ const orgLabel = clean(org.name);
93
+ const outcome = await d.loop(org.id, orgLabel);
94
+ report(outcome, orgLabel);
95
+ return outcome;
96
+ }
@@ -53,19 +53,28 @@ deps = {}) {
53
53
  // authorship AND their dispatch lane onto your account, irreversibly, and there is no
54
54
  // CLI path back. A scripted run has nobody to be wrong on behalf of.
55
55
  if (!d.interactive() || opts.yes)
56
- return;
56
+ return "skipped";
57
+ // Whether anything was linked BEFORE the round that ends the loop. A claim is followed by
58
+ // another round (a second migrated tool may still be offerable), and that round normally
59
+ // ends in "nothing left" — which must not erase the claim that just happened.
60
+ let claimedAny = false;
57
61
  for (let round = 0; round < MAX_IDENTITY_ROUNDS; round++) {
58
62
  let ghosts;
59
63
  try {
60
64
  const res = await d.claimableGhosts({ orgId });
61
- if (res.asked || !res.ghosts.length)
62
- return;
65
+ // Two different endings the server returns on one shape. "Answered" is this human's
66
+ // permanent org-wide decline; "none" is simply an empty list, and they can be asked
67
+ // again after the next migration. `join` treats both as silence; `identity` does not.
68
+ if (res.asked)
69
+ return claimedAny ? "claimed" : "answered";
70
+ if (!res.ghosts.length)
71
+ return claimedAny ? "claimed" : "none";
63
72
  ghosts = res.ghosts;
64
73
  }
65
74
  catch (e) {
66
75
  const { message } = formatError(e);
67
76
  console.error(` ! Couldn't check for imported history (${message}). Carrying on.`);
68
- return;
77
+ return claimedAny ? "claimed" : "unavailable";
69
78
  }
70
79
  console.log(round === 0
71
80
  ? `\nWhen ${orgLabel ?? "this org"} migrated, some people were carried across.\n` +
@@ -87,8 +96,13 @@ deps = {}) {
87
96
  }
88
97
  catch (e) {
89
98
  console.error(` ! ${formatError(e).message}`);
99
+ // NOT "declined". A caller answers that with "you won't be asked again", and the
100
+ // write is the only thing that makes it true — the flag never landed, so they WILL
101
+ // be asked again. The error is already on stderr; `unavailable` adds no second
102
+ // sentence on top of it rather than a reassuring false one.
103
+ return "unavailable";
90
104
  }
91
- return;
105
+ return "declined";
92
106
  }
93
107
  // Confirm before claiming — it is irreversible and it moves dispatch routing, so it
94
108
  // must not be a single keystroke on a row in a list. Same qualitative wording as the
@@ -106,6 +120,7 @@ deps = {}) {
106
120
  try {
107
121
  const res = await d.claimGhost({ orgId, memberId: chosen.id });
108
122
  console.log(`✓ Linked ${clean(res.name)}.`);
123
+ claimedAny = true;
109
124
  }
110
125
  catch (e) {
111
126
  // SOURCE_ALREADY_CLAIMED, IMPORT_RUNNING, NOT_CLAIMABLE — all readable, all reachable
@@ -114,11 +129,14 @@ deps = {}) {
114
129
  console.error(` ✗ ${code ? `${code}: ` : ""}${message}`);
115
130
  if (hint)
116
131
  console.error(` → ${hint}`);
117
- return;
132
+ return claimedAny ? "claimed" : "unavailable";
118
133
  }
119
134
  // Claiming one ClickUp identity removes EVERY remaining ClickUp row (`claimableGhosts`
120
135
  // filters by spent source), so the next round can only ever offer a different tool.
121
136
  }
137
+ // Round budget spent. Only reachable by declining individual rows over and over — the
138
+ // list is unchanged each time, so nothing was linked and nothing was answered for good.
139
+ return claimedAny ? "claimed" : "left";
122
140
  }
123
141
  export async function joinAction(link, opts) {
124
142
  // Client-side, before anything else: the code is what the server matches, and someone
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import { installGate } from "./commands/gate.js";
7
7
  import { claimAction } from "./commands/claim.js";
8
8
  import { bindAction } from "./commands/bind.js";
9
9
  import { joinAction } from "./commands/join.js";
10
+ import { identityAction } from "./commands/identity.js";
10
11
  import { doctorAction } from "./commands/doctor.js";
11
12
  import { billingAction } from "./commands/billing.js";
12
13
  import { isNetworkError, readLocalBinding, resolveBinding } from "./lib/binding.js";
@@ -372,11 +373,30 @@ program
372
373
  .option("--project-id <id>", "Which project to bind to (skips the picker)")
373
374
  .option("--agent <name>", "Agent member name (default: auto)")
374
375
  .option("--runtime <runtime>", "Agent runtime", "claude-code")
375
- .option("-y, --yes", "Don't prompt to replace an existing binding, and skip the identity question")
376
+ // RTSC-477 name the way back. `--yes` skips the identity question and must never answer
377
+ // it, so the flag that causes the gap is the right place to say how to close it.
378
+ .option("-y, --yes", "Don't prompt to replace an existing binding, and skip the identity question (ask it later with `retasc identity`)")
376
379
  .allowExcessArguments(false)
377
380
  .action(async (link, opts) => {
378
381
  await joinAction(link, opts).catch(fail);
379
382
  });
383
+ // RTSC-477 — the same question `join` asks, on demand. `join` fires once, at the moment you
384
+ // accept an invite; placeholders arrive with EVERY migration, and claiming is per source, so
385
+ // the question recurs and needed a surface that recurs with it.
386
+ //
387
+ // No `-y/--yes`, and no `identity claim <name>` subcommand, on purpose: a claim is
388
+ // irreversible and moves dispatch routing, so it is never answered on a script's behalf.
389
+ // `allowExcessArguments(false)` therefore rejects `retasc identity claim …` outright rather
390
+ // than ignoring the words and prompting anyway.
391
+ program
392
+ .command("identity")
393
+ .description("Link imported history to your account: shows the people a migration carried into this org and asks which one is you.")
394
+ .option("--org-id <id>", "Which org (defaults to your only one).")
395
+ .allowExcessArguments(false)
396
+ .action(async (opts) => {
397
+ requireLogin();
398
+ await identityAction({ orgId: opts.orgId }).catch(fail);
399
+ });
380
400
  // --- mcp wiring ------------------------------------------------------------
381
401
  const mcp = program.command("mcp").description("Wire the Retasc MCP server into your agent.");
382
402
  mcp
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.9.0",
3
+ "version": "1.10.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": {