@retasc/cli 1.9.0 → 1.11.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/auth.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ConvexHttpClient } from "convex/browser";
2
2
  import { makeFunctionReference } from "convex/server";
3
3
  import { loadConfig, patchConfig } from "./config.js";
4
+ import { ask, isInteractive } from "./lib/prompt.js";
4
5
  // The GitHub OAuth App's PUBLIC client id (safe to ship — device flow needs no
5
6
  // secret). Baked in so `retasc login` works out-of-the-box; override via env.
6
7
  const GITHUB_CLIENT_ID = process.env.RETASC_GITHUB_CLIENT_ID ?? "Ov23linqRy875IU8OYTW";
@@ -8,9 +9,16 @@ const DEVICE_CODE_URL = "https://github.com/login/device/code";
8
9
  const TOKEN_URL = "https://github.com/login/oauth/access_token";
9
10
  const GRANT = "urn:ietf:params:oauth:grant-type:device_code";
10
11
  // Convex Auth's own public sign-in action. We call it with the "github-device"
11
- // credentials provider; it verifies the GitHub token server-side and returns a
12
- // session ({ tokens: { token, refreshToken } }).
12
+ // or "google-device" credentials provider; it verifies the upstream token
13
+ // server-side and returns a session ({ tokens: { token, refreshToken } }).
13
14
  const signIn = makeFunctionReference("auth:signIn");
