@retasc/cli 1.7.2 → 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.
@@ -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";
@@ -83,6 +84,22 @@ export function strandRecoveryHint(args) {
83
84
  ` retasc bind --org-id ${orgId}`);
84
85
  }
85
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
+ }
86
103
  const cfg = loadConfig();
87
104
  const cwd = process.cwd();
88
105
  // --- loud on re-bind -------------------------------------------------------
@@ -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. */
@@ -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
@@ -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: "retasc",
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: "retasc",
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
- "--", "retasc", "mcp-proxy",
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
- "--", "retasc", "mcp-proxy",
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
- const res = tryClaudeCliWatchdog(opts.url, opts.key, scope);
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
- const res = tryClaudeCliMarker(opts.workspaceId, scope);
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
- const path = writeProjectMcpJson(mcpMarkerEntry(opts.workspaceId));
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 { readFileSync } from "node:fs";
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(pkg.version);
20
+ .version(VERSION);
28
21
  function requireLogin() {
29
22
  if (!isLoggedIn()) {
30
23
  console.error("Not signed in. Run `retasc login` first.");
@@ -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
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.7.2",
3
+ "version": "1.8.0",
4
4
  "description": "Retasc CLI — sign in with GitHub, create projects, mint agent API keys, and wire your agent to the Retasc MCP server in one command.",
5
5
  "type": "module",
6
6
  "bin": {