@retasc/cli 1.31.1 → 1.33.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,43 @@ 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.33.0 (2026-08-23)
10
+
11
+ - **RTSC-722** — the setup questions arrive as a matrix your agent can actually render.
12
+ 1.32.0 sent three of the four as free text, and a picker cannot draw a question with no
13
+ options, so the whole set collapsed back into the numbered list in prose that RTSC-720
14
+ existed to remove. They now carry defaults derived from the folder you are standing in:
15
+ in `~/the egg` you get `The Egg`, `The Egg`, `EGG`, and the folder confirm, four clicks
16
+ instead of three typed answers. Every question keeps a "Something else" escape.
17
+ Folders that name nothing about the work (`/Users`, your home directory, `src`, a
18
+ dotfile, anything too long for the server to accept) suggest nothing and fall back to
19
+ text, because a plausible wrong default is worse than no default when what you are
20
+ confirming is which folder gets connected. Picking a project in an org that has more
21
+ than four is a picker too now, instead of an unrenderable list of every project.
22
+
23
+ ## 1.32.0 (2026-08-23)
24
+
25
+ - **RTSC-720** — when `bind --json` stops to ask, it hands your agent the exact questions
26
+ as data: what to call the workspace, the first project, its prefix, and a confirmation
27
+ naming the precise folder about to be connected — with the flag each answer fills. Your
28
+ agent presents them instead of paraphrasing a status line, which is the difference
29
+ between being asked "what should we call it?" and being told "you have no organization"
30
+ by an agent waiting for you to notice.
31
+ - **RTSC-721** — `retasc unbind` exists. It puts a folder back the way it was before
32
+ `bind`: the keystore entry, the MCP entry in either location it can live, and the
33
+ agent key — revoked server-side when your session can, named for the Dash when it
34
+ cannot. Until now undoing a binding meant hand-editing five places, one of them keyed
35
+ by an id nothing surfaces, with the key left live throughout. It confirms before
36
+ removing anything, names the absolute folder, and leaves your sign-in alone —
37
+ `retasc logout` is still its own decision.
38
+ - **RTSC-721** — `bind` heals a dead binding instead of interrogating you about it. Come
39
+ back months later, after the workspace was deleted or the key revoked, and `bind` used
40
+ to ask "Replace it?" about a binding that no longer worked — a confusing question at
41
+ the exact moment you were least equipped to answer it. It now says the binding is no
42
+ longer accepted, clears it, and reconnects. Only on the server's own refusal: a network
43
+ failure still lands on the cautious path, because clearing a healthy credential to fix
44
+ a problem your machine does not have would be worse than asking.
45
+
9
46
  ## 1.31.1 (2026-08-23)
10
47
 
11
48
  - **RTSC-715** — setup no longer finishes by wiring a `retasc` command that is not there.
@@ -3,13 +3,13 @@ import { api, cliError } from "../api.js";
3
3
  import { deviceLogin } from "../auth.js";
4
4
  import { loadConfig, patchConfig } from "../config.js";
5
5
  import { installMarker, printMarkerBlock } from "./mcp.js";
6
- import { readLocalBinding, resolveBinding } from "../lib/binding.js";
7
- import { getBinding, setBinding, newWorkspaceId } from "../lib/keystore.js";
6
+ import { readLocalBinding, resolveBinding, bindingRefused } from "../lib/binding.js";
7
+ import { getBinding, setBinding, removeBinding, newWorkspaceId } from "../lib/keystore.js";
8
8
  import { resolveLauncher, launcherNote, onDurablePath, selfCommand, versionStamp } from "../lib/launcher.js";
9
9
  import { ask, confirm, isInteractive } from "../lib/prompt.js";
10
10
  import { clean } from "../lib/text.js";
11
11
  import { card, DOT } from "../lib/card.js";
12
- import { emit } from "../lib/outcome.js";
12
+ import { answeredElsewhere, ELSEWHERE_VALUE, emit, orgQuestions, projectQuestions, } from "../lib/outcome.js";
13
13
  import { VERSION } from "../version.js";
14
14
  // RTSC-508: `ask`/`confirm`/`isInteractive` now live in lib/prompt.ts so `auth.ts`
15
15
  // can use them without closing an import cycle (bind → auth → bind). Re-exported
@@ -136,8 +136,43 @@ export async function rebindGuard(args) {
136
136
  const b = await resolveBinding(existing.url || args.mcpUrl, existing.key);
137
137
  where = `org "${clean(b.org.name)}" / project ${clean(b.project.prefix)}`;
138
138
  }