15
+ // RTSC-508 — the Google device flow's two legs, both server-side. Google, unlike
16
+ // GitHub, requires a client SECRET on the token exchange, and a published npm
17
+ // package cannot hold one. So there is deliberately no Google client id in this
18
+ // file either: the CLI never talks to Google directly, so it never needs one, and
19
+ // rotating that client becomes a Convex env change with no CLI release.
20
+ const googleDeviceStart = makeFunctionReference("googleDevice:start");
21
+ const googleDevicePoll = makeFunctionReference("googleDevice:poll");
14
22
  async function postForm(url, body) {
15
23
  const res = await fetch(url, {
16
24
  method: "POST",
@@ -20,11 +28,14 @@ async function postForm(url, body) {
20
28
  return res.json();
21
29
  }
22
30
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
23
- /**
24
- * Run the full GitHub device-flow login and persist the resulting Convex Auth
25
- * session to ~/.retasc/config.json. Prints the user code + verification URL.
26
- */
27
- export async function deviceLogin() {
31
+ /** Print the two lines a human has to act on, identically for both doors. */
32
+ function announceCode(where, url, code) {
33
+ console.log(`\n Open: ${url}`);
34
+ console.log(` Enter code: ${code}\n`);
35
+ console.log(` Waiting for ${where} authorization…`);
36
+ }
37
+ /** Run GitHub's device flow (talking to GitHub directly) and return its access token. */
38
+ async function githubDeviceToken() {
28
39
  if (!GITHUB_CLIENT_ID) {
29
40
  throw new Error("GitHub client id not configured. Set RETASC_GITHUB_CLIENT_ID (the OAuth App's public Client ID).");
30
41
  }
@@ -35,13 +46,10 @@ export async function deviceLogin() {
35
46
  if (!start.device_code) {
36
47
  throw new Error(`GitHub device-flow start failed: ${JSON.stringify(start)}`);
37
48
  }
38
- console.log(`\n Open: ${start.verification_uri}`);
39
- console.log(` Enter code: ${start.user_code}\n`);
40
- console.log(" Waiting for authorization…");
49
+ announceCode("GitHub", start.verification_uri, start.user_code);
41
50
  // Poll GitHub for the access token.
42
51
  let intervalMs = (start.interval || 5) * 1000;
43
52
  const deadline = Date.now() + start.expires_in * 1000;
44
- let githubToken = "";
45
53
  while (Date.now() < deadline) {
46
54
  await sleep(intervalMs);
47
55
  const r = await postForm(TOKEN_URL, {
@@ -49,10 +57,8 @@ export async function deviceLogin() {
49
57
  device_code: start.device_code,
50
58
  grant_type: GRANT,
51
59
  });
52
- if (r.access_token) {
53
- githubToken = r.access_token;
54
- break;
55
- }
60
+ if (r.access_token)
61
+ return r.access_token;
56
62
  if (r.error === "authorization_pending")
57
63
  continue;
58
64
  if (r.error === "slow_down") {
@@ -61,20 +67,113 @@ export async function deviceLogin() {
61
67
  }
62
68
  throw new Error(`GitHub authorization failed: ${r.error_description ?? r.error}`);
63
69
  }
64
- if (!githubToken)
65
- throw new Error("Timed out waiting for GitHub authorization.");
66
- // Exchange the GitHub token for a Retasc (Convex Auth) session.
70
+ throw new Error("Timed out waiting for GitHub authorization.");
71
+ }
72
+ /**
73
+ * Run Google's device flow and return its access token (RTSC-508).
74
+ *
75
+ * Structurally the same as the GitHub one, with the two Google calls made by the
76
+ * backend rather than from here — see the note on the function references above.
77
+ * The loop, the interval back-off and the deadline stay client-side: the wait is
78
+ * as long as a human takes to find their browser, which is not something to hold
79
+ * a Convex action open for.
80
+ */
81
+ async function googleDeviceToken(convex) {
82
+ const start = (await convex.action(googleDeviceStart, {}));
83
+ announceCode("Google", start.verificationUrl, start.userCode);
84
+ let intervalMs = (start.intervalSeconds || 5) * 1000;
85
+ const deadline = Date.now() + start.expiresInSeconds * 1000;
86
+ while (Date.now() < deadline) {
87
+ await sleep(intervalMs);
88
+ // Anything terminal — declined, expired, misconfigured — is thrown by the
89
+ // backend as a readable userError and propagates to the caller's `fail`.
90
+ const r = (await convex.action(googleDevicePoll, { deviceCode: start.deviceCode }));
91
+ if (r.status === "ok")
92
+ return r.googleToken;
93
+ if (r.status === "slowDown")
94
+ intervalMs += 5000;
95
+ }
96
+ throw new Error("Timed out waiting for Google authorization.");
97
+ }
98
+ /**
99
+ * Which door, when the caller didn't say (RTSC-508).
100
+ *
101
+ * Asked rather than defaulted because the person who needs the second door is
102
+ * precisely the one who cannot be expected to know a flag exists: they signed up
103
+ * through the web Dash with Google, and a GitHub-only prompt is a dead end they
104
+ * have no way to read as one. One keystroke, and only when someone is there to
105
+ * press it.
106
+ *
107
+ * Non-interactive runs keep today's behaviour exactly — GitHub, no prompt — so
108
+ * nothing scripted against `retasc login` changes.
109
+ */
110
+ export async function chooseProvider(
111
+ // Injected so the question can be pinned by tests. Picking the wrong door is
112
+ // not a typo you correct on the next run: it signs you in as a SECOND identity
113
+ // with its own membership, and nothing in the CLI can merge them back. Same
114
+ // reasoning as `identityLoop`'s deps — the loud path is the one that must be
115
+ // provable, and a live run only ever exercises the quiet one.
116
+ deps = {}) {
117
+ const askFn = deps.askFn ?? ask;
118
+ const interactive = deps.interactive ?? isInteractive;
119
+ if (!interactive())
120
+ return "github";
121
+ // A default exists ONLY when it is a recorded fact about this human — the door
122
+ // they last signed in through successfully. That is the whole rule. Defaulting
123
+ // to GitHub because most people use it would be assuming an answer to a
124
+ // question whose wrong answer is a second identity, for exactly the person
125
+ // this feature exists to serve. A remembered door assumes nothing: it already
126
+ // worked for them once.
127
+ const remembered = deps.remembered;
128
+ const DOORS = [
129
+ { key: "github", slot: "1", label: "GitHub" },
130
+ { key: "google", slot: "2", label: "Google" },
131
+ ];
132
+ console.log("\nHow do you sign in to Retasc?");
133
+ for (const d of DOORS) {
134
+ console.log(` ${d.slot}) ${d.label}${d.key === remembered ? " (last used)" : ""}`);
135
+ }
136
+ const rememberedSlot = DOORS.find((d) => d.key === remembered)?.slot;
137
+ for (let attempt = 0; attempt < 3; attempt++) {
138
+ const a = await askFn(rememberedSlot ? `Choose a number [${rememberedSlot}]: ` : "Choose a number: ");
139
+ // Empty is an answer only when there is something true to answer WITH.
140
+ if (a === "" && remembered)
141
+ return remembered;
142
+ const hit = DOORS.find((d) => d.slot === a);
143
+ if (hit)
144
+ return hit.key;
145
+ console.log("Please enter 1 or 2.");
146
+ }
147
+ throw new Error("no valid choice — aborting");
148
+ }
149
+ /**
150
+ * Run a device-flow login and persist the resulting Convex Auth session to
151
+ * ~/.retasc/config.json. Prints the user code + verification URL.
152
+ *
153
+ * With no `provider`, asks which door when a human is present and falls back to
154
+ * GitHub when one isn't.
155
+ */
156
+ export async function deviceLogin(provider) {
67
157
  const cfg = loadConfig();
158
+ const door = provider ?? (await chooseProvider({ remembered: cfg.loginProvider }));
68
159
  const convex = new ConvexHttpClient(cfg.deploymentUrl);
160
+ // Exchange the upstream token for a Retasc (Convex Auth) session. The token is
161
+ // used here and never stored: the session is the only credential that persists.
162
+ const params = door === "google"
163
+ ? { googleToken: await googleDeviceToken(convex) }
164
+ : { githubToken: await githubDeviceToken() };
69
165
  const res = await convex.action(signIn, {
70
- provider: "github-device",
71
- params: { githubToken },
166
+ provider: door === "google" ? "google-device" : "github-device",
167
+ params,
72
168
  });
73
169
  const tokens = res?.tokens;
74
170
  if (!tokens?.token) {
75
171
  throw new Error("Sign-in did not return a session token.");
76
172
  }
77
- patchConfig({ token: tokens.token, refreshToken: tokens.refreshToken });
173
+ // Record the door only now, on the far side of a sign-in that actually worked.
174
+ // Written here rather than at the point of choosing so a failed or abandoned
175
+ // attempt can never become next time's default.
176
+ patchConfig({ token: tokens.token, refreshToken: tokens.refreshToken, loginProvider: door });
78
177
  }
79
178
  /**
80
179
  * Does this thrown error clearly mean "this refresh token can never mint a new
@@ -1,5 +1,3 @@
1
- import { createInterface } from "node:readline/promises";
2
- import { stdin, stdout } from "node:process";
3
1
  import { api, cliError } from "../api.js";
4
2
  import { deviceLogin } from "../auth.js";
5
3
  import { loadConfig } from "../config.js";
@@ -7,34 +5,14 @@ import { installMarker } from "./mcp.js";
7
5
  import { readLocalBinding, resolveBinding } from "../lib/binding.js";
8
6
  import { getBinding, setBinding, newWorkspaceId } from "../lib/keystore.js";
9
7
  import { resolveLauncher, launcherNote, selfCommand } from "../lib/launcher.js";
8
+ import { ask, confirm, isInteractive } from "../lib/prompt.js";
10
9
  import { clean } from "../lib/text.js";
11
10
  import { VERSION } from "../version.js";
12
- export function isInteractive() {
13
- return Boolean(stdin.isTTY && stdout.isTTY);
14
- }
15
- export async function ask(question) {
16
- const rl = createInterface({ input: stdin, output: stdout });
17
- try {
18
- // readline's question() NEVER settles when stdin hits EOF (Ctrl-D, or a
19
- // closed pipe) — it silently drops the callback rather than resolving ""
20
- // or throwing, so a bare `await` here wedges the CLI with no output and no
21
- // exit. Race the close event so callers fail fast instead (RTSC-269).
22
- const closed = new Promise((_, reject) => rl.once("close", () => reject(new Error("input closed"))));
23
- closed.catch(() => { }); // the normal path closes rl too; that loser rejection is expected
24
- return (await Promise.race([rl.question(question), closed])).trim();
25
- }
26
- finally {
27
- rl.close();
28
- }
29
- }
30
- export async function confirm(question, assumeYes) {
31
- if (assumeYes)
32
- return true;
33
- if (!isInteractive())
34
- return false;
35
- const a = (await ask(`${question} [y/N] `)).toLowerCase();
36
- return a === "y" || a === "yes";
37
- }
11
+ // RTSC-508: `ask`/`confirm`/`isInteractive` now live in lib/prompt.ts so `auth.ts`
12
+ // can use them without closing an import cycle (bind auth → bind). Re-exported
13
+ // here because they have been part of this module's surface since RTSC-269, and
14
+ // moving the import in every call site would be churn for no gain.
15
+ export { ask, confirm, isInteractive };
38
16
  /**
39
17
  * Render a numbered menu and read one answer, optionally with a trailing extra option.
40
18
  *
@@ -317,7 +295,10 @@ export async function ensureSignedIn() {
317
295
  if (!isInteractive()) {
318
296
  throw new Error("Not signed in. Run `retasc login` first (no TTY here for the device flow).");
319
297
  }
320
- console.error("Not signed in yet authenticating with GitHub first…");
298
+ // RTSC-508: don't name a provider here. `deviceLogin` asks which door, and
299
+ // announcing "GitHub" before the question would be wrong for the invited
300
+ // teammate who signed up through Google — the exact person `join` is for.
301
+ console.error("Not signed in yet — let's do that first.");
321
302
  await deviceLogin();
322
303
  }
323
304
  export async function bindAction(opts) {
@@ -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/config.js CHANGED
@@ -55,6 +55,12 @@ export function loadConfig() {
55
55
  user: stored.user,
56
56
  defaultOrgId: stored.defaultOrgId,
57
57
  defaultProjectPrefix: stored.defaultProjectPrefix,
58
+ // Validated on read, not trusted: this value decides which door a bare Enter
59
+ // takes, and the file is hand-editable. Anything unrecognised reads as "never
60
+ // recorded", which falls back to asking outright.
61
+ loginProvider: stored.loginProvider === "github" || stored.loginProvider === "google"
62
+ ? stored.loginProvider
63
+ : undefined,
58
64
  };
59
65
  }
60
66
  /** Move a corrupt config aside to a unique sibling so a human can recover any
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";
@@ -37,12 +38,23 @@ function fail(e) {
37
38
  process.exit(1);
38
39
  }
39
40
  // --- auth ------------------------------------------------------------------
41
+ // RTSC-508: two doors, not one. Google sign-in shipped for the web Dash first, so
42
+ // an account can exist that GitHub has never heard of — and for that human every
43
+ // login-gated command here was unreachable. With no flag the command ASKS, which
44
+ // is the only form that helps someone who doesn't know a flag exists.
40
45
  program
41
46
  .command("login")
42
- .description("Sign in with GitHub (device flow).")
43
- .action(async () => {
47
+ .description("Sign in with GitHub or Google (device flow).")
48
+ .option("--github", "Sign in with GitHub, without being asked")
49
+ .option("--google", "Sign in with Google, without being asked")
50
+ .action(async (opts) => {
44
51
  try {
45
- await deviceLogin();
52
+ // Naming both is a contradiction, not a preference — and quietly picking
53
+ // one could sign them in through the door that mints a SECOND identity,
54
+ // which nothing in the CLI can undo.
55
+ if (opts.github && opts.google)
56
+ return fail("Pass --github or --google, not both.");
57
+ await deviceLogin(opts.google ? "google" : opts.github ? "github" : undefined);
46
58
  const me = (await api.me());
47
59
  const u = me?.user ?? {};
48
60
  console.log(`\n✓ Signed in${u.name ? ` as ${u.name}` : ""}${u.email ? ` <${u.email}>` : ""}.`);
@@ -372,11 +384,30 @@ program
372
384
  .option("--project-id <id>", "Which project to bind to (skips the picker)")
373
385
  .option("--agent <name>", "Agent member name (default: auto)")
374
386
  .option("--runtime <runtime>", "Agent runtime", "claude-code")
375
- .option("-y, --yes", "Don't prompt to replace an existing binding, and skip the identity question")
387
+ // RTSC-477 name the way back. `--yes` skips the identity question and must never answer
388
+ // it, so the flag that causes the gap is the right place to say how to close it.
389
+ .option("-y, --yes", "Don't prompt to replace an existing binding, and skip the identity question (ask it later with `retasc identity`)")
376
390
  .allowExcessArguments(false)
377
391
  .action(async (link, opts) => {
378
392
  await joinAction(link, opts).catch(fail);
379
393
  });
394
+ // RTSC-477 — the same question `join` asks, on demand. `join` fires once, at the moment you
395
+ // accept an invite; placeholders arrive with EVERY migration, and claiming is per source, so
396
+ // the question recurs and needed a surface that recurs with it.
397
+ //
398
+ // No `-y/--yes`, and no `identity claim <name>` subcommand, on purpose: a claim is
399
+ // irreversible and moves dispatch routing, so it is never answered on a script's behalf.
400
+ // `allowExcessArguments(false)` therefore rejects `retasc identity claim …` outright rather
401
+ // than ignoring the words and prompting anyway.
402
+ program
403
+ .command("identity")
404
+ .description("Link imported history to your account: shows the people a migration carried into this org and asks which one is you.")
405
+ .option("--org-id <id>", "Which org (defaults to your only one).")
406
+ .allowExcessArguments(false)
407
+ .action(async (opts) => {
408
+ requireLogin();
409
+ await identityAction({ orgId: opts.orgId }).catch(fail);
410
+ });
380
411
  // --- mcp wiring ------------------------------------------------------------
381
412
  const mcp = program.command("mcp").description("Wire the Retasc MCP server into your agent.");
382
413
  mcp
@@ -0,0 +1,38 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { stdin, stdout } from "node:process";
3
+ // The terminal-input primitives, extracted from commands/bind.ts (RTSC-508).
4
+ //
5
+ // They moved because `auth.ts` needs to ask a question too — which door to sign
6
+ // in through — and importing it from `commands/bind.ts` would close a cycle:
7
+ // bind already imports `deviceLogin` from auth. Duplicating `ask` instead would
8
+ // have meant duplicating the EOF race fix below, which is exactly the kind of
9
+ // thing that gets fixed once and then silently rots in the copy.
10
+ //
11
+ // `commands/bind.ts` re-exports all three, so every existing call site is
12
+ // unchanged.
13
+ export function isInteractive() {
14
+ return Boolean(stdin.isTTY && stdout.isTTY);
15
+ }
16
+ export async function ask(question) {
17
+ const rl = createInterface({ input: stdin, output: stdout });
18
+ try {
19
+ // readline's question() NEVER settles when stdin hits EOF (Ctrl-D, or a
20
+ // closed pipe) — it silently drops the callback rather than resolving ""
21
+ // or throwing, so a bare `await` here wedges the CLI with no output and no
22
+ // exit. Race the close event so callers fail fast instead (RTSC-269).
23
+ const closed = new Promise((_, reject) => rl.once("close", () => reject(new Error("input closed"))));
24
+ closed.catch(() => { }); // the normal path closes rl too; that loser rejection is expected
25
+ return (await Promise.race([rl.question(question), closed])).trim();
26
+ }
27
+ finally {
28
+ rl.close();
29
+ }
30
+ }
31
+ export async function confirm(question, assumeYes) {
32
+ if (assumeYes)
33
+ return true;
34
+ if (!isInteractive())
35
+ return false;
36
+ const a = (await ask(`${question} [y/N] `)).toLowerCase();
37
+ return a === "y" || a === "yes";
38
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.9.0",
3
+ "version": "1.11.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": {