@retasc/cli 1.7.1 → 1.8.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/commands/bind.js +124 -64
- package/dist/commands/claim.js +3 -0
- package/dist/commands/doctor.js +40 -0
- package/dist/commands/mcp.js +44 -24
- package/dist/index.js +2 -9
- package/dist/lib/binding.js +7 -2
- package/dist/lib/launcher.js +182 -0
- package/dist/version.js +11 -0
- package/package.json +1 -1
package/dist/commands/bind.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
2
|
import { stdin, stdout } from "node:process";
|
|
3
3
|
import { api } from "../api.js";
|
|
4
|
+
import { deviceLogin } from "../auth.js";
|
|
4
5
|
import { loadConfig } from "../config.js";
|
|
5
6
|
import { installMarker } from "./mcp.js";
|
|
6
7
|
import { readLocalBinding, resolveBinding } from "../lib/binding.js";
|
|
@@ -63,7 +64,42 @@ export async function pick(label, items, render, askFn = ask) {
|
|
|
63
64
|
}
|
|
64
65
|
throw new Error("no valid choice — aborting");
|
|
65
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* The message to show when `bind` aborts mid-run, or null when there's nothing
|
|
69
|
+
* to say. `bind` commits a new org server-side before the project step; if a
|
|
70
|
+
* later step throws, that org is stranded (no project, no key, no binding) and
|
|
71
|
+
* unreachable from the CLI. There's no CLI-callable org delete, so we surface
|
|
72
|
+
* the id and the resume command instead (RTSC-297, Option 2).
|
|
73
|
+
*
|
|
74
|
+
* It fires ONLY for an org THIS run created that never got bound. A merely
|
|
75
|
+
* SELECTED org (or one already bound before the failure) is not ours to
|
|
76
|
+
* reclaim, and returns null — nothing extra is printed.
|
|
77
|
+
*/
|
|
78
|
+
export function strandRecoveryHint(args) {
|
|
79
|
+
const { createdOrgThisRun, orgId, bindingWritten } = args;
|
|
80
|
+
if (!createdOrgThisRun || !orgId || bindingWritten)
|
|
81
|
+
return null;
|
|
82
|
+
return (`\n✗ Aborted before this folder was bound. A new org was created (${orgId}) ` +
|
|
83
|
+
`but has no project or key yet — re-run to finish:\n` +
|
|
84
|
+
` retasc bind --org-id ${orgId}`);
|
|
85
|
+
}
|
|
66
86
|
export async function bindAction(opts) {
|
|
87
|
+
// RTSC-481 — sign in first when there is no session at all.
|
|
88
|
+
//
|
|
89
|
+
// `withAuth` deliberately refuses to start a device flow for someone who was
|
|
90
|
+
// never signed in: on an arbitrary call that would be a surprise browser prompt
|
|
91
|
+
// under a misleading "session expired". That reasoning holds for every OTHER
|
|
92
|
+
// command and is left alone. It does not hold here, because bind is the single
|
|
93
|
+
// instruction the Dash hands a brand-new owner, and "run this, but run the other
|
|
94
|
+
// one first" is not a single instruction. So the sign-in is explicit, announced,
|
|
95
|
+
// and only when a human is present to authorize it.
|
|
96
|
+
if (!loadConfig().token) {
|
|
97
|
+
if (!isInteractive()) {
|
|
98
|
+
throw new Error("Not signed in. Run `retasc login` first (no TTY here for the device flow).");
|
|
99
|
+
}
|
|
100
|
+
console.error("Not signed in yet — authenticating with GitHub first…");
|
|
101
|
+
await deviceLogin();
|
|
102
|
+
}
|
|
67
103
|
const cfg = loadConfig();
|
|
68
104
|
const cwd = process.cwd();
|
|
69
105
|
// --- loud on re-bind -------------------------------------------------------
|
|
@@ -106,10 +142,20 @@ export async function bindAction(opts) {
|
|
|
106
142
|
}
|
|
107
143
|
}
|
|
108
144
|
// --- resolve org (pick / flag / create) ------------------------------------
|
|
145
|
+
// Track whether THIS run created the org (vs. selected a pre-existing one or
|
|
146
|
+
// took one via --org-id). An org is created and committed server-side BEFORE
|
|
147
|
+
// the project step runs, so any abort in the project/mint/bind steps below
|
|
148
|
+
// strands it: no project, no key, no local binding — invisible from the CLI,
|
|
149
|
+
// and (orgs land on the billing rail) accumulating against the account on
|
|
150
|
+
// repeated aborts. There's no CLI-callable org delete, so instead we name the
|
|
151
|
+
// stranded org in the error and print the command to resume (RTSC-297). A
|
|
152
|
+
// merely-SELECTED org must never trigger this — it isn't ours to reclaim.
|
|
109
153
|
let orgId = opts.orgId;
|
|
154
|
+
let createdOrgThisRun = false;
|
|
110
155
|
if (!orgId && opts.orgName) {
|
|
111
156
|
const org = (await api.createOrg({ name: opts.orgName }));
|
|
112
157
|
orgId = org.orgId;
|
|
158
|
+
createdOrgThisRun = true;
|
|
113
159
|
console.log(`✓ Created org "${opts.orgName}".`);
|
|
114
160
|
}
|
|
115
161
|
if (!orgId) {
|
|
@@ -125,6 +171,7 @@ export async function bindAction(opts) {
|
|
|
125
171
|
if (!name)
|
|
126
172
|
throw new Error("org name required");
|
|
127
173
|
orgId = (await api.createOrg({ name })).orgId;
|
|
174
|
+
createdOrgThisRun = true;
|
|
128
175
|
console.log(`✓ Created org "${name}".`);
|
|
129
176
|
}
|
|
130
177
|
}
|
|
@@ -135,76 +182,89 @@ export async function bindAction(opts) {
|
|
|
135
182
|
throw new Error("no org selected — pass --org-id <id> or --org-name <name> (or run interactively).");
|
|
136
183
|
}
|
|
137
184
|
}
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
185
|
+
// Everything from here to the binding write is the strand window: the org
|
|
186
|
+
// exists but the workspace isn't bound yet. If we abort in it after creating
|
|
187
|
+
// the org this run, name it and print the resume command (RTSC-297).
|
|
188
|
+
let bindingWritten = false;
|
|
189
|
+
try {
|
|
190
|
+
// --- resolve project (pick / flag / create) --------------------------------
|
|
191
|
+
let projectId = opts.projectId;
|
|
192
|
+
let prefix;
|
|
193
|
+
if (!projectId && opts.project && opts.prefix) {
|
|
194
|
+
const p = (await api.createProject({ orgId: orgId, name: opts.project, prefix: opts.prefix }));
|
|
195
|
+
projectId = p.projectId;
|
|
196
|
+
prefix = p.prefix;
|
|
197
|
+
console.log(`✓ Created project ${p.prefix}.`);
|
|
198
|
+
}
|
|
199
|
+
if (!projectId) {
|
|
200
|
+
const { projects } = (await api.listProjects({ orgId: orgId }));
|
|
201
|
+
const list = projects ?? [];
|
|
202
|
+
if (isInteractive()) {
|
|
203
|
+
const chosen = await pick("Select a project", list, (p) => `${p.prefix} — ${p.name}`);
|
|
204
|
+
if (chosen) {
|
|
205
|
+
projectId = chosen.id;
|
|
206
|
+
prefix = chosen.prefix;
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
const name = await ask("New project name: ");
|
|
210
|
+
const pfx = (await ask("Project prefix (e.g. ACME): ")).toUpperCase();
|
|
211
|
+
if (!name || !pfx)
|
|
212
|
+
throw new Error("project name and prefix required");
|
|
213
|
+
const p = (await api.createProject({ orgId: orgId, name, prefix: pfx }));
|
|
214
|
+
projectId = p.projectId;
|
|
215
|
+
prefix = p.prefix;
|
|
216
|
+
console.log(`✓ Created project ${p.prefix}.`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
else if (list.length === 1) {
|
|
220
|
+
projectId = list[0].id;
|
|
221
|
+
prefix = list[0].prefix;
|
|
155
222
|
}
|
|
156
223
|
else {
|
|
157
|
-
|
|
158
|
-
const pfx = (await ask("Project prefix (e.g. ACME): ")).toUpperCase();
|
|
159
|
-
if (!name || !pfx)
|
|
160
|
-
throw new Error("project name and prefix required");
|
|
161
|
-
const p = (await api.createProject({ orgId: orgId, name, prefix: pfx }));
|
|
162
|
-
projectId = p.projectId;
|
|
163
|
-
prefix = p.prefix;
|
|
164
|
-
console.log(`✓ Created project ${p.prefix}.`);
|
|
224
|
+
throw new Error("no project selected — pass --project-id <id>, or --project <name> --prefix <PFX>.");
|
|
165
225
|
}
|
|
166
226
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
227
|
+
// --- mint a key for THIS (org, project) and wire the watchdog into THIS folder
|
|
228
|
+
const minted = (await api.mintKey({
|
|
229
|
+
orgId: orgId,
|
|
230
|
+
projectId: projectId,
|
|
231
|
+
agentName: opts.agent,
|
|
232
|
+
runtime: opts.runtime ?? "claude-code",
|
|
233
|
+
keyName: prefix ? `${prefix} key` : undefined,
|
|
234
|
+
}));
|
|
235
|
+
console.log(`✓ Minted key for this workspace (${minted.key.slice(0, 14)}…).\n`);
|
|
236
|
+
// RTSC-92: the secret stays OUT of the repo. Store it in the home keystore
|
|
237
|
+
// keyed by a workspace id; reuse this folder's existing id (so a re-bind, or a
|
|
238
|
+
// teammate's committed marker, keeps the same id) or mint a fresh one.
|
|
239
|
+
const workspaceId = existing?.workspaceId ?? newWorkspaceId();
|
|
240
|
+
setBinding(workspaceId, {
|
|
241
|
+
orgId: orgId,
|
|
242
|
+
projectId: projectId,
|
|
243
|
+
key: minted.key,
|
|
244
|
+
url: cfg.mcpUrl,
|
|
245
|
+
prefix,
|
|
246
|
+
orgName: undefined,
|
|
247
|
+
boundPath: cwd,
|
|
248
|
+
createdAt: Date.now(),
|
|
249
|
+
});
|
|
250
|
+
bindingWritten = true; // strand window closed — the org is now reachable
|
|
251
|
+
// Per-folder only (local scope), always watchdog. The marker carries only the
|
|
252
|
+
// workspace id — no secret — so ./.mcp.json is safe to commit.
|
|
253
|
+
installMarker({ workspaceId, scope: "local" });
|
|
254
|
+
// Confirm the binding the same way the agent will see it.
|
|
255
|
+
try {
|
|
256
|
+
const b = await resolveBinding(cfg.mcpUrl, minted.key);
|
|
257
|
+
console.log(`\n✓ This folder is bound to org "${b.org.name}" / project ${b.project.prefix}.\n` +
|
|
258
|
+
` Agents launched here can only ever read or write ${b.project.prefix}.`);
|
|
170
259
|
}
|
|
171
|
-
|
|
172
|
-
|
|
260
|
+
catch {
|
|
261
|
+
/* binding written; whoami confirmation is best-effort */
|
|
173
262
|
}
|
|
174
263
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
runtime: opts.runtime ?? "claude-code",
|
|
181
|
-
keyName: prefix ? `${prefix} key` : undefined,
|
|
182
|
-
}));
|
|
183
|
-
console.log(`✓ Minted key for this workspace (${minted.key.slice(0, 14)}…).\n`);
|
|
184
|
-
// RTSC-92: the secret stays OUT of the repo. Store it in the home keystore
|
|
185
|
-
// keyed by a workspace id; reuse this folder's existing id (so a re-bind, or a
|
|
186
|
-
// teammate's committed marker, keeps the same id) or mint a fresh one.
|
|
187
|
-
const workspaceId = existing?.workspaceId ?? newWorkspaceId();
|
|
188
|
-
setBinding(workspaceId, {
|
|
189
|
-
orgId: orgId,
|
|
190
|
-
projectId: projectId,
|
|
191
|
-
key: minted.key,
|
|
192
|
-
url: cfg.mcpUrl,
|
|
193
|
-
prefix,
|
|
194
|
-
orgName: undefined,
|
|
195
|
-
boundPath: cwd,
|
|
196
|
-
createdAt: Date.now(),
|
|
197
|
-
});
|
|
198
|
-
// Per-folder only (local scope), always watchdog. The marker carries only the
|
|
199
|
-
// workspace id — no secret — so ./.mcp.json is safe to commit.
|
|
200
|
-
installMarker({ workspaceId, scope: "local" });
|
|
201
|
-
// Confirm the binding the same way the agent will see it.
|
|
202
|
-
try {
|
|
203
|
-
const b = await resolveBinding(cfg.mcpUrl, minted.key);
|
|
204
|
-
console.log(`\n✓ This folder is bound to org "${b.org.name}" / project ${b.project.prefix}.\n` +
|
|
205
|
-
` Agents launched here can only ever read or write ${b.project.prefix}.`);
|
|
206
|
-
}
|
|
207
|
-
catch {
|
|
208
|
-
/* binding written; whoami confirmation is best-effort */
|
|
264
|
+
catch (err) {
|
|
265
|
+
const hint = strandRecoveryHint({ createdOrgThisRun, orgId, bindingWritten });
|
|
266
|
+
if (hint)
|
|
267
|
+
console.error(hint);
|
|
268
|
+
throw err;
|
|
209
269
|
}
|
|
210
270
|
}
|
package/dist/commands/claim.js
CHANGED
|
@@ -193,6 +193,9 @@ export async function claimAction(opts) {
|
|
|
193
193
|
note(`✓ Claimed ${issueId}${title ? ` — ${title}` : ""}`);
|
|
194
194
|
note(` worktree: ${plan.path}`);
|
|
195
195
|
note(` branch: ${plan.branch}`);
|
|
196
|
+
// RTSC-446: a fresh worktree checks out tracked files only — deps/artifacts are absent.
|
|
197
|
+
note(` Fresh worktree: only tracked files. Run the repo's usual setup (what docs/CI`);
|
|
198
|
+
note(` run on a fresh checkout) before your first build or test.`);
|
|
196
199
|
finish(plan.path, plan.branch, issueId, title, claim.claimToken, opts);
|
|
197
200
|
}
|
|
198
201
|
/** Emit machine output / drop into the worktree per the chosen flags. */
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { loadConfig } from "../config.js";
|
|
2
2
|
import { claudeConfigPath, isNetworkError, readGlobalBinding, readLocalBinding, readShadowedBinding, resolveBinding, sameIdentity, } from "../lib/binding.js";
|
|
3
3
|
import { getBinding } from "../lib/keystore.js";
|
|
4
|
+
import { runsOk } from "../lib/launcher.js";
|
|
4
5
|
// RTSC-91 (DESIGN §13): `retasc doctor` — confirm THIS folder is correctly and
|
|
5
6
|
// safely bound. The question a human actually has is "which org/project does
|
|
6
7
|
// this folder talk to?", so the healthy answer is ONE line naming them.
|
|
@@ -20,6 +21,41 @@ const bad = (m) => console.log(` ✗ ${m}`);
|
|
|
20
21
|
// hostile endpoint (reachable via a config-supplied legacy url) must not be
|
|
21
22
|
// able to smuggle ANSI escapes into the very output people trust for ✓/✗.
|
|
22
23
|
const clean = (s) => String(s).replace(/[\x00-\x1f\x7f]/g, " ");
|
|
24
|
+
/**
|
|
25
|
+
* RTSC-493: can the registered entry actually be STARTED on this machine?
|
|
26
|
+
*
|
|
27
|
+
* Every other check here asks whether the binding is right. This one asks whether it can
|
|
28
|
+
* run at all, which is a different failure and an invisible one: the entry names a
|
|
29
|
+
* command, something else spawns it later, and when that command isn't there the only
|
|
30
|
+
* symptom is an agent with no Retasc tools. Nothing points back here.
|
|
31
|
+
*
|
|
32
|
+
* Folders bound before the fix carry a bare `retasc` that npx never installed, and
|
|
33
|
+
* re-binding is what repairs them, so this is the surface that has to say so.
|
|
34
|
+
*/
|
|
35
|
+
export function launcherVerdict(local, probe = runsOk) {
|
|
36
|
+
if (!local.command)
|
|
37
|
+
return null; // HTTP transport spawns nothing
|
|
38
|
+
// The stored args end in `mcp-proxy` (the subcommand). Probe the LAUNCHER, so drop it.
|
|
39
|
+
const args = local.args ?? [];
|
|
40
|
+
const probeArgs = args[args.length - 1] === "mcp-proxy" ? args.slice(0, -1) : args;
|
|
41
|
+
return {
|
|
42
|
+
startable: Boolean(probe(local.command, probeArgs)),
|
|
43
|
+
shown: clean([local.command, ...probeArgs].join(" ")),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function checkLauncher(local) {
|
|
47
|
+
const v = launcherVerdict(local);
|
|
48
|
+
if (!v)
|
|
49
|
+
return;
|
|
50
|
+
if (v.startable) {
|
|
51
|
+
ok(`your agent can start Retasc (${v.shown}).`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
bad(`your agent CANNOT start Retasc — this folder is registered to run "${v.shown}",\n` +
|
|
55
|
+
` which doesn't run on this machine. The binding itself is fine; the command is missing.\n` +
|
|
56
|
+
` This is what a bind through npx used to leave behind.\n` +
|
|
57
|
+
` Fix: npm install -g @retasc/cli then re-run \`retasc bind\` here.`);
|
|
58
|
+
}
|
|
23
59
|
export async function doctorAction() {
|
|
24
60
|
const cfg = loadConfig();
|
|
25
61
|
const cwd = process.cwd();
|
|
@@ -69,6 +105,10 @@ export async function doctorAction() {
|
|
|
69
105
|
bad(`key not accepted by the server: ${msg}. Re-run \`retasc bind\`.`);
|
|
70
106
|
}
|
|
71
107
|
}
|
|
108
|
+
// Directly under the headline, because "bound to org X" reads as an all-clear and
|
|
109
|
+
// this is the one way it can be true and the folder still not work (RTSC-493).
|
|
110
|
+
// Local and independent of the server, so it runs even when the network is down.
|
|
111
|
+
checkLauncher(local);
|
|
72
112
|
// Caveats below the headline — each is a real risk, none is the common case.
|
|
73
113
|
if (local.workspaceId) {
|
|
74
114
|
// The id resolved, but if it was bound at a different folder, this marker
|
package/dist/commands/mcp.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { resolveLauncher, launcherNote, portableLauncher } from "../lib/launcher.js";
|
|
5
|
+
import { VERSION } from "../version.js";
|
|
4
6
|
export const SERVER_NAME = "retasc";
|
|
5
7
|
/** Normalize a user-supplied scope string. `user` (global) is refused and
|
|
6
8
|
* downgraded to `local`, loudly — per-folder binding is the only right way. */
|
|
@@ -32,31 +34,31 @@ export function mcpConfigBlock(url, key) {
|
|
|
32
34
|
* claimed issues' leases alive automatically. url+key go via env (not a header,
|
|
33
35
|
* since this is a spawned command). Harness-agnostic: any stdio MCP client works.
|
|
34
36
|
*/
|
|
35
|
-
export function mcpProxyEntry(url, key) {
|
|
37
|
+
export function mcpProxyEntry(url, key, launcher) {
|
|
36
38
|
return {
|
|
37
|
-
command:
|
|
38
|
-
args: ["mcp-proxy"],
|
|
39
|
+
command: launcher.command,
|
|
40
|
+
args: [...launcher.args, "mcp-proxy"],
|
|
39
41
|
env: { RETASC_MCP_URL: url, RETASC_MCP_KEY: key },
|
|
40
42
|
};
|
|
41
43
|
}
|
|
42
44
|
/** Copy-pasteable watchdog block for Codex / OpenCode / any stdio MCP client. */
|
|
43
|
-
export function mcpProxyConfigBlock(url, key) {
|
|
44
|
-
return JSON.stringify({ mcpServers: { [SERVER_NAME]: mcpProxyEntry(url, key) } }, null, 2);
|
|
45
|
+
export function mcpProxyConfigBlock(url, key, launcher) {
|
|
46
|
+
return JSON.stringify({ mcpServers: { [SERVER_NAME]: mcpProxyEntry(url, key, launcher) } }, null, 2);
|
|
45
47
|
}
|
|
46
48
|
/**
|
|
47
49
|
* RTSC-92: the SECRET-FREE watchdog marker. No key — only the opaque workspace
|
|
48
50
|
* id, which the proxy resolves to a key through the home keystore. Safe to
|
|
49
51
|
* commit; doubles as the "this repo is a Retasc workspace" marker.
|
|
50
52
|
*/
|
|
51
|
-
export function mcpMarkerEntry(workspaceId) {
|
|
53
|
+
export function mcpMarkerEntry(workspaceId, launcher) {
|
|
52
54
|
return {
|
|
53
|
-
command:
|
|
54
|
-
args: ["mcp-proxy"],
|
|
55
|
+
command: launcher.command,
|
|
56
|
+
args: [...launcher.args, "mcp-proxy"],
|
|
55
57
|
env: { RETASC_WORKSPACE: workspaceId },
|
|
56
58
|
};
|
|
57
59
|
}
|
|
58
|
-
export function mcpMarkerConfigBlock(workspaceId) {
|
|
59
|
-
return JSON.stringify({ mcpServers: { [SERVER_NAME]: mcpMarkerEntry(workspaceId) } }, null, 2);
|
|
60
|
+
export function mcpMarkerConfigBlock(workspaceId, launcher) {
|
|
61
|
+
return JSON.stringify({ mcpServers: { [SERVER_NAME]: mcpMarkerEntry(workspaceId, launcher) } }, null, 2);
|
|
60
62
|
}
|
|
61
63
|
// --- `claude mcp add` argv builders (pure, exported for tests) -------------
|
|
62
64
|
// RTSC-99: the stdio builders put SERVER_NAME *before* the variadic `--env`
|
|
@@ -75,7 +77,7 @@ export function buildHttpAddArgs(url, key, scope) {
|
|
|
75
77
|
];
|
|
76
78
|
}
|
|
77
79
|
/** Build argv for the stdio watchdog proxy (RTSC-44). */
|
|
78
|
-
export function buildWatchdogAddArgs(url, key, scope) {
|
|
80
|
+
export function buildWatchdogAddArgs(url, key, scope, launcher) {
|
|
79
81
|
return [
|
|
80
82
|
"mcp", "add",
|
|
81
83
|
SERVER_NAME,
|
|
@@ -83,28 +85,28 @@ export function buildWatchdogAddArgs(url, key, scope) {
|
|
|
83
85
|
"--scope", scope,
|
|
84
86
|
"--env", `RETASC_MCP_URL=${url}`,
|
|
85
87
|
"--env", `RETASC_MCP_KEY=${key}`,
|
|
86
|
-
"--",
|
|
88
|
+
"--", launcher.command, ...launcher.args, "mcp-proxy",
|
|
87
89
|
];
|
|
88
90
|
}
|
|
89
91
|
/** Build argv for the secret-free stdio marker (RTSC-92). */
|
|
90
|
-
export function buildMarkerAddArgs(workspaceId, scope) {
|
|
92
|
+
export function buildMarkerAddArgs(workspaceId, scope, launcher) {
|
|
91
93
|
return [
|
|
92
94
|
"mcp", "add",
|
|
93
95
|
SERVER_NAME,
|
|
94
96
|
"--transport", "stdio",
|
|
95
97
|
"--scope", scope,
|
|
96
98
|
"--env", `RETASC_WORKSPACE=${workspaceId}`,
|
|
97
|
-
"--",
|
|
99
|
+
"--", launcher.command, ...launcher.args, "mcp-proxy",
|
|
98
100
|
];
|
|
99
101
|
}
|
|
100
102
|
function tryClaudeCli(url, key, scope) {
|
|
101
103
|
return runClaudeAdd(buildHttpAddArgs(url, key, scope));
|
|
102
104
|
}
|
|
103
|
-
function tryClaudeCliWatchdog(url, key, scope) {
|
|
104
|
-
return runClaudeAdd(buildWatchdogAddArgs(url, key, scope));
|
|
105
|
+
function tryClaudeCliWatchdog(url, key, scope, launcher) {
|
|
106
|
+
return runClaudeAdd(buildWatchdogAddArgs(url, key, scope, launcher));
|
|
105
107
|
}
|
|
106
|
-
function tryClaudeCliMarker(workspaceId, scope) {
|
|
107
|
-
return runClaudeAdd(buildMarkerAddArgs(workspaceId, scope));
|
|
108
|
+
function tryClaudeCliMarker(workspaceId, scope, launcher) {
|
|
109
|
+
return runClaudeAdd(buildMarkerAddArgs(workspaceId, scope, launcher));
|
|
108
110
|
}
|
|
109
111
|
function runClaudeAdd(args) {
|
|
110
112
|
const r = spawnSync("claude", args, { encoding: "utf8" });
|
|
@@ -167,17 +169,23 @@ export function installMcp(opts) {
|
|
|
167
169
|
const scope = opts.scope ?? "local";
|
|
168
170
|
// Watchdog mode (RTSC-44): wire the stdio proxy so claims stay alive automatically.
|
|
169
171
|
if (opts.watchdog) {
|
|
170
|
-
|
|
172
|
+
// Only the stdio forms spawn a command; the plain HTTP entry below carries a URL and
|
|
173
|
+
// needs nothing installed, so resolving is scoped to the branch that depends on it.
|
|
174
|
+
const resolved = resolveLauncher({ version: VERSION, install: opts.install });
|
|
175
|
+
const note = launcherNote(resolved);
|
|
176
|
+
if (note)
|
|
177
|
+
console.log(note);
|
|
178
|
+
const res = tryClaudeCliWatchdog(opts.url, opts.key, scope, resolved.launcher);
|
|
171
179
|
if (res.ok) {
|
|
172
180
|
console.log(`✓ Registered Retasc with the liveness watchdog (stdio proxy, scope: ${scope}).`);
|
|
173
181
|
}
|
|
174
182
|
else {
|
|
175
|
-
const path = writeProjectMcpJson(mcpProxyEntry(opts.url, opts.key));
|
|
183
|
+
const path = writeProjectMcpJson(mcpProxyEntry(opts.url, opts.key, resolved.launcher));
|
|
176
184
|
console.log(`✓ Wrote watchdog MCP config to ${path}`);
|
|
177
185
|
console.log(` ${fallbackNote(res)}`);
|
|
178
186
|
}
|
|
179
187
|
console.log("\nWatchdog MCP config (Codex / OpenCode / any stdio MCP client):\n");
|
|
180
|
-
console.log(mcpProxyConfigBlock(opts.url, opts.key));
|
|
188
|
+
console.log(mcpProxyConfigBlock(opts.url, opts.key, resolved.launcher));
|
|
181
189
|
console.log("\nThe watchdog keeps your claims alive automatically — no per-claim heartbeats. If it ever isn't running you just fall back to the normal lease timeout (safe).");
|
|
182
190
|
return;
|
|
183
191
|
}
|
|
@@ -201,16 +209,28 @@ export function installMcp(opts) {
|
|
|
201
209
|
*/
|
|
202
210
|
export function installMarker(opts) {
|
|
203
211
|
const scope = opts.scope ?? "local";
|
|
204
|
-
|
|
212
|
+
// RTSC-493: resolve BEFORE writing anything. The marker names a command something else
|
|
213
|
+
// will spawn later, so it has to name one that has been proved to run on this machine.
|
|
214
|
+
const resolved = resolveLauncher({ version: VERSION, install: opts.install });
|
|
215
|
+
const note = launcherNote(resolved);
|
|
216
|
+
if (note)
|
|
217
|
+
console.log(note);
|
|
218
|
+
// `local` scope lands in this machine's own Claude config, where the absolute path is
|
|
219
|
+
// both correct and fastest. `project` scope, and the ./.mcp.json fallback below, are
|
|
220
|
+
// SHARED — a path under this user's home directory would leak their username into a
|
|
221
|
+
// committed file and break every teammate who clones it. See portableLauncher.
|
|
222
|
+
const shared = portableLauncher(resolved, VERSION);
|
|
223
|
+
const res = tryClaudeCliMarker(opts.workspaceId, scope, scope === "project" ? shared : resolved.launcher);
|
|
205
224
|
if (res.ok) {
|
|
206
225
|
console.log(`✓ Registered Retasc watchdog (secret-free marker, scope: ${scope}).`);
|
|
207
226
|
}
|
|
208
227
|
else {
|
|
209
|
-
|
|
228
|
+
// Always ./.mcp.json, whatever scope was asked for, so always the shared form.
|
|
229
|
+
const path = writeProjectMcpJson(mcpMarkerEntry(opts.workspaceId, shared));
|
|
210
230
|
console.log(`✓ Wrote secret-free watchdog marker to ${path}`);
|
|
211
231
|
console.log(` ${fallbackNote(res)}`);
|
|
212
232
|
}
|
|
213
233
|
console.log("\nMarker block (any stdio MCP client) — no secret, safe to commit:\n");
|
|
214
|
-
console.log(mcpMarkerConfigBlock(opts.workspaceId));
|
|
234
|
+
console.log(mcpMarkerConfigBlock(opts.workspaceId, shared));
|
|
215
235
|
console.log("\nThe key lives in your home keystore (~/.retasc/bindings.json), not in the repo.");
|
|
216
236
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
|
-
import {
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { dirname, join } from "node:path";
|
|
3
|
+
import { VERSION } from "./version.js";
|
|
6
4
|
import { loadConfig, patchConfig, saveConfig, configPath, isLoggedIn } from "./config.js";
|
|
7
5
|
import { installMcp, normalizeScope } from "./commands/mcp.js";
|
|
8
6
|
import { installGate } from "./commands/gate.js";
|
|
@@ -15,16 +13,11 @@ import { tidyAction, doneAction } from "./commands/tidy.js";
|
|
|
15
13
|
import { runProxy } from "./proxy.js";
|
|
16
14
|
import { deviceLogin } from "./auth.js";
|
|
17
15
|
import { api, formatError } from "./api.js";
|
|
18
|
-
// Single source of truth for the version: read package.json at runtime from the
|
|
19
|
-
// compiled file's location (dist/index.js -> ../package.json). A JSON import won't
|
|
20
|
-
// work here — tsconfig has rootDir "src", so importing ../package.json is outside
|
|
21
|
-
// rootDir and fails tsc.
|
|
22
|
-
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"));
|
|
23
16
|
const program = new Command();
|
|
24
17
|
program
|
|
25
18
|
.name("retasc")
|
|
26
19
|
.description("Retasc — sign in, create projects, mint agent API keys, and wire your agent to the MCP server.")
|
|
27
|
-
.version(
|
|
20
|
+
.version(VERSION);
|
|
28
21
|
function requireLogin() {
|
|
29
22
|
if (!isLoggedIn()) {
|
|
30
23
|
console.error("Not signed in. Run `retasc login` first.");
|
package/dist/lib/binding.js
CHANGED
|
@@ -26,13 +26,17 @@ function parseServerEntry(s, source) {
|
|
|
26
26
|
if (!s)
|
|
27
27
|
return undefined;
|
|
28
28
|
// Canonical (RTSC-92): secret-free marker → resolve the key from the keystore.
|
|
29
|
+
// RTSC-493: the spawned command, when this is one of the stdio forms.
|
|
30
|
+
const spawn = typeof s.command === "string"
|
|
31
|
+
? { command: s.command, args: Array.isArray(s.args) ? s.args.map(String) : [] }
|
|
32
|
+
: {};
|
|
29
33
|
const workspaceId = s.env?.RETASC_WORKSPACE;
|
|
30
34
|
if (workspaceId) {
|
|
31
35
|
const entry = getBinding(workspaceId);
|
|
32
36
|
if (entry)
|
|
33
|
-
return { key: entry.key, url: entry.url, watchdog: true, workspaceId, source };
|
|
37
|
+
return { key: entry.key, url: entry.url, watchdog: true, workspaceId, source, ...spawn };
|
|
34
38
|
// Marker present but no keystore entry (e.g. a cloned repo) → needs binding.
|
|
35
|
-
return { key: "", url: "", watchdog: true, workspaceId, markerOnly: true, source };
|
|
39
|
+
return { key: "", url: "", watchdog: true, workspaceId, markerOnly: true, source, ...spawn };
|
|
36
40
|
}
|
|
37
41
|
// Legacy inline-key forms (pre-RTSC-92): key in env or the Authorization header.
|
|
38
42
|
if (s.env?.RETASC_MCP_KEY) {
|
|
@@ -42,6 +46,7 @@ function parseServerEntry(s, source) {
|
|
|
42
46
|
watchdog: true,
|
|
43
47
|
legacy: true,
|
|
44
48
|
source,
|
|
49
|
+
...spawn,
|
|
45
50
|
};
|
|
46
51
|
}
|
|
47
52
|
const auth = s.headers?.Authorization ?? s.headers?.authorization;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const PKG = "@retasc/cli";
|
|
5
|
+
// Windows shims are .cmd files, which cannot be spawned without a shell. Everywhere
|
|
6
|
+
// else a shell is an injection surface for no benefit, so it stays off.
|
|
7
|
+
//
|
|
8
|
+
// WINDOWS IS NOT A SUPPORTED PLATFORM. The branches below are written to npm's
|
|
9
|
+
// documented layout and unit-tested for shape, but nothing here has ever been RUN on
|
|
10
|
+
// Windows. Treat them as best effort: they may well work, and a Windows bug report is
|
|
11
|
+
// not a regression against anything we claim. Kept rather than removed because throwing
|
|
12
|
+
// on win32 would break someone for whom it currently works, for no gain.
|
|
13
|
+
const WIN = process.platform === "win32";
|
|
14
|
+
/**
|
|
15
|
+
* Does `command […args] --version` actually run, and print something version-shaped?
|
|
16
|
+
*
|
|
17
|
+
* Executing is the whole point. A binary can exist on disk and still be unusable (wrong
|
|
18
|
+
* arch, broken symlink, a shim whose target was removed), and the version check also
|
|
19
|
+
* guards against spawning some UNRELATED program that happens to be called `retasc`.
|
|
20
|
+
*/
|
|
21
|
+
export function runsOk(command, args = []) {
|
|
22
|
+
let r;
|
|
23
|
+
try {
|
|
24
|
+
r = spawnSync(command, [...args, "--version"], {
|
|
25
|
+
encoding: "utf8",
|
|
26
|
+
shell: WIN,
|
|
27
|
+
timeout: 60_000,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
if (r.error || r.status !== 0)
|
|
34
|
+
return null;
|
|
35
|
+
const out = (r.stdout || "").trim();
|
|
36
|
+
// Our `--version` prints a bare semver (commander's .version()). Anything else is a
|
|
37
|
+
// different program, and pointing a marker at it would be worse than not writing one.
|
|
38
|
+
return /^\d+\.\d+\.\d+/.test(out) ? out : null;
|
|
39
|
+
}
|
|
40
|
+
/** npm's global prefix, or null when npm itself can't be run. */
|
|
41
|
+
export function npmGlobalPrefix() {
|
|
42
|
+
let r;
|
|
43
|
+
try {
|
|
44
|
+
r = spawnSync("npm", ["prefix", "-g"], { encoding: "utf8", shell: WIN, timeout: 60_000 });
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (r.error || r.status !== 0)
|
|
50
|
+
return null;
|
|
51
|
+
const out = (r.stdout || "").trim();
|
|
52
|
+
return out || null;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Where npm puts the `retasc` shim for a global install.
|
|
56
|
+
*
|
|
57
|
+
* `npm bin -g` was REMOVED in npm 9, so it can't be asked directly — the prefix has to
|
|
58
|
+
* be turned into a bin path by hand, and the layout differs by platform: POSIX nests a
|
|
59
|
+
* `bin/`, Windows puts the `.cmd` shim straight in the prefix root.
|
|
60
|
+
*/
|
|
61
|
+
export function globalBinCandidates(prefix, win = WIN) {
|
|
62
|
+
return win
|
|
63
|
+
? [join(prefix, "retasc.cmd"), join(prefix, "retasc")]
|
|
64
|
+
: [join(prefix, "bin", "retasc")];
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A launcher safe to write into a SHARED file.
|
|
68
|
+
*
|
|
69
|
+
* The absolute-path form is correct for the machine that resolved it and wrong everywhere
|
|
70
|
+
* else: `./.mcp.json` is the secret-free marker the product describes as safe to commit,
|
|
71
|
+
* so a path under someone's home directory both leaks their username into a committed
|
|
72
|
+
* file and hands every teammate a command that does not exist on their machine. The bare
|
|
73
|
+
* `retasc` it replaces was at least portable. So project-scoped writes get the pinned npx
|
|
74
|
+
* form, which is slower but true on any machine.
|
|
75
|
+
*/
|
|
76
|
+
export function portableLauncher(r, version) {
|
|
77
|
+
if (r.how !== "absolute")
|
|
78
|
+
return r.launcher;
|
|
79
|
+
return { command: "npx", args: ["-y", `${PKG}@${version}`] };
|
|
80
|
+
}
|
|
81
|
+
/** Install (or upgrade to) an exact version globally. Returns null on success. */
|
|
82
|
+
function installGlobal(version) {
|
|
83
|
+
// A cold global install takes seconds with no output of its own. Silence here reads as
|
|
84
|
+
// a hang in the middle of a bind, so say what is happening before it starts.
|
|
85
|
+
console.log(" Installing the retasc CLI so your agent can start it…");
|
|
86
|
+
let r;
|
|
87
|
+
try {
|
|
88
|
+
r = spawnSync("npm", ["install", "-g", `${PKG}@${version}`], {
|
|
89
|
+
encoding: "utf8",
|
|
90
|
+
shell: WIN,
|
|
91
|
+
// A cold global install pulls the tarball and its deps; 60s is not always enough.
|
|
92
|
+
timeout: 180_000,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
return e instanceof Error ? e.message : String(e);
|
|
97
|
+
}
|
|
98
|
+
if (r.error)
|
|
99
|
+
return r.error.message;
|
|
100
|
+
if (r.status !== 0) {
|
|
101
|
+
// EACCES is by far the common one (a prefix owned by root), and its first line is
|
|
102
|
+
// the useful part — the rest is npm's log-file boilerplate.
|
|
103
|
+
const msg = (r.stderr || r.stdout || "").trim().split("\n")[0];
|
|
104
|
+
return msg || `npm exited ${r.status}`;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Decide what the marker should name, and prove it before returning it.
|
|
110
|
+
*
|
|
111
|
+
* Order matters. An already-working `retasc` is left alone (no surprise installs for
|
|
112
|
+
* someone who already manages their own). Otherwise we try to make one, and if the
|
|
113
|
+
* install lands somewhere PATH can't see, the ABSOLUTE path to that binary is used
|
|
114
|
+
* instead — verified to run, full speed, and immune to whatever is wrong with PATH.
|
|
115
|
+
*
|
|
116
|
+
* npx is last on purpose. It works, but it costs a registry round-trip on every agent
|
|
117
|
+
* start (measured ~2.4s against ~0.06s for the binary), and unpinned it would resolve
|
|
118
|
+
* `latest` each time, so an agent's MCP server could change version mid-project without
|
|
119
|
+
* anyone asking. It is pinned here for exactly that reason.
|
|
120
|
+
*/
|
|
121
|
+
export function resolveLauncher(opts) {
|
|
122
|
+
// 1. Already usable? Leave it alone.
|
|
123
|
+
if (runsOk("retasc")) {
|
|
124
|
+
return { launcher: { command: "retasc", args: [] }, how: "on-path", verified: true };
|
|
125
|
+
}
|
|
126
|
+
const npxLauncher = { command: "npx", args: ["-y", `${PKG}@${opts.version}`] };
|
|
127
|
+
// Last resort, so it is probed too rather than assumed — the point of this whole
|
|
128
|
+
// module is that nothing gets written into a marker on faith.
|
|
129
|
+
const npxFallback = (reason) => ({
|
|
130
|
+
launcher: npxLauncher,
|
|
131
|
+
how: "npx",
|
|
132
|
+
reason,
|
|
133
|
+
verified: runsOk("npx", npxLauncher.args) !== null,
|
|
134
|
+
});
|
|
135
|
+
if (opts.install === false)
|
|
136
|
+
return npxFallback("install not attempted");
|
|
137
|
+
// 2. Try to make `retasc` real.
|
|
138
|
+
const failure = installGlobal(opts.version);
|
|
139
|
+
// 3. Prove it, by running it. An exit code of 0 is not evidence the command resolves:
|
|
140
|
+
// npm can install happily into a prefix whose bin directory PATH never searches.
|
|
141
|
+
if (!failure) {
|
|
142
|
+
if (runsOk("retasc")) {
|
|
143
|
+
return { launcher: { command: "retasc", args: [] }, how: "installed", verified: true };
|
|
144
|
+
}
|
|
145
|
+
// Installed, but PATH can't see it. Name the file directly — this is the case a
|
|
146
|
+
// shell one-liner can never recover from, because the shell fails before our code runs.
|
|
147
|
+
const prefix = npmGlobalPrefix();
|
|
148
|
+
for (const bin of prefix ? globalBinCandidates(prefix) : []) {
|
|
149
|
+
if (existsSync(bin) && runsOk(bin)) {
|
|
150
|
+
return { launcher: { command: bin, args: [] }, how: "absolute", binPath: bin, verified: true };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// 4. Nothing durable. Fall back, pinned, and let the caller say why.
|
|
155
|
+
return npxFallback(failure ?? "installed, but the binary could not be located or run");
|
|
156
|
+
}
|
|
157
|
+
/** What to tell the user about the outcome. One line, or none when nothing happened. */
|
|
158
|
+
export function launcherNote(r) {
|
|
159
|
+
switch (r.how) {
|
|
160
|
+
case "on-path":
|
|
161
|
+
return null; // nothing changed; saying so is noise
|
|
162
|
+
case "installed":
|
|
163
|
+
return "✓ Installed `retasc` so your agent can start it directly.";
|
|
164
|
+
case "absolute":
|
|
165
|
+
return (`✓ Installed retasc at ${r.binPath}\n` +
|
|
166
|
+
" Its folder isn't on your PATH, so your agent will use the full path above.\n" +
|
|
167
|
+
` To type \`retasc\` yourself, add this to your shell profile:\n` +
|
|
168
|
+
` export PATH="${r.binPath?.replace(/\/retasc$/, "")}:$PATH"`);
|
|
169
|
+
case "npx":
|
|
170
|
+
// Unverified is the one outcome where the workspace is written but NOT working.
|
|
171
|
+
// Say that outright: the whole defect this replaces was a setup that reported
|
|
172
|
+
// success and left the agent unable to start.
|
|
173
|
+
if (!r.verified) {
|
|
174
|
+
return (`! Couldn't install retasc${r.reason ? ` (${r.reason})` : ""}, and npx can't start it either.\n` +
|
|
175
|
+
" The marker was written, but your agent will NOT be able to start Retasc yet.\n" +
|
|
176
|
+
" Install the CLI, then run `retasc bind` again: npm install -g @retasc/cli");
|
|
177
|
+
}
|
|
178
|
+
return (`! Couldn't install retasc globally${r.reason ? ` (${r.reason})` : ""}.\n` +
|
|
179
|
+
" Your agent will start it through npx instead, which works but is slower\n" +
|
|
180
|
+
" and needs the network. To fix it later, run: npm install -g @retasc/cli");
|
|
181
|
+
}
|
|
182
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
// Single source of truth for the version, read from package.json at runtime relative to
|
|
5
|
+
// the COMPILED file (dist/version.js -> ../package.json). A JSON import won't work:
|
|
6
|
+
// tsconfig has rootDir "src", so importing ../package.json is outside it.
|
|
7
|
+
//
|
|
8
|
+
// It lives in its own module because two places now need it — `--version`, and the
|
|
9
|
+
// pinned npx fallback the MCP marker may name (RTSC-493). A marker pinned to a version
|
|
10
|
+
// this build doesn't match would spawn a different CLI than the one that wrote it.
|
|
11
|
+
export const VERSION = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
|
package/package.json
CHANGED