139
- catch {
140
- /* key may be stale/revoked still warn before replacing */
139
+ catch (e) {
140
+ // RTSC-721 a binding the server REFUSES is dead, and "Replace it?" is the wrong
141
+ // question to ask about a corpse.
142
+ //
143
+ // This is the months-later return: someone bound a folder, drifted away, and came
144
+ // back after their org was deleted or their key revoked. The local state is intact
145
+ // and points at nothing, and the person standing here does not know `unbind` exists —
146
+ // they ran `bind`, because `bind` is what they remember. Asking them to confirm
147
+ // replacing "an existing Retasc binding" is confusing precisely when they are least
148
+ // equipped to answer; there is nothing usable to preserve.
149
+ //
150
+ // ONLY on a refusal. `bindingRefused` matches the server's UNAUTHORIZED verdict and
151
+ // nothing else — a network blip or a 5xx falls through to the cautious path below,
152
+ // because clearing a healthy credential to fix a problem the machine does not have
153
+ // would be strictly worse than asking a confusing question. That asymmetry is the
154
+ // whole design: misread-refusal costs a question, misread-outage costs a credential.
155
+ //
156
+ // Non-interactive proceeds WITHOUT --yes, unlike the replace path below. Replacing a
157
+ // live binding destroys something; clearing a dead one does not, and an agent-driven
158
+ // `bind` (RTSC-713) that wedged here would need a human to relay a question about
159
+ // state the human cannot see.
160
+ if (bindingRefused(e)) {
161
+ console.log("This folder has a Retasc binding the server no longer accepts — the workspace " +
162
+ "may have been deleted, or the key revoked. There is nothing usable to keep.");
163
+ if (isInteractive() && !(await confirm("Clear it and reconnect?", args.yes))) {
164
+ console.log("Left unchanged.");
165
+ return { proceed: false, existing };
166
+ }
167
+ if (!isInteractive())
168
+ console.log("Clearing it and continuing.");
169
+ if (existing.workspaceId)
170
+ removeBinding(existing.workspaceId);
171
+ // Reuse the workspace id: the marker in this folder (possibly committed) already
172
+ // names it, and bind writes a fresh keystore entry under the same id.
173
+ return { proceed: true, existing };
174
+ }
175
+ /* network/unknown failure — key may still be fine; warn before replacing, as ever */
141
176
  }
142
177
  console.log(`This folder is already bound to ${where}.`);
143
178
  if (await confirm("Replace it?", args.yes))
@@ -605,6 +640,31 @@ export async function bindAction(opts) {
605
640
  ? { onUrl: (url) => emit({ event: "approve_url", url }) }
606
641
  : undefined;
607
642
  const folder = process.cwd();
643
+ // RTSC-722 — the escape option's value is a sentinel, and this is where it stops being
644
+ // an answer. Every suggested question offers "Something else", and the agent is told to
645
+ // put the chosen value in the named flag; without this a human declining the suggestion
646
+ // creates a workspace literally called `__ask_me__`. Strip it here, at the boundary, so
647
+ // no downstream site has to know: the flag reads as unanswered, and the re-ask drops
648
+ // the suggestion this human just declined instead of offering it again.
649
+ const declined = answeredElsewhere(opts);
650
+ if (declined.size) {
651
+ opts = {
652
+ ...opts,
653
+ orgName: opts.orgName === ELSEWHERE_VALUE ? undefined : opts.orgName,
654
+ project: opts.project === ELSEWHERE_VALUE ? undefined : opts.project,
655
+ prefix: opts.prefix === ELSEWHERE_VALUE ? undefined : opts.prefix,
656
+ projectId: opts.projectId === ELSEWHERE_VALUE ? undefined : opts.projectId,
657
+ };
658
+ }
659
+ // What the agent has ALREADY collected, so the re-ask asks only for the rest. Computed
660
+ // after the strip: a sentinel is a decline, never an answer.
661
+ const answered = new Set();
662
+ if (opts.orgName)
663
+ answered.add("org.name");
664
+ if (opts.project)
665
+ answered.add("project.name");
666
+ if (opts.prefix)
667
+ answered.add("project.prefix");
608
668
  /** Continuable: say what is missing, exit 0. See lib/outcome.ts for why 0. */
609
669
  const pause = (o) => {
610
670
  if (agent)
@@ -711,6 +771,27 @@ export async function bindAction(opts) {
711
771
  orgs,
712
772
  pendingInvites: pendingInvites.length ? pendingInvites : undefined,
713
773
  missing: ["--org-id or --org-name", "--project or --project-id"],
774
+ // RTSC-720 — the questions as data, so the agent presents them instead of
775
+ // authoring its own from the prose below. On the run that shaped this, the
776
+ // agent handed this state as a sentence asked its three questions in a
777
+ // paragraph; and after a restart the sentence read as a STATUS, so it reported
778
+ // "logged in but no organization" and waited for the human to notice.
779
+ askHuman: pendingInvites.length
780
+ ? [
781
+ {
782
+ id: "onboarding.invite",
783
+ question: `Join "${pendingInvites[0].org}", or start your own workspace?`,
784
+ context: `You have a pending invitation. Joining puts your work in that team's ` +
785
+ `organization; a new organization cannot be deleted from the terminal ` +
786
+ `once created.`,
787
+ options: [
788
+ { value: "join", label: `Join "${pendingInvites[0].org}"`, detail: "Their projects, shared with the team that invited you." },
789
+ { value: "own", label: "Start my own workspace", detail: "Pick this only if the invite was not meant for this work." },
790
+ ],
791
+ resume: { tool: "join" },
792
+ },
793
+ ]
794
+ : orgQuestions(signedInAs, folder, declined, answered),
714
795
  next: pendingInvites.length
715
796
  ? `Signed in as ${signedInAs}. They have been invited to ` +
716
797
  `${pendingInvites.map((i) => `"${i.org}"`).join(", ")}. ASK whether they want to join ` +
@@ -758,15 +839,31 @@ export async function bindAction(opts) {
758
839
  state: "NEEDS_PROJECT",
759
840
  org: { id: orgId, name: opts.orgName ?? orgId },
760
841
  projects: list,
761
- missing: list.length ? ["--project-id"] : ["--project", "--prefix"],
762
- next: list.length
763
- ? `That organization has ${list.length} projects. Ask which one THIS FOLDER is ` +
764
- `for, then re-run with --project-id. Confirm the folder with them: this will ` +
765
- `connect ${folder}.`
766
- : `That organization has no projects yet. Ask what they are working on, and ` +
767
- `whether they want to bring a backlog across from Linear, Jira, Asana, ClickUp ` +
768
- `or Shortcut rather than start empty. Then re-run with --project and --prefix. ` +
769
- `Confirm the folder with them: this will connect ${folder}.`,
842
+ // RTSC-722 once THIS run created the org, `--org-name` is a trap: the obvious
843
+ // agent move is to replay the previous argv with the missing flag added, which
844
+ // re-enters createOrg, hits `CONFLICT: org slug is taken` and exits 1. The
845
+ // instructions say a non-zero exit means stop, so the run dies one flag from
846
+ // working, with a stranded org. Naming --org-id here is what makes the retry
847
+ // survivable. The REFUSED branch below already warns about this; this pause is
848
+ // the one that reaches it, and did not.
849
+ missing: [
850
+ ...(createdOrgThisRun ? [`--org-id ${orgId}`] : []),
851
+ ...(list.length ? ["--project-id"] : ["--project", "--prefix"]),
852
+ ],
853
+ askHuman: projectQuestions(folder, list, declined),
854
+ next: (createdOrgThisRun
855
+ ? `The organization "${opts.orgName}" now EXISTS — it was created by this run. ` +
856
+ `Every retry from here must pass --org-id ${orgId} and must NOT pass ` +
857
+ `--org-name again, which would try to create a second one and fail. `
858
+ : "") +
859
+ (list.length
860
+ ? `That organization has ${list.length} projects. Ask which one THIS FOLDER is ` +
861
+ `for, then re-run with --project-id. Confirm the folder with them: this will ` +
862
+ `connect ${folder}.`
863
+ : `That organization has no projects yet. Ask what they are working on, and ` +
864
+ `whether they want to bring a backlog across from Linear, Jira, Asana, ClickUp ` +
865
+ `or Shortcut rather than start empty. Then re-run with --project and --prefix. ` +
866
+ `Confirm the folder with them: this will connect ${folder}.`),
770
867
  });
771
868
  return;
772
869
  }
@@ -813,6 +910,10 @@ export async function bindAction(opts) {
813
910
  pause({
814
911
  state: "BOUND",
815
912
  org: { id: orgId },
913
+ // The statement as data (tellHuman, RTSC-717's convention): exact words to relay,
914
+ // so "restart" survives the trip without being reworded into "you may need to".
915
+ tellHuman: `Setup is finished and ${folder} is connected. Restart me — start a NEW session, ` +
916
+ `not a resumed one — and then say "show me my issues".`,
816
917
  next: `Setup is complete and ${folder} is connected. TELL YOUR HUMAN it worked, name the ` +
817
918
  `folder and the project, and ask them to restart you — your Retasc tools only load ` +
818
919
  `when your client starts, so setup is not finished until they do. A resumed session ` +
@@ -277,6 +277,55 @@ export function installMarker(opts) {
277
277
  }
278
278
  return { where: path, wroteFile: true };
279
279
  }
280
+ /**
281
+ * Remove the retasc entry from this folder's ./.mcp.json (RTSC-721).
282
+ *
283
+ * The inverse of `writeProjectMcpJson`, and just as narrow: it deletes ONLY the
284
+ * `retasc` server entry and leaves everything else — other servers, unknown keys —
285
+ * byte-for-byte where it was. If retasc was the last entry the file still stays,
286
+ * empty of servers, because deleting a file the user may have hand-edited is a
287
+ * bigger act than we were asked to perform.
288
+ *
289
+ * Returns whether anything was actually removed, so the caller's receipt reports
290
+ * what happened rather than what was attempted.
291
+ */
292
+ export function removeProjectMarker(dir) {
293
+ const path = join(dir, ".mcp.json");
294
+ if (!existsSync(path))
295
+ return false;
296
+ let doc;
297
+ try {
298
+ doc = JSON.parse(readFileSync(path, "utf8"));
299
+ }
300
+ catch {
301
+ return false; // unparseable is not ours to rewrite
302
+ }
303
+ if (!doc?.mcpServers?.[SERVER_NAME])
304
+ return false;
305
+ delete doc.mcpServers[SERVER_NAME];
306
+ writeFileSync(path, JSON.stringify(doc, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
307
+ return true;
308
+ }
309
+ /**
310
+ * Ask Claude Code to drop its own retasc entry, best effort (RTSC-721).
311
+ *
312
+ * The marker may live in claude-local scope rather than ./.mcp.json (that scope WINS at
313
+ * runtime, which is exactly why it must not be left behind). `claude mcp remove` is the
314
+ * only sanctioned way to edit that config. Absent `claude`, or a failure, is reported,
315
+ * not thrown — unbind must finish its local work even where Claude Code is not installed.
316
+ */
317
+ export function tryClaudeCliRemove() {
318
+ try {
319
+ const r = spawnSync("claude", ["mcp", "remove", SERVER_NAME, "-s", "local"], {
320
+ encoding: "utf8",
321
+ timeout: 30_000,
322
+ });
323
+ return !r.error && r.status === 0;
324
+ }
325
+ catch {
326
+ return false;
327
+ }
328
+ }
280
329
  /**
281
330
  * The MCP config block, printed ONLY when we could not write it anywhere (RTSC-673).
282
331
  *
@@ -0,0 +1,100 @@
1
+ import { api } from "../api.js";
2
+ import { isLoggedIn } from "../config.js";
3
+ import { readLocalBinding } from "../lib/binding.js";
4
+ import { getBinding, removeBinding, keystorePath } from "../lib/keystore.js";
5
+ import { confirm, isInteractive } from "../lib/prompt.js";
6
+ import { removeProjectMarker, tryClaudeCliRemove } from "./mcp.js";
7
+ /**
8
+ * `retasc unbind` — put a folder back the way it was before `bind` (RTSC-721).
9
+ *
10
+ * Until this existed, undoing a binding meant knowing about, and hand-editing, five
11
+ * places: the keystore (keyed by an opaque ws_… id nothing surfaces), the folder's
12
+ * `.mcp.json`, Claude Code's local scope, the session file, and a server-side key that
13
+ * stayed LIVE through all of it. Mihnea, after repeated onboarding tests: "wipe clean is
14
+ * quite hard." It is also how you recover from binding the wrong folder — which nearly
15
+ * happened the same day, a `bind` about to connect `/Users` itself — and a wrong binding
16
+ * has no symptom once written (RTSC-532).
17
+ *
18
+ * Three rules, each earned:
19
+ *
20
+ * • **Say what it will remove, then wait.** Same rule RTSC-716 set for the write side:
21
+ * nothing irreversible happens without the human seeing the absolute path. `bind`
22
+ * confirms before connecting; disconnecting is not less destructive.
23
+ * • **Revoke, don't merely forget.** Dropping the local copy while the key stays live
24
+ * is the worse half of what hand-editing did. Best-effort — the session may lack the
25
+ * rights, or be signed out — and when it can't, it says where to finish the job
26
+ * rather than pretending it did.
27
+ * • **The session is left alone.** Signing out is `logout`'s job; conflating the two
28
+ * turns "detach this folder" into "log me out everywhere", which nobody asked.
29
+ */
30
+ export async function unbindAction(opts) {
31
+ const cwd = process.cwd();
32
+ const existing = readLocalBinding(cwd);
33
+ if (!existing) {
34
+ console.log("This folder has no Retasc binding. Nothing to do.");
35
+ return;
36
+ }
37
+ const entry = existing.workspaceId ? getBinding(existing.workspaceId) : undefined;
38
+ const keyPrefix = (entry?.key ?? existing.key ?? "").slice(0, 12);
39
+ console.log(`This will disconnect ${cwd} from Retasc:`);
40
+ if (entry?.orgName || entry?.prefix) {
41
+ console.log(` bound to: ${entry.orgName ?? entry.orgId} / ${entry.prefix ?? entry.projectId}`);
42
+ }
43
+ if (existing.workspaceId)
44
+ console.log(` keystore entry ${existing.workspaceId} in ${keystorePath()}`);
45
+ console.log(` the retasc MCP entry for this folder`);
46
+ if (keyPrefix)
47
+ console.log(` the agent key ${keyPrefix}… (revoked server-side, if this session can)`);
48
+ // Non-interactive without --yes refuses, exactly as rebindGuard does for a replace:
49
+ // a script must not be told success for a destruction it never confirmed.
50
+ if (!(await confirm("Proceed?", opts.yes))) {
51
+ if (!isInteractive()) {
52
+ console.error("✗ refusing to unbind non-interactively — pass --yes.");
53
+ process.exitCode = 1;
54
+ }
55
+ else {
56
+ console.log("Left unchanged.");
57
+ }
58
+ return;
59
+ }
60
+ // Revoke FIRST, while the keystore still holds what identifies the key. displayPrefix
61
+ // is how the server names keys (the raw value is never stored there), so the first 12
62
+ // chars of ours is the join.
63
+ if (entry?.orgId && keyPrefix && isLoggedIn()) {
64
+ try {
65
+ const { keys } = (await api.listKeys({ orgId: entry.orgId }));
66
+ const mine = (keys ?? []).find((k) => k.displayPrefix === keyPrefix && !k.revokedAt);
67
+ if (mine) {
68
+ await api.revokeKey({ keyId: mine.id ?? mine._id });
69
+ console.log(`✓ Revoked key ${keyPrefix}… server-side.`);
70
+ }
71
+ else {
72
+ console.log(`· Key ${keyPrefix}… not found server-side (already revoked, or the org is gone).`);
73
+ }
74
+ }
75
+ catch (e) {
76
+ console.log(`· Could not revoke the key from here (${e instanceof Error ? e.message.split("\n")[0] : e}). ` +
77
+ `Revoke ${keyPrefix}… from the Dash → API Keys.`);
78
+ }
79
+ }
80
+ else if (keyPrefix) {
81
+ console.log(`· Not signed in — revoke ${keyPrefix}… from the Dash → API Keys.`);
82
+ }
83
+ if (existing.workspaceId) {
84
+ removeBinding(existing.workspaceId);
85
+ console.log(`✓ Removed keystore entry ${existing.workspaceId}.`);
86
+ }
87
+ // The marker can live in either legal location, and claude-local WINS at runtime, so
88
+ // both are cleared: leaving that one behind is how a "removed" binding keeps answering.
89
+ const claudeRemoved = tryClaudeCliRemove();
90
+ const folderRemoved = removeProjectMarker(cwd);
91
+ if (claudeRemoved)
92
+ console.log("✓ Removed the Claude Code (local scope) entry.");
93
+ if (folderRemoved)
94
+ console.log("✓ Removed the retasc entry from ./.mcp.json.");
95
+ if (!claudeRemoved && !folderRemoved) {
96
+ console.log("· No MCP entry found to remove (it may live in a scope this command cannot edit).");
97
+ }
98
+ console.log("\nDone. Restart your agent so it stops loading the old server. Your sign-in is untouched — " +
99
+ "`retasc logout` if you want that too.");
100
+ }
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import { installMcp, normalizeScope } from "./commands/mcp.js";
7
7
  import { installGate, resolveGatePrefix } from "./commands/gate.js";
8
8
  import { claimAction, releaseAction } from "./commands/claim.js";
9
9
  import { bindAction, setupFromToken } from "./commands/bind.js";
10
+ import { unbindAction } from "./commands/unbind.js";
10
11
  import { joinAction } from "./commands/join.js";
11
12
  import { chooseInviteOrg, chooseInviteProjects } from "./commands/invite.js";
12
13
  import { identityAction } from "./commands/identity.js";
@@ -228,6 +229,13 @@ program
228
229
  await bindAction(opts).catch(fail);
229
230
  });
230
231
  // `retasc doctor` — verify this folder's binding + flag any illegal global server.
232
+ program
233
+ .command("unbind")
234
+ .description("Disconnect THIS folder from Retasc: keystore entry, MCP entry, and revoke the key.")
235
+ .option("-y, --yes", "Don't ask for confirmation")
236
+ .action(async (opts) => {
237
+ await unbindAction(opts).catch(fail);
238
+ });
231
239
  program
232
240
  .command("doctor")
233
241
  .description("Check that THIS workspace is correctly and safely bound to one org/project.")
@@ -103,6 +103,26 @@ export function claudeLocalRetascEntry(dir) {
103
103
  }
104
104
  return undefined;
105
105
  }
106
+ /**
107
+ * Was this resolveBinding failure the SERVER refusing the key, or the network failing us
108
+ * (RTSC-721)?
109
+ *
110
+ * The difference decides whether a binding may be treated as DEAD. Every path in
111
+ * `resolveAuth` that declines a caller throws `UNAUTHORIZED: ...` (RTSC-703 pinned that),
112
+ * and that text rides back through the MCP error content into resolveBinding's throw. A
113
+ * network outage, a 5xx, or a timeout produces anything BUT that word.
114
+ *
115
+ * Matching narrowly matters more than matching completely: clearing a binding on a
116
+ * misread network blip would destroy a healthy credential to fix a problem the machine
117
+ * does not have. A refusal we fail to recognise merely falls back to the cautious
118
+ * "Replace it?" path, which is where every failure landed before this existed.
119
+ *
120
+ * NOT `isAuthError` from api.ts — that matcher looks for "unauthenticated"/OIDC shapes
121
+ * from the SESSION layer and does not match the MCP surface's "UNAUTHORIZED".
122
+ */
123
+ export function bindingRefused(e) {
124
+ return /\bUNAUTHORIZED\b/i.test(String(e?.message ?? e));
125
+ }
106
126
  /** Read the Retasc binding a folder's agent actually uses, from either legal
107
127
  * location — claude-local first, matching Claude Code's runtime precedence. */
108
128
  export function readLocalBinding(dir) {
@@ -47,6 +47,7 @@
47
47
  * There is a test that greps for these code strings under `convex/`. If one ever appears
48
48
  * there, the split has been broken.
49
49
  */
50
+ import { homedir } from "node:os";
50
51
  /**
51
52
  * Every terminal outcome of an agent-driven `bind`, in two classes.
52
53
  *
@@ -96,6 +97,197 @@ export const TERMINAL_STATES = [
96
97
  export function isContinuable(state) {
97
98
  return CONTINUABLE_STATES.includes(state);
98
99
  }
100
+ /**
101
+ * The questions each continuable state asks, as pure builders so they are testable
102
+ * without a network. bindAction attaches their output to the outcome it emits.
103
+ */
104
+ /**
105
+ * Names suggested from the folder, or null when the folder does not name anything
106
+ * (RTSC-722).
107
+ *
108
+ * Every question in the setup matrix needs OPTIONS, because a picker cannot render a
109
+ * question that has none — which is exactly why RTSC-720's three free-text questions
110
+ * came out as prose. The folder is where the defaults come from: somebody working in
111
+ * `~/the egg` almost certainly wants a workspace called The Egg, and the common case
112
+ * becomes a click rather than three typed answers.
113
+ *
114
+ * NULL for a folder that names nothing, and this guard is load-bearing rather than
115
+ * tidy. The run before this one had a human standing in `/Users` — suggesting "Users"
116
+ * as their workspace name would have made a WRONG binding easier to accept, which is
117
+ * the opposite of what the folder confirmation exists for. Home, root, a dotfile and
118
+ * the usual meaningless container directories all decline to suggest, and those
119
+ * questions fall back to text.
120
+ */
121
+ const UNHELPFUL = new Set([
122
+ "users", "home", "root", "src", "lib", "tmp", "temp", "var", "opt", "etc", "desktop",
123
+ "documents", "downloads", "projects", "project", "code", "dev", "developer", "repos",
124
+ "repo", "workspace", "work", "apps", "app", "api", "web", "www", "site", "server",
125
+ "client", "backend", "frontend", "main", "new", "new folder", "untitled folder", "untitled",
126
+ "node modules", "git", "github", "test", "tests", "build", "dist",
127
+ ]);
128
+ /** Longest suggestion worth offering. The server caps an org name at 120 (manage.ts
129
+ * MAX_ORG_NAME), but a picker option is read at a glance and a folder whose basename
130
+ * runs past this is not a workspace name anyway. */
131
+ const MAX_SUGGESTED_NAME = 48;
132
+ /** Case-folded, trailing-separator-free, for comparing two paths for sameness. */
133
+ const samePath = (a, b) => a.replace(/[/\\]+$/, "").toLowerCase() === b.replace(/[/\\]+$/, "").toLowerCase();
134
+ export function suggestNames(folder, home = homedir()) {
135
+ const trimmed = folder.replace(/[/\\]+$/, "");
136
+ // The home directory names the HUMAN, not the work. It is the likeliest wrong folder
137
+ // after `/Users` itself, and "Kim" as a workspace name is plausible enough to click
138
+ // past — which is exactly the wrong-folder binding the confirm exists to catch. The
139
+ // basename check below cannot see this: "kim" is not a generic container word.
140
+ // Case-insensitively, because macOS and Windows filesystems are: `/users/kim` and
141
+ // `/Users/kim` are the same directory and only one of them would match an exact compare.
142
+ if (home && samePath(trimmed, home))
143
+ return null;
144
+ const raw0 = trimmed.split(/[/\\]/).pop() ?? "";
145
+ // Fold accents to their ASCII base BEFORE splitting. The split treats every character
146
+ // outside [A-Za-z0-9] as a SEPARATOR, so without this `café-api` silently becomes
147
+ // "Caf Api" and `résumé` becomes "R Sum" — a confident, wrong, pre-filled default,
148
+ // which is the one thing this function's null-return exists to avoid. A basename that
149
+ // is entirely non-Latin already returns null (no words survive); it is the MIXED case
150
+ // that mangles.
151
+ const base = raw0.normalize("NFD").replace(/\p{Diacritic}/gu, "");
152
+ // Anything still outside ASCII after folding (CJK, Cyrillic, emoji) cannot be
153
+ // transliterated honestly, so decline rather than drop characters.
154
+ if (/[^\x20-\x7E]/.test(base))
155
+ return null;
156
+ // A dotfile directory, an empty basename (root), or a generic container names nothing
157
+ // about the work. `-2`/` 2` suffixes are stripped first, so "New Folder 2" and
158
+ // "project-3" decline the same way their unsuffixed forms do.
159
+ const generic = base.toLowerCase().replace(/[\s._-]+\d+$/, "").replace(/[_-]+/g, " ").trim();
160
+ if (!base || base.startsWith(".") || UNHELPFUL.has(generic))
161
+ return null;
162
+ const words = base.split(/[^A-Za-z0-9]+/).filter(Boolean);
163
+ if (!words.length)
164
+ return null;
165
+ const name = words.map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
166
+ // Do not offer a default the server will reject, or one a picker cannot show.
167
+ if (name.length > MAX_SUGGESTED_NAME)
168
+ return null;
169
+ // Must satisfy the server's own rule — /^[A-Z][A-Z0-9]{1,9}$/ (manage.ts). A suggested
170
+ // default the server then rejects is worse than no suggestion, because the human
171
+ // accepted it and the failure arrives afterwards, attributed to nothing they did.
172
+ const joined = words.join("").toUpperCase().replace(/[^A-Z0-9]/g, "");
173
+ // A mid-word truncation reads as a typo, and the prefix then appears in EVERY issue id
174
+ // in the project (renaming one is owner-gated and Dash-only). `a-very-long-project-name`
175
+ // as AVERYLONGP is worse than no suggestion; its initials, AVLPN, is a real prefix.
176
+ const prefix = joined.length <= 10
177
+ ? joined
178
+ : words.map((w) => w[0]).join("").toUpperCase().replace(/[^A-Z0-9]/g, "");
179
+ if (!/^[A-Z][A-Z0-9]{1,9}$/.test(prefix))
180
+ return null;
181
+ return { name, prefix };
182
+ }
183
+ /**
184
+ * The escape every suggested question carries, so the matrix never traps anyone.
185
+ *
186
+ * The value is a SENTINEL, not a word. `toElicitation` turns the option list into a JSON
187
+ * schema `enum`, and the instructions tell the agent to put the chosen value in the named
188
+ * flag — so a human-readable escape value like "other" arrives as `--org-name other` and
189
+ * names the workspace "other". `bind` rejects this exact string on every answer flag
190
+ * (`answeredElsewhere` below), so a mistake costs one more question rather than a wrongly
191
+ * named workspace. Enforced in the command, not in the wording: an instruction the agent
192
+ * can misread is not a guard.
193
+ */
194
+ export const ELSEWHERE_VALUE = "__ask_me__";
195
+ /** A picker renders at most four options (AskUserQuestion caps there, and a longer list
196
+ * stops being scannable anyway). Emitting more is the RTSC-722 defect. */
197
+ export const MAX_OPTIONS = 4;
198
+ const ELSEWHERE = { value: ELSEWHERE_VALUE, label: "Something else — I'll tell you" };
199
+ /**
200
+ * Which answers came back as the escape, so the re-ask does NOT re-offer the suggestion
201
+ * the human just declined. Without this the same matrix comes back unchanged and the
202
+ * exchange loops: suggestion, "something else", same suggestion.
203
+ */
204
+ export function answeredElsewhere(opts) {
205
+ const out = new Set();
206
+ if (opts.orgName === ELSEWHERE_VALUE)
207
+ out.add("org.name");
208
+ if (opts.project === ELSEWHERE_VALUE)
209
+ out.add("project.name");
210
+ if (opts.prefix === ELSEWHERE_VALUE)
211
+ out.add("project.prefix");
212
+ return out;
213
+ }
214
+ export function orgQuestions(signedInAs, folder, declined = new Set(), answered = new Set()) {
215
+ const context = (signedInAs ? `Signed in as ${signedInAs}. ` : "") +
216
+ `These three answers create the workspace; the folder question is what connects ` +
217
+ `THIS folder to it.`;
218
+ const g = suggestNames(folder);
219
+ // With a usable folder name every question carries options, so all four render as ONE
220
+ // matrix and the common case is four clicks on pre-filled defaults. Without one they
221
+ // stay text — a wrong suggestion is worse than a typed answer.
222
+ // `hint` is documented on BOTH sides of the mirror (here and convex/lib/askHuman.ts) as
223
+ // meaningful only with `input: "text"`, so it rides the text branch only. A question
224
+ // showing THEEGG does not also need "e.g. ACME".
225
+ const suggested = (value, arg, id, question, hint) => g && !declined.has(id)
226
+ ? { id, question, options: [{ value, label: value }, ELSEWHERE], resume: { tool: "bind", arg } }
227
+ : { id, question, input: "text", ...(hint ? { hint } : {}), resume: { tool: "bind", arg } };
228
+ // An answer already in hand is not a question. Declining ONE of the three used to
229
+ // re-emit all three, so a human who had already named their project was asked for it a
230
+ // second time, and could answer differently the second time.
231
+ const all = [
232
+ { ...suggested(g?.name ?? "", "--org-name", "org.name", "What should your workspace (organization) be called?"), context },
233
+ suggested(g?.name ?? "", "--project", "project.name", "And the first project?"),
234
+ suggested(g?.prefix ?? "", "--prefix", "project.prefix", "A short prefix for issue ids?", "e.g. ACME"),
235
+ {
236
+ // The folder rides IN the exchange, not after it. It earned this on the run that
237
+ // shaped RTSC-720: the human was standing in /Users, the outcome named the path,
238
+ // and the agent caught it before anything was written. A wrong binding has no
239
+ // symptom once written (RTSC-532).
240
+ id: "bind.folder",
241
+ question: `Connect ${folder} to that project?`,
242
+ options: [
243
+ { value: "yes", label: `Yes — ${folder} is the right folder` },
244
+ { value: "no", label: "No — I am in the wrong folder", detail: "Open the right one and run setup there instead." },
245
+ ],
246
+ resume: { tool: "bind" },
247
+ },
248
+ ];
249
+ return all.filter((q) => !answered.has(q.id));
250
+ }
251
+ export function projectQuestions(folder, projects, declined = new Set()) {
252
+ if (projects.length) {
253
+ // RTSC-722 again, on the path the first pass missed. A picker renders at most FOUR
254
+ // options, so an org with five projects reproduced the exact failure this issue is
255
+ // about: unrenderable, so the question degrades to prose. Show three and an escape;
256
+ // the outcome carries the FULL list in `projects`, so "none of these" means the agent
257
+ // reads that list out and passes the id the human picks — an escape, not a dead end.
258
+ const shown = projects.length <= MAX_OPTIONS ? projects : projects.slice(0, MAX_OPTIONS - 1);
259
+ const options = shown.map((p) => ({
260
+ value: p.id,
261
+ label: `${p.prefix ?? ""} — ${p.name ?? p.id}`.trim(),
262
+ }));
263
+ if (shown.length < projects.length) {
264
+ options.push({
265
+ value: ELSEWHERE_VALUE,
266
+ label: `None of these — show me all ${projects.length}`,
267
+ });
268
+ }
269
+ return [
270
+ {
271
+ id: "project.pick",
272
+ question: `Which project is ${folder} for?`,
273
+ options,
274
+ resume: { tool: "bind", arg: "--project-id" },
275
+ },
276
+ ];
277
+ }
278
+ // Same rule as orgQuestions: options where the folder names something, text where it
279
+ // does not. This path is reached with an org already chosen and no projects in it.
280
+ const g = suggestNames(folder);
281
+ const pick = (id) => g && !declined.has(id);
282
+ return [
283
+ pick("project.name")
284
+ ? { id: "project.name", question: "What is the first project called?", options: [{ value: g.name, label: g.name }, ELSEWHERE], resume: { tool: "bind", arg: "--project" } }
285
+ : { id: "project.name", question: "What is the first project called?", input: "text", resume: { tool: "bind", arg: "--project" } },
286
+ pick("project.prefix")
287
+ ? { id: "project.prefix", question: "A short prefix for issue ids?", options: [{ value: g.prefix, label: g.prefix }, ELSEWHERE], resume: { tool: "bind", arg: "--prefix" } }
288
+ : { id: "project.prefix", question: "A short prefix for issue ids?", input: "text", hint: "e.g. ACME", resume: { tool: "bind", arg: "--prefix" } },
289
+ ];
290
+ }
99
291
  /** stdout, one line, no trailing prose. Never stderr: see the module header. */
100
292
  export function emit(e) {
101
293
  process.stdout.write(JSON.stringify(e) + "\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.31.1",
3
+ "version": "1.33.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": {