@retasc/cli 1.31.0 → 1.32.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 +33 -0
- package/dist/commands/bind.js +71 -7
- package/dist/commands/mcp.js +49 -0
- package/dist/commands/unbind.js +100 -0
- package/dist/index.js +8 -0
- package/dist/lib/binding.js +20 -0
- package/dist/lib/launcher.js +65 -3
- package/dist/lib/outcome.js +46 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,39 @@ 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.32.0 (2026-08-23)
|
|
10
|
+
|
|
11
|
+
- **RTSC-720** — when `bind --json` stops to ask, it hands your agent the exact questions
|
|
12
|
+
as data: what to call the workspace, the first project, its prefix, and a confirmation
|
|
13
|
+
naming the precise folder about to be connected — with the flag each answer fills. Your
|
|
14
|
+
agent presents them instead of paraphrasing a status line, which is the difference
|
|
15
|
+
between being asked "what should we call it?" and being told "you have no organization"
|
|
16
|
+
by an agent waiting for you to notice.
|
|
17
|
+
- **RTSC-721** — `retasc unbind` exists. It puts a folder back the way it was before
|
|
18
|
+
`bind`: the keystore entry, the MCP entry in either location it can live, and the
|
|
19
|
+
agent key — revoked server-side when your session can, named for the Dash when it
|
|
20
|
+
cannot. Until now undoing a binding meant hand-editing five places, one of them keyed
|
|
21
|
+
by an id nothing surfaces, with the key left live throughout. It confirms before
|
|
22
|
+
removing anything, names the absolute folder, and leaves your sign-in alone —
|
|
23
|
+
`retasc logout` is still its own decision.
|
|
24
|
+
- **RTSC-721** — `bind` heals a dead binding instead of interrogating you about it. Come
|
|
25
|
+
back months later, after the workspace was deleted or the key revoked, and `bind` used
|
|
26
|
+
to ask "Replace it?" about a binding that no longer worked — a confusing question at
|
|
27
|
+
the exact moment you were least equipped to answer it. It now says the binding is no
|
|
28
|
+
longer accepted, clears it, and reconnects. Only on the server's own refusal: a network
|
|
29
|
+
failure still lands on the cautious path, because clearing a healthy credential to fix
|
|
30
|
+
a problem your machine does not have would be worse than asking.
|
|
31
|
+
|
|
32
|
+
## 1.31.1 (2026-08-23)
|
|
33
|
+
|
|
34
|
+
- **RTSC-715** — setup no longer finishes by wiring a `retasc` command that is not there.
|
|
35
|
+
Run as `npx @retasc/cli@latest bind`, the CLI asked whether `retasc` was on your PATH
|
|
36
|
+
and got yes, because npx puts its own cache directory on the PATH of whatever it runs.
|
|
37
|
+
So the answer was true while `bind` ran and false the moment it exited, and your agent
|
|
38
|
+
started with `ENOENT: Executable not found in $PATH: "retasc"` after a setup that had
|
|
39
|
+
reported success. It now checks that the command it found will still resolve afterwards,
|
|
40
|
+
and falls back to a global install or a pinned `npx` launcher when it will not.
|
|
41
|
+
|
|
9
42
|
## 1.31.0 (2026-08-23)
|
|
10
43
|
|
|
11
44
|
- **RTSC-713** — your agent can set Retasc up for you. It runs `bind` itself now, so
|
package/dist/commands/bind.js
CHANGED
|
@@ -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";
|
|
8
|
-
import { resolveLauncher, launcherNote,
|
|
6
|
+
import { readLocalBinding, resolveBinding, bindingRefused } from "../lib/binding.js";
|
|
7
|
+
import { getBinding, setBinding, removeBinding, newWorkspaceId } from "../lib/keystore.js";
|
|
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 { 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
|
-
|
|
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))
|
|
@@ -177,7 +212,10 @@ export async function chooseInstall(
|
|
|
177
212
|
flag, deps = {}) {
|
|
178
213
|
if (flag === false)
|
|
179
214
|
return false;
|
|
180
|
-
|
|
215
|
+
// RTSC-715 — the same durable-PATH question `resolveLauncher` asks. Under npx a bare
|
|
216
|
+
// `retasc` resolves into the npx cache, so this shortcut used to conclude "nothing to
|
|
217
|
+
// install" on a machine that had nothing installed.
|
|
218
|
+
const onPath = deps.onPath ?? (() => onDurablePath("retasc"));
|
|
181
219
|
// Nothing to install, so nothing to ask.
|
|
182
220
|
if (onPath())
|
|
183
221
|
return true;
|
|
@@ -708,6 +746,27 @@ export async function bindAction(opts) {
|
|
|
708
746
|
orgs,
|
|
709
747
|
pendingInvites: pendingInvites.length ? pendingInvites : undefined,
|
|
710
748
|
missing: ["--org-id or --org-name", "--project or --project-id"],
|
|
749
|
+
// RTSC-720 — the questions as data, so the agent presents them instead of
|
|
750
|
+
// authoring its own from the prose below. On the run that shaped this, the
|
|
751
|
+
// agent handed this state as a sentence asked its three questions in a
|
|
752
|
+
// paragraph; and after a restart the sentence read as a STATUS, so it reported
|
|
753
|
+
// "logged in but no organization" and waited for the human to notice.
|
|
754
|
+
askHuman: pendingInvites.length
|
|
755
|
+
? [
|
|
756
|
+
{
|
|
757
|
+
id: "onboarding.invite",
|
|
758
|
+
question: `Join "${pendingInvites[0].org}", or start your own workspace?`,
|
|
759
|
+
context: `You have a pending invitation. Joining puts your work in that team's ` +
|
|
760
|
+
`organization; a new organization cannot be deleted from the terminal ` +
|
|
761
|
+
`once created.`,
|
|
762
|
+
options: [
|
|
763
|
+
{ value: "join", label: `Join "${pendingInvites[0].org}"`, detail: "Their projects, shared with the team that invited you." },
|
|
764
|
+
{ value: "own", label: "Start my own workspace", detail: "Pick this only if the invite was not meant for this work." },
|
|
765
|
+
],
|
|
766
|
+
resume: { tool: "join" },
|
|
767
|
+
},
|
|
768
|
+
]
|
|
769
|
+
: orgQuestions(signedInAs, folder),
|
|
711
770
|
next: pendingInvites.length
|
|
712
771
|
? `Signed in as ${signedInAs}. They have been invited to ` +
|
|
713
772
|
`${pendingInvites.map((i) => `"${i.org}"`).join(", ")}. ASK whether they want to join ` +
|
|
@@ -756,6 +815,7 @@ export async function bindAction(opts) {
|
|
|
756
815
|
org: { id: orgId, name: opts.orgName ?? orgId },
|
|
757
816
|
projects: list,
|
|
758
817
|
missing: list.length ? ["--project-id"] : ["--project", "--prefix"],
|
|
818
|
+
askHuman: projectQuestions(folder, list),
|
|
759
819
|
next: list.length
|
|
760
820
|
? `That organization has ${list.length} projects. Ask which one THIS FOLDER is ` +
|
|
761
821
|
`for, then re-run with --project-id. Confirm the folder with them: this will ` +
|
|
@@ -810,6 +870,10 @@ export async function bindAction(opts) {
|
|
|
810
870
|
pause({
|
|
811
871
|
state: "BOUND",
|
|
812
872
|
org: { id: orgId },
|
|
873
|
+
// The statement as data (tellHuman, RTSC-717's convention): exact words to relay,
|
|
874
|
+
// so "restart" survives the trip without being reworded into "you may need to".
|
|
875
|
+
tellHuman: `Setup is finished and ${folder} is connected. Restart me — start a NEW session, ` +
|
|
876
|
+
`not a resumed one — and then say "show me my issues".`,
|
|
813
877
|
next: `Setup is complete and ${folder} is connected. TELL YOUR HUMAN it worked, name the ` +
|
|
814
878
|
`folder and the project, and ask them to restart you — your Retasc tools only load ` +
|
|
815
879
|
`when your client starts, so setup is not finished until they do. A resumed session ` +
|
package/dist/commands/mcp.js
CHANGED
|
@@ -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.")
|
package/dist/lib/binding.js
CHANGED
|
@@ -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) {
|
package/dist/lib/launcher.js
CHANGED
|
@@ -37,6 +37,59 @@ export function runsOk(command, args = []) {
|
|
|
37
37
|
// different program, and pointing a marker at it would be worse than not writing one.
|
|
38
38
|
return /^\d+\.\d+\.\d+/.test(out) ? out : null;
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Is `retasc` on PATH in a way that OUTLIVES this process (RTSC-715)?
|
|
42
|
+
*
|
|
43
|
+
* `runsOk("retasc")` answers a subtly different question, and the difference cost a
|
|
44
|
+
* fresh-machine setup its tools. When `bind` runs under `npx @retasc/cli@latest`, npx puts
|
|
45
|
+
* its own cache directory on the child's PATH, so `retasc` resolves and runs:
|
|
46
|
+
*
|
|
47
|
+
* outside npx: which retasc => /Users/me/.npm-global/bin/retasc
|
|
48
|
+
* inside npx: which retasc => /Users/me/.npm/_npx/ccd2a9…/node_modules/.bin/retasc
|
|
49
|
+
*
|
|
50
|
+
* `resolveLauncher` read that as "already usable, leave it alone" and wrote a bare
|
|
51
|
+
* `retasc` into the marker, marked verified, having genuinely run it. Then npx exited,
|
|
52
|
+
* that directory left PATH, and the MCP client spawning the proxy got
|
|
53
|
+
* `ENOENT: Executable not found in $PATH: "retasc"`. Setup reported success, the Dash
|
|
54
|
+
* showed a healthy workspace, and no tools loaded.
|
|
55
|
+
*
|
|
56
|
+
* The probe was not sloppy. It measured the right thing on the wrong PATH: the one `bind`
|
|
57
|
+
* inherited, rather than the one the agent spawns with later. So resolve the command to a
|
|
58
|
+
* real path and reject the npx cache, which is transient by construction — the whole point
|
|
59
|
+
* of `_npx` is that it is not a durable install.
|
|
60
|
+
*
|
|
61
|
+
* Resolved with `which`/`where` rather than by walking PATH ourselves: that is the lookup
|
|
62
|
+
* the shell will actually do, PATHEXT and all, and reimplementing it is how this class of
|
|
63
|
+
* bug gets a second edition.
|
|
64
|
+
*
|
|
65
|
+
* Anything unresolvable answers FALSE, deliberately. Every caller fallback (global install,
|
|
66
|
+
* absolute path, pinned npx) still produces something that runs, whereas a wrong "yes"
|
|
67
|
+
* produces a marker that cannot start. The two errors are not symmetric.
|
|
68
|
+
*/
|
|
69
|
+
export function onDurablePath(command = "retasc") {
|
|
70
|
+
let r;
|
|
71
|
+
try {
|
|
72
|
+
r = spawnSync(WIN ? "where" : "which", [command], {
|
|
73
|
+
encoding: "utf8",
|
|
74
|
+
shell: WIN,
|
|
75
|
+
timeout: 60_000,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
if (r.error || r.status !== 0)
|
|
82
|
+
return false;
|
|
83
|
+
// `where` can print several matches; the first is the one that would run.
|
|
84
|
+
const resolved = (r.stdout || "").trim().split(/\r?\n/)[0]?.trim();
|
|
85
|
+
if (!resolved)
|
|
86
|
+
return false;
|
|
87
|
+
// The npx cache. Separators on both sides, so a project that happens to live in a
|
|
88
|
+
// directory called `_npx` is not caught by it.
|
|
89
|
+
if (/[\\/]_npx[\\/]/.test(resolved))
|
|
90
|
+
return false;
|
|
91
|
+
return runsOk(resolved) !== null;
|
|
92
|
+
}
|
|
40
93
|
/** npm's global prefix, or null when npm itself can't be run. */
|
|
41
94
|
export function npmGlobalPrefix() {
|
|
42
95
|
let r;
|
|
@@ -156,8 +209,12 @@ function installGlobal(version) {
|
|
|
156
209
|
* anyone asking. It is pinned here for exactly that reason.
|
|
157
210
|
*/
|
|
158
211
|
export function resolveLauncher(opts) {
|
|
159
|
-
// 1. Already usable? Leave it alone.
|
|
160
|
-
|
|
212
|
+
// 1. Already usable, and still usable after we exit? Leave it alone.
|
|
213
|
+
//
|
|
214
|
+
// RTSC-715 — `onDurablePath`, not `runsOk`. Under npx the bare name resolves into the
|
|
215
|
+
// npx cache, which disappears when the command ends, so this branch used to write a
|
|
216
|
+
// marker that could not start.
|
|
217
|
+
if (onDurablePath("retasc")) {
|
|
161
218
|
return { launcher: { command: "retasc", args: [] }, how: "on-path", verified: true };
|
|
162
219
|
}
|
|
163
220
|
const npxLauncher = { command: "npx", args: ["-y", `${PKG}@${opts.version}`] };
|
|
@@ -176,7 +233,12 @@ export function resolveLauncher(opts) {
|
|
|
176
233
|
// 3. Prove it, by running it. An exit code of 0 is not evidence the command resolves:
|
|
177
234
|
// npm can install happily into a prefix whose bin directory PATH never searches.
|
|
178
235
|
if (!failure) {
|
|
179
|
-
|
|
236
|
+
// RTSC-715 — `onDurablePath` here too, and this one is the subtler half. Under npx the
|
|
237
|
+
// bare name still resolves to the npx cache AHEAD of the global bin we just installed
|
|
238
|
+
// into, so a successful install would have been confirmed by running the wrong binary
|
|
239
|
+
// and written the same unusable marker. The absolute-path loop below then covers the
|
|
240
|
+
// real case this branch exists for: installed, but PATH cannot see it.
|
|
241
|
+
if (onDurablePath("retasc")) {
|
|
180
242
|
return { launcher: { command: "retasc", args: [] }, how: "installed", verified: true };
|
|
181
243
|
}
|
|
182
244
|
// Installed, but PATH can't see it. Name the file directly — this is the case a
|
package/dist/lib/outcome.js
CHANGED
|
@@ -96,6 +96,52 @@ export const TERMINAL_STATES = [
|
|
|
96
96
|
export function isContinuable(state) {
|
|
97
97
|
return CONTINUABLE_STATES.includes(state);
|
|
98
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* The questions each continuable state asks, as pure builders so they are testable
|
|
101
|
+
* without a network. bindAction attaches their output to the outcome it emits.
|
|
102
|
+
*/
|
|
103
|
+
export function orgQuestions(signedInAs, folder) {
|
|
104
|
+
const context = (signedInAs ? `Signed in as ${signedInAs}. ` : "") +
|
|
105
|
+
`These three answers create the workspace; the folder question is what connects ` +
|
|
106
|
+
`THIS folder to it.`;
|
|
107
|
+
return [
|
|
108
|
+
{ id: "org.name", question: "What should your workspace (organization) be called?", context, input: "text", resume: { tool: "bind", arg: "--org-name" } },
|
|
109
|
+
{ id: "project.name", question: "And the first project?", input: "text", resume: { tool: "bind", arg: "--project" } },
|
|
110
|
+
{ id: "project.prefix", question: "A short prefix for issue ids?", input: "text", hint: "e.g. ACME", resume: { tool: "bind", arg: "--prefix" } },
|
|
111
|
+
{
|
|
112
|
+
// The folder rides IN the exchange, not after it. It earned this on the run that
|
|
113
|
+
// shaped RTSC-720: the human was standing in /Users, the outcome named the path,
|
|
114
|
+
// and the agent caught it before anything was written. A wrong binding has no
|
|
115
|
+
// symptom once written (RTSC-532).
|
|
116
|
+
id: "bind.folder",
|
|
117
|
+
question: `Connect ${folder} to that project?`,
|
|
118
|
+
options: [
|
|
119
|
+
{ value: "yes", label: `Yes — ${folder} is the right folder` },
|
|
120
|
+
{ value: "no", label: "No — I am in the wrong folder", detail: "Open the right one and run setup there instead." },
|
|
121
|
+
],
|
|
122
|
+
resume: { tool: "bind" },
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
export function projectQuestions(folder, projects) {
|
|
127
|
+
if (projects.length) {
|
|
128
|
+
return [
|
|
129
|
+
{
|
|
130
|
+
id: "project.pick",
|
|
131
|
+
question: `Which project is ${folder} for?`,
|
|
132
|
+
options: projects.map((p) => ({
|
|
133
|
+
value: p.id,
|
|
134
|
+
label: `${p.prefix ?? ""} — ${p.name ?? p.id}`.trim(),
|
|
135
|
+
})),
|
|
136
|
+
resume: { tool: "bind", arg: "--project-id" },
|
|
137
|
+
},
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
return [
|
|
141
|
+
{ id: "project.name", question: "What is the first project called?", input: "text", resume: { tool: "bind", arg: "--project" } },
|
|
142
|
+
{ id: "project.prefix", question: "A short prefix for issue ids?", input: "text", hint: "e.g. ACME", resume: { tool: "bind", arg: "--prefix" } },
|
|
143
|
+
];
|
|
144
|
+
}
|
|
99
145
|
/** stdout, one line, no trailing prose. Never stderr: see the module header. */
|
|
100
146
|
export function emit(e) {
|
|
101
147
|
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.
|
|
3
|
+
"version": "1.32.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": {
|