@retasc/cli 1.32.0 → 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 +14 -0
- package/dist/commands/bind.js +52 -12
- package/dist/lib/outcome.js +158 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,20 @@ 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
|
+
|
|
9
23
|
## 1.32.0 (2026-08-23)
|
|
10
24
|
|
|
11
25
|
- **RTSC-720** — when `bind --json` stops to ask, it hands your agent the exact questions
|
package/dist/commands/bind.js
CHANGED
|
@@ -9,7 +9,7 @@ import { resolveLauncher, launcherNote, onDurablePath, selfCommand, versionStamp
|
|
|
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, orgQuestions, projectQuestions } 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
|
|
@@ -640,6 +640,31 @@ export async function bindAction(opts) {
|
|
|
640
640
|
? { onUrl: (url) => emit({ event: "approve_url", url }) }
|
|
641
641
|
: undefined;
|
|
642
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");
|
|
643
668
|
/** Continuable: say what is missing, exit 0. See lib/outcome.ts for why 0. */
|
|
644
669
|
const pause = (o) => {
|
|
645
670
|
if (agent)
|
|
@@ -766,7 +791,7 @@ export async function bindAction(opts) {
|
|
|
766
791
|
resume: { tool: "join" },
|
|
767
792
|
},
|
|
768
793
|
]
|
|
769
|
-
: orgQuestions(signedInAs, folder),
|
|
794
|
+
: orgQuestions(signedInAs, folder, declined, answered),
|
|
770
795
|
next: pendingInvites.length
|
|
771
796
|
? `Signed in as ${signedInAs}. They have been invited to ` +
|
|
772
797
|
`${pendingInvites.map((i) => `"${i.org}"`).join(", ")}. ASK whether they want to join ` +
|
|
@@ -814,16 +839,31 @@ export async function bindAction(opts) {
|
|
|
814
839
|
state: "NEEDS_PROJECT",
|
|
815
840
|
org: { id: orgId, name: opts.orgName ?? orgId },
|
|
816
841
|
projects: list,
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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}.`),
|
|
827
867
|
});
|
|
828
868
|
return;
|
|
829
869
|
}
|
package/dist/lib/outcome.js
CHANGED
|
@@ -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
|
*
|
|
@@ -100,14 +101,137 @@ export function isContinuable(state) {
|
|
|
100
101
|
* The questions each continuable state asks, as pure builders so they are testable
|
|
101
102
|
* without a network. bindAction attaches their output to the outcome it emits.
|
|
102
103
|
*/
|
|
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()) {
|
|
104
215
|
const context = (signedInAs ? `Signed in as ${signedInAs}. ` : "") +
|
|
105
216
|
`These three answers create the workspace; the folder question is what connects ` +
|
|
106
217
|
`THIS folder to it.`;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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"),
|
|
111
235
|
{
|
|
112
236
|
// The folder rides IN the exchange, not after it. It earned this on the run that
|
|
113
237
|
// shaped RTSC-720: the human was standing in /Users, the outcome named the path,
|
|
@@ -122,24 +246,46 @@ export function orgQuestions(signedInAs, folder) {
|
|
|
122
246
|
resume: { tool: "bind" },
|
|
123
247
|
},
|
|
124
248
|
];
|
|
249
|
+
return all.filter((q) => !answered.has(q.id));
|
|
125
250
|
}
|
|
126
|
-
export function projectQuestions(folder, projects) {
|
|
251
|
+
export function projectQuestions(folder, projects, declined = new Set()) {
|
|
127
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
|
+
}
|
|
128
269
|
return [
|
|
129
270
|
{
|
|
130
271
|
id: "project.pick",
|
|
131
272
|
question: `Which project is ${folder} for?`,
|
|
132
|
-
options
|
|
133
|
-
value: p.id,
|
|
134
|
-
label: `${p.prefix ?? ""} — ${p.name ?? p.id}`.trim(),
|
|
135
|
-
})),
|
|
273
|
+
options,
|
|
136
274
|
resume: { tool: "bind", arg: "--project-id" },
|
|
137
275
|
},
|
|
138
276
|
];
|
|
139
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);
|
|
140
282
|
return [
|
|
141
|
-
|
|
142
|
-
|
|
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" } },
|
|
143
289
|
];
|
|
144
290
|
}
|
|
145
291
|
/** stdout, one line, no trailing prose. Never stderr: see the module header. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retasc/cli",
|
|
3
|
-
"version": "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": {
|