@retasc/cli 1.37.0 → 1.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,54 @@ 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.38.1 (2026-08-28)
10
+
11
+ - **RTSC-780 follow-up** — `retasc init` now sets a folder up the same way every other
12
+ door does. 1.38.0 taught `bind`, `join` and `bind --setup` to wire every harness on the
13
+ machine, but `init` kept its own copy of that work: it minted a key and wrote a
14
+ key-bearing entry for Claude Code and nothing else. So the one command named onboarding
15
+ was the one that still left Codex with no Retasc tools, which is the exact failure
16
+ 1.38.0 exists to end.
17
+ Fixed by deleting the duplicate rather than teaching it the same trick. Everything after
18
+ the org is now `bind`'s job, which also means `init` writes the keystore binding an
19
+ `auto` marker resolves against, guards a folder that is already bound instead of
20
+ overwriting it, and prints the same receipt.
21
+ `--scope` and `--no-watchdog` are gone from `init`: both described the shape of an entry
22
+ it no longer writes. `-y/--yes` and `--no-install` are there instead, matching `bind`.
23
+
24
+ ## 1.38.0 (2026-08-28)
25
+
26
+ - **RTSC-780** — `retasc setup` wires Retasc into every MCP harness on the machine, once,
27
+ and `retasc bind` now runs it for you. Until this release the CLI could wire exactly one
28
+ harness: it spawned `claude mcp add`, and if that worked it stopped. Codex, Grok and
29
+ anything else got nothing, which is why an agent bound to the same project as two
30
+ working Claude Code sessions could sit there unable to claim a single issue.
31
+ The entry we write names no project and carries no key. The watchdog proxy works out
32
+ which project it is in from the directory it was spawned in, so one line in a global
33
+ config is correct in every folder, and the folder still decides the org exactly as
34
+ before. Adding a harness is one entry in the registry; Claude Code, Codex and Grok ship
35
+ today, each verified against a real installation rather than a config format from
36
+ memory.
37
+ A folder you have not bound yet is now an ordinary state rather than a broken install,
38
+ so it gets a real answer: the tool call comes back naming the folder and telling you to
39
+ run `retasc bind` in it. Previously a keyless call surfaced as an authentication or
40
+ network failure while the CLI's own login was global and still fine, which reads as
41
+ "the Retasc server is down" and sends you to check a server that is serving everyone
42
+ else. The server cannot name your folder; the proxy runs in it, so it can.
43
+ Which folder counts is bounded by the repository: a subdirectory resolves to its repo,
44
+ and a repo checked out inside a bound directory resolves to nothing. Binding a
45
+ directory must not quietly bind every unrelated project underneath it.
46
+ Existing markers are untouched: one naming a workspace id keeps resolving through that
47
+ id, and keeps priority over the folder.
48
+
49
+ - **RTSC-781** — `--runtime` now says what it does. It sets the agent's label in the
50
+ Dash, and it never chose where config was written, but it is offered on the commands
51
+ that install MCP config and documented with a list of harness names, so
52
+ `key mint --runtime codex --install` named Codex, wired Claude Code, and said nothing.
53
+ The help text says label, and the commands that mint a key and write a key-bearing
54
+ entry now say plainly that they write for this folder only, that a key never belongs
55
+ in a machine-wide config, and that `retasc setup` is what wires the harness you named.
56
+
9
57
  ## 1.37.0 (2026-08-26)
10
58
 
11
59
  - **RTSC-749** — `retasc triage` reads and approves work filed from outside your org. Work
@@ -3,6 +3,7 @@ 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 { runSetup } from "./setup.js";
6
7
  import { readLocalBinding, resolveBinding, bindingRefused } from "../lib/binding.js";
7
8
  import { getBinding, setBinding, removeBinding, newWorkspaceId } from "../lib/keystore.js";
8
9
  import { resolveLauncher, launcherNote, onDurablePath, selfCommand, versionStamp } from "../lib/launcher.js";
@@ -450,6 +451,11 @@ export async function completeWorkspaceSetup(args) {
450
451
  // Per-folder only (local scope), always watchdog. The marker carries only the
451
452
  // workspace id — no secret — so ./.mcp.json is safe to commit.
452
453
  const marker = installMarker({ workspaceId, scope: "local", launcher, quiet: true });
454
+ // RTSC-780: and the once-per-machine half. Idempotent by construction (each adapter
455
+ // replaces its own entry), so running it on every bind is how a harness installed
456
+ // AFTER the first bind gets picked up without anyone having to know that `retasc
457
+ // setup` exists. Quiet: the receipt below names what it wired, in one row.
458
+ const wired = runSetup({ launcher, quiet: true });
453
459
  // Confirm the binding the same way the agent will see it, and print it as the receipt
454
460
  // (RTSC-673). Resolved rather than echoed from what we just sent: this is the last
455
461
  // chance to notice a folder bound to something other than what was asked for, and a
@@ -470,6 +476,15 @@ export async function completeWorkspaceSetup(args) {
470
476
  { label: "Project", value: bound?.name ? `${pfx} ${DOT} ${bound.name}` : pfx },
471
477
  { label: "Agent key", value: `${minted.key.slice(0, 14)}…`, note: "never leaves ~/.retasc" },
472
478
  { label: "MCP wired", value: marker.where, note: "secret-free, safe to commit" },
479
+ ...(wired.wired.length
480
+ ? [
481
+ {
482
+ label: "Harnesses",
483
+ value: wired.wired.map((w) => w.label).join(", "),
484
+ note: "every folder",
485
+ },
486
+ ]
487
+ : []),
473
488
  ], `Agents launched here can only ever read or write ${pfx}.`));
474
489
  // Only when we could write it nowhere. See printMarkerBlock.
475
490
  if (!marker.wroteFile && marker.where === "")
@@ -568,6 +583,12 @@ export async function setupFromToken(token, opts, deps = {}) {
568
583
  createdAt: Date.now(),
569
584
  });
570
585
  (deps.marker ?? installMarker)({ workspaceId, scope: "local", launcher });
586
+ // RTSC-780 — same once-per-machine wiring on the agent door. This path has no TTY and
587
+ // no receipt card, so it prints its own line rather than staying silent.
588
+ const wired = (deps.setup ?? runSetup)({ launcher, quiet: true });
589
+ if (wired.wired.length) {
590
+ console.log(`✓ Retasc wired into ${wired.wired.map((w) => w.label).join(", ")}.`);
591
+ }
571
592
  // RTSC-532 — name the PATH, not "this folder". A binding is folder → project, and
572
593
  // until this line said which folder, a wrong one was undetectable: the agent still
573
594
  // calls in, so the Dash shows success while the folder she actually works in has no
@@ -161,6 +161,34 @@ export function writeProjectMcpJson(entry) {
161
161
  }
162
162
  return path;
163
163
  }
164
+ /**
165
+ * Say plainly that `--runtime` labels an agent and does not choose a harness (RTSC-781).
166
+ *
167
+ * The flag reads as a promise. It is offered on the same commands that install MCP
168
+ * config, it is documented with a list of harness names, and before RTSC-780 the only
169
+ * thing any of those commands could write was Claude Code. So
170
+ * `key mint --runtime codex --install` named Codex, wired Claude Code, and said nothing
171
+ * — and the resulting failure surfaced as "signed in, but the server is unreachable",
172
+ * which sends you to check a server that is fine and serving other clients.
173
+ *
174
+ * The commands that call this mint a key and write a KEY-BEARING entry, which is
175
+ * deliberately per-folder and Claude-Code-only: a key must never be written into a
176
+ * machine-global config (RTSC-91). So the honest answer is not to wire the named harness
177
+ * here, it is to say that this command cannot and name the one that can.
178
+ *
179
+ * A note rather than a refusal. By the time this prints, the key is minted and the
180
+ * Claude Code wiring is real work someone asked for; exiting would throw that away to
181
+ * make a point. Silence was the bug, not the wiring.
182
+ */
183
+ export function noteRuntimeIsALabel(runtime) {
184
+ const r = (runtime ?? "").trim();
185
+ if (!r || r === "claude-code")
186
+ return;
187
+ console.log(`Note: --runtime ${r} sets this agent's LABEL in the Dash. It does not choose where\n` +
188
+ `config is written. This command writes a key-bearing entry for THIS folder, and a\n` +
189
+ `key only ever belongs in Claude Code's own config, never in a machine-wide one.\n` +
190
+ `To wire ${r}, run \`retasc setup\` — secret-free, every harness, every folder.\n`);
191
+ }
164
192
  /**
165
193
  * Wire the Retasc MCP server into the user's agent. Prefers the `claude` CLI;
166
194
  * falls back to writing ./.mcp.json. Always prints the manual block so the user
@@ -0,0 +1,86 @@
1
+ // RTSC-780: `retasc setup` — wire every MCP harness on this machine, once.
2
+ //
3
+ // The shape this exists to deliver:
4
+ // once per machine `retasc setup`, and every harness that is installed gets an entry
5
+ // once per project `retasc bind`, and every one of them sees it
6
+ //
7
+ // Nothing harness-specific after that, ever again. The entry written here names no
8
+ // workspace (keystore.AUTO_WORKSPACE), so it is correct in every folder: the proxy
9
+ // resolves the directory it was spawned in against the home keystore at startup. The
10
+ // folder still decides the org, which is the whole routing model (RTSC-91) and the thing
11
+ // a machine-global entry would otherwise break.
12
+ //
13
+ // NOT an npm postinstall hook. Most people reach this CLI through `npx`, which installs
14
+ // nothing (the reason `resolveLauncher` exists, RTSC-493), so a postinstall would never
15
+ // run for them; and writing into somebody's global agent configs from a package install
16
+ // is a thing to do in front of a human, not behind one.
17
+ import { AUTO_WORKSPACE } from "../lib/keystore.js";
18
+ import { HARNESSES, detectHarnesses, tildePath } from "../lib/harness.js";
19
+ import { resolveLauncher, launcherNote } from "../lib/launcher.js";
20
+ import { VERSION } from "../version.js";
21
+ import { card } from "../lib/card.js";
22
+ /** The one entry every harness gets: the watchdog proxy, resolving its own folder. */
23
+ export function autoMarkerEntry(launcher) {
24
+ return {
25
+ command: launcher.command,
26
+ args: [...launcher.args, "mcp-proxy"],
27
+ env: { RETASC_WORKSPACE: AUTO_WORKSPACE },
28
+ };
29
+ }
30
+ /**
31
+ * Wire the auto marker into every detected harness.
32
+ *
33
+ * `quiet` is for the call at the end of `bind`, where the bind receipt does the talking.
34
+ */
35
+ export function runSetup(opts) {
36
+ const all = opts.harnesses;
37
+ const resolved = opts.launcher ?? resolveLauncher({ version: VERSION, install: opts.install });
38
+ if (!opts.launcher) {
39
+ const note = launcherNote(resolved);
40
+ if (note)
41
+ console.log(note);
42
+ }
43
+ const entry = autoMarkerEntry(resolved.launcher);
44
+ const known = all ?? HARNESSES;
45
+ const present = detectHarnesses(known);
46
+ const presentIds = new Set(present.map((h) => h.id));
47
+ const result = {
48
+ wired: [],
49
+ failed: [],
50
+ absent: known.filter((h) => !presentIds.has(h.id)).map((h) => h.label),
51
+ };
52
+ for (const h of present) {
53
+ const outcome = h.install(entry);
54
+ if (outcome.ok) {
55
+ result.wired.push({ label: h.label, where: tildePath(outcome.path), replaced: outcome.replaced });
56
+ }
57
+ else {
58
+ result.failed.push({ label: h.label, reason: outcome.reason });
59
+ }
60
+ }
61
+ if (!opts.quiet)
62
+ printSetup(result);
63
+ return result;
64
+ }
65
+ /** The receipt. One row per harness, so "what did this just touch" needs no guessing. */
66
+ export function printSetup(r) {
67
+ if (!r.wired.length && !r.failed.length) {
68
+ console.log("No MCP harness found on this machine.");
69
+ console.log("Install Claude Code, Codex or Grok and run `retasc setup` again, or bind a folder\n" +
70
+ "with `retasc bind` and paste the config block it prints into your own client.");
71
+ return;
72
+ }
73
+ console.log("\n" +
74
+ card(`✓ Retasc wired into ${r.wired.length} ${r.wired.length === 1 ? "harness" : "harnesses"}`, r.wired.map((w) => ({
75
+ label: w.label,
76
+ value: w.where,
77
+ note: w.replaced ? "updated" : "new",
78
+ }))));
79
+ for (const f of r.failed)
80
+ console.log(` ! ${f.label}: ${f.reason}`);
81
+ if (r.absent.length)
82
+ console.log(` Not on this machine: ${r.absent.join(", ")}.`);
83
+ console.log("\nThese entries carry no key and name no project, so they are correct in every folder.\n" +
84
+ "Run `retasc bind` in a project to say which org it belongs to, then start your agent\n" +
85
+ "there (or restart it if it's already open).");
86
+ }
package/dist/index.js CHANGED
@@ -3,10 +3,11 @@ import { Command } from "commander";
3
3
  import { VERSION } from "./version.js";
4
4
  import { selfCommand, versionStamp } from "./lib/launcher.js";
5
5
  import { loadConfig, patchConfig, saveConfig, configPath, isLoggedIn } from "./config.js";
6
- import { installMcp, normalizeScope } from "./commands/mcp.js";
6
+ import { installMcp, noteRuntimeIsALabel, normalizeScope } from "./commands/mcp.js";
7
+ import { runSetup } from "./commands/setup.js";
7
8
  import { installGate, resolveGatePrefix } from "./commands/gate.js";
8
9
  import { claimAction, releaseAction } from "./commands/claim.js";
9
- import { bindAction, setupFromToken } from "./commands/bind.js";
10
+ import { bindAction, completeWorkspaceSetup, rebindGuard, setupFromToken } from "./commands/bind.js";
10
11
  import { unbindAction } from "./commands/unbind.js";
11
12
  import { joinAction } from "./commands/join.js";
12
13
  import { chooseInviteOrg, chooseInviteProjects } from "./commands/invite.js";
@@ -144,9 +145,9 @@ program
144
145
  .requiredOption("--project <name>", "Project name")
145
146
  .requiredOption("--prefix <PREFIX>", "Project prefix, e.g. XEN")
146
147
  .option("--agent <name>", "Agent member name (default: auto, \"{you}'s {runtime}\")")
147
- .option("--runtime <runtime>", "Agent runtime: claude-code | codex | opencode | …", "claude-code")
148
- .option("--scope <scope>", "MCP install scope: local | project (per-folder only)", "local")
149
- .option("--no-watchdog", "Wire a plain direct connection instead of the liveness watchdog")
148
+ .option("--runtime <runtime>", "Label for this agent in the Dash (claude-code | codex | grok | …). Does NOT choose where MCP config is written — `retasc setup` wires every harness on the machine.", "claude-code")
149
+ .option("-y, --yes", "Don't prompt to confirm replacing an existing binding")
150
+ .option("--no-install", "Don't install `retasc` on this machine; wire the pinned npx launcher instead")
150
151
  .action(async (opts) => {
151
152
  requireLogin();
152
153
  try {
@@ -158,21 +159,43 @@ program
158
159
  orgId = org.orgId;
159
160
  console.log(`✓ Created org (${org.slug}).`);
160
161
  }
161
- const project = (await api.createProject({ orgId: orgId, name: opts.project, prefix: opts.prefix }));
162
- console.log(`✓ Created project ${project.prefix}.`);
163
- const minted = (await api.mintKey({
162
+ // RTSC-780 follow-up: everything after the org is `bind`'s job now.
163
+ //
164
+ // This command used to mint a key and call `installMcp` itself, which wrote a
165
+ // key-bearing entry for Claude Code and nothing else. When RTSC-780 taught the
166
+ // other three doors (`bind`, `join`, `bind --setup`) to wire every harness on the
167
+ // machine, this one was left behind — so the one command named "onboarding" was
168
+ // the one that still left Codex with no Retasc tools, the exact failure RTSC-780
169
+ // exists to end.
170
+ //
171
+ // Fixed by DELETING the duplicate rather than teaching it the same trick.
172
+ // `completeWorkspaceSetup` already creates the project, mints, writes the keystore
173
+ // binding (which is what an `auto` marker resolves against — without it a marker
174
+ // has nothing to find), installs the folder marker, runs setup and prints the
175
+ // receipt. Two implementations of "set this folder up" is how they drift apart,
176
+ // and this is the second time they did.
177
+ //
178
+ // `--scope` and `--no-watchdog` are gone with it: both described the shape of an
179
+ // entry that is no longer written here. The marker is per-folder and secret-free,
180
+ // and the watchdog is not optional in it.
181
+ const cfg = patchConfig({ defaultProjectPrefix: opts.prefix });
182
+ const guard = await rebindGuard({ cwd: process.cwd(), mcpUrl: cfg.mcpUrl, yes: opts.yes });
183
+ if (!guard.proceed)
184
+ return;
185
+ await completeWorkspaceSetup({
164
186
  orgId: orgId,
165
- projectId: project.projectId,
166
- agentName: opts.agent, // undefined → backend auto-names "{you}'s {runtime}"
167
- runtime: opts.runtime,
168
- keyName: `${project.prefix} key`,
169
- }));
170
- console.log(`✓ Minted API key (${minted.key.slice(0, 14)}…).`);
171
- // RTSC-91 (§13, override #2): never persist a global default org — binding
172
- // is a per-folder act. (The prefix is kept only as a gate convenience.)
173
- const cfg = patchConfig({ defaultProjectPrefix: project.prefix });
174
- console.log("");
175
- installMcp({ url: cfg.mcpUrl, key: minted.key, scope: normalizeScope(opts.scope), watchdog: opts.watchdog });
187
+ orgLabel: opts.org,
188
+ opts: {
189
+ project: opts.project,
190
+ prefix: opts.prefix,
191
+ agent: opts.agent,
192
+ runtime: opts.runtime,
193
+ install: opts.install,
194
+ yes: opts.yes,
195
+ },
196
+ existing: guard.existing,
197
+ canCreateProject: true,
198
+ });
176
199
  }
177
200
  catch (e) {
178
201
  fail(e);
@@ -190,7 +213,7 @@ program
190
213
  .option("--project <name>", "Create a new project with this name (with --prefix)")
191
214
  .option("--prefix <PREFIX>", "Prefix for a new project, e.g. ACME")
192
215
  .option("--agent <name>", "Agent member name (default: auto)")
193
- .option("--runtime <runtime>", "Agent runtime", "claude-code")
216
+ .option("--runtime <runtime>", "Label for this agent in the Dash (claude-code | codex | grok | …). Does NOT choose where MCP config is written — `retasc setup` wires every harness on the machine.", "claude-code")
194
217
  .option("-y, --yes", "Don't prompt to confirm replacing an existing binding")
195
218
  // RTSC-523 — decline the global install without a TTY. `bind`/`join` ask when a human
196
219
  // is present; this is how a scripted run, or a developer whose own agent runs setup,
@@ -338,7 +361,7 @@ key
338
361
  .requiredOption("--org-id <id>")
339
362
  .requiredOption("--project-id <id>")
340
363
  .option("--agent <name>", "Agent member name (default: auto, \"{you}'s {runtime}\")")
341
- .option("--runtime <runtime>", "Agent runtime: claude-code | codex | opencode | …", "claude-code")
364
+ .option("--runtime <runtime>", "Label for this agent in the Dash (claude-code | codex | grok | …). Does NOT choose where MCP config is written — `retasc setup` wires every harness on the machine.", "claude-code")
342
365
  .option("--name <label>", "Key label")
343
366
  .option("--install", "Also wire the key into your agent via MCP")
344
367
  .option("--scope <scope>", "MCP install scope if --install: local | project", "local")
@@ -357,6 +380,7 @@ key
357
380
  if (opts.install) {
358
381
  const cfg = loadConfig();
359
382
  console.log("");
383
+ noteRuntimeIsALabel(opts.runtime);
360
384
  installMcp({ url: cfg.mcpUrl, key: res.key, scope: normalizeScope(opts.scope), watchdog: true });
361
385
  }
362
386
  }
@@ -493,7 +517,7 @@ program
493
517
  .option("--no-bind", "Redeem only — don't set this folder up")
494
518
  .option("--project-id <id>", "Which project to bind to (skips the picker)")
495
519
  .option("--agent <name>", "Agent member name (default: auto)")
496
- .option("--runtime <runtime>", "Agent runtime", "claude-code")
520
+ .option("--runtime <runtime>", "Label for this agent in the Dash (claude-code | codex | grok | …). Does NOT choose where MCP config is written — `retasc setup` wires every harness on the machine.", "claude-code")
497
521
  // RTSC-477 — name the way back. `--yes` skips the identity question and must never answer
498
522
  // it, so the flag that causes the gap is the right place to say how to close it.
499
523
  .option("-y, --yes", "Don't prompt to replace an existing binding, and skip the identity question (ask it later with `retasc identity`)")
@@ -541,6 +565,17 @@ program
541
565
  await importAction({ orgId: opts.orgId, source: opts.source, yes: opts.yes }).catch(fail);
542
566
  });
543
567
  // --- mcp wiring ------------------------------------------------------------
568
+ // RTSC-780 — the once-per-machine half of setup. `bind` runs this itself the first
569
+ // time, so most people never type it; it exists on its own for the second harness
570
+ // installed after a bind, and for re-running after an upgrade moved the launcher.
571
+ program
572
+ .command("setup")
573
+ .description("Detect the MCP harnesses on this machine and wire Retasc into each, once.")
574
+ .option("--no-install", "Don't install `retasc` on this machine; wire the pinned npx launcher instead")
575
+ .allowExcessArguments(false)
576
+ .action((opts) => {
577
+ runSetup({ install: opts.install });
578
+ });
544
579
  const mcp = program.command("mcp").description("Wire the Retasc MCP server into your agent.");
545
580
  mcp
546
581
  .command("install")
@@ -0,0 +1,236 @@
1
+ // RTSC-780: the harness registry. One entry per MCP client we know how to wire.
2
+ //
3
+ // Before this file the CLI could wire exactly one harness: `installMcp`/`installMarker`
4
+ // spawned `claude mcp add` and, if that failed, wrote ./.mcp.json. Everything else got
5
+ // nothing, which is why Codex had never worked in any project on any machine. The only
6
+ // non-Claude binding that ever existed here was hand-written into ~/.grok/config.toml by
7
+ // an agent, machine-global with an inline API key — the exact cross-org leak `normalizeScope`
8
+ // refuses to allow for Claude Code.
9
+ //
10
+ // The entry we write is the `auto` marker (keystore.AUTO_WORKSPACE): no key, no workspace
11
+ // id, nothing folder-specific. That is what makes ONE write per harness enough for every
12
+ // project, and it is only safe because the harnesses verified below spawn stdio servers
13
+ // with cwd set to the project directory, so the proxy can resolve the folder itself.
14
+ //
15
+ // Adding a harness is one entry in HARNESSES. Deliberately NOT populated from theory:
16
+ // each entry here was checked against a real installation. Cursor, Windsurf, Gemini CLI,
17
+ // OpenCode and VS Code all have a known config shape and belong here, but writing an
18
+ // unverified serializer into somebody's global agent config is not a thing to ship on a
19
+ // guess — they go in as each is confirmed.
20
+ import { spawnSync } from "node:child_process";
21
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { dirname, join } from "node:path";
24
+ /**
25
+ * The home directory harness configs are resolved under.
26
+ *
27
+ * `RETASC_HOME` overrides it, the same escape hatch `keystore.ts` gives with
28
+ * `RETASC_DIR` and for the same reason: without one, anything that exercises this
29
+ * module writes into the developer's REAL global agent configs. That is not
30
+ * hypothetical — it happened while building this, and the tell was `retasc setup`
31
+ * reporting "replaced" on a Codex config that had never held a retasc entry.
32
+ * A test that can silently reconfigure the machine it runs on is a worse bug than
33
+ * whatever it was testing.
34
+ */
35
+ function home() {
36
+ return process.env.RETASC_HOME || homedir();
37
+ }
38
+ /** `/Users/me/.codex/config.toml` → `~/.codex/config.toml`, for receipts. The card's
39
+ * value column is 20 columns wide, and an absolute path ellipses away to nothing
40
+ * useful exactly where the reader is checking WHICH file we touched. */
41
+ export function tildePath(p) {
42
+ const h = home();
43
+ return p.startsWith(h + "/") ? `~${p.slice(h.length)}` : p;
44
+ }
45
+ export const SERVER_NAME = "retasc";
46
+ // --- shared helpers ---------------------------------------------------------
47
+ /**
48
+ * True when `bin` resolves on PATH.
49
+ *
50
+ * This DOES run a shell (`command -v` is a shell builtin, not an executable), so the
51
+ * argument is concatenated into a command string and a name carrying `;` or a space
52
+ * would be a command injection. Every caller passes a literal from HARNESSES below,
53
+ * but "the only caller today is safe" is how that stops being true, so the shape of
54
+ * the name is checked here rather than trusted.
55
+ */
56
+ export function onPath(bin) {
57
+ if (!/^[a-zA-Z0-9._-]+$/.test(bin))
58
+ return false;
59
+ const r = spawnSync("command", ["-v", bin], { encoding: "utf8", shell: "/bin/sh" });
60
+ return r.status === 0 && Boolean((r.stdout || "").trim());
61
+ }
62
+ function readIfExists(path) {
63
+ try {
64
+ return existsSync(path) ? readFileSync(path, "utf8") : "";
65
+ }
66
+ catch {
67
+ return "";
68
+ }
69
+ }
70
+ /** Write, creating the parent directory. Config files can hold nothing secret by
71
+ * design (the marker is secret-free), so no 0600 dance is needed here — but we
72
+ * also never widen an existing file's mode, so an existing 0600 config stays 0600. */
73
+ function writeConfig(path, text) {
74
+ mkdirSync(dirname(path), { recursive: true });
75
+ writeFileSync(path, text, "utf8");
76
+ }
77
+ // --- TOML (Codex, Grok) -----------------------------------------------------
78
+ /**
79
+ * Splice one table (and its sub-tables) into a TOML document, in place.
80
+ *
81
+ * Deliberately textual rather than parse-and-reserialize. These are the user's own
82
+ * global config files: they hold comments, hand-chosen ordering, and settings that
83
+ * have nothing to do with us. A round-trip through a TOML parser would silently
84
+ * rewrite all of that, and the CLI ships with two dependencies (commander, convex)
85
+ * precisely so it stays cheap to install — adding a TOML library to reformat
86
+ * somebody's config is the wrong trade twice over.
87
+ *
88
+ * Replaces every existing `[prefix]` / `[prefix.*]` table, then appends `block`.
89
+ * A table ends at the next line that starts a different table at column 0.
90
+ */
91
+ export function spliceTomlTable(text, prefix, block) {
92
+ const lines = text.split("\n");
93
+ const out = [];
94
+ let skipping = false;
95
+ const opens = (line) => {
96
+ const m = line.match(/^\s*\[\[?([^\]]+)\]\]?\s*$/);
97
+ return m ? m[1].trim() : null;
98
+ };
99
+ for (const line of lines) {
100
+ const table = opens(line);
101
+ if (table !== null) {
102
+ // A new table header always ends whatever we were skipping.
103
+ skipping = table === prefix || table.startsWith(`${prefix}.`);
104
+ }
105
+ if (!skipping)
106
+ out.push(line);
107
+ }
108
+ // Collapse the blank-line run the removal may have left at the end, so repeated
109
+ // installs don't grow the file by one newline every time.
110
+ while (out.length && out[out.length - 1].trim() === "")
111
+ out.pop();
112
+ const body = out.join("\n");
113
+ return `${body}\n\n${block.trim()}\n`;
114
+ }
115
+ /** The `[mcp_servers.retasc]` block, in the shape Codex and Grok both accept. */
116
+ export function tomlBlock(entry) {
117
+ const arr = entry.args.map((a) => JSON.stringify(a)).join(", ");
118
+ const env = Object.entries(entry.env)
119
+ .map(([k, v]) => `${k} = ${JSON.stringify(v)}`)
120
+ .join("\n");
121
+ return [
122
+ `[mcp_servers.${SERVER_NAME}]`,
123
+ `command = ${JSON.stringify(entry.command)}`,
124
+ `args = [${arr}]`,
125
+ `enabled = true`,
126
+ ``,
127
+ `[mcp_servers.${SERVER_NAME}.env]`,
128
+ env,
129
+ ].join("\n");
130
+ }
131
+ function tomlHarness(id, label, path, bin) {
132
+ return {
133
+ id,
134
+ label,
135
+ configPath: path,
136
+ detect: () => existsSync(path()) || onPath(bin),
137
+ install(entry) {
138
+ const p = path();
139
+ const before = readIfExists(p);
140
+ try {
141
+ writeConfig(p, spliceTomlTable(before, `mcp_servers.${SERVER_NAME}`, tomlBlock(entry)));
142
+ }
143
+ catch (e) {
144
+ return { ok: false, reason: String(e?.message ?? e) };
145
+ }
146
+ return { ok: true, path: p, replaced: /^\s*\[\[?mcp_servers\.retasc\b/m.test(before) };
147
+ },
148
+ };
149
+ }
150
+ // --- JSON (./.mcp.json in the folder) ---------------------------------------
151
+ /** Merge the retasc entry into a `{ mcpServers: {…} }` document, leaving every
152
+ * other server and unknown key byte-for-byte where it was. */
153
+ export function mergeMcpServers(before, entry) {
154
+ let doc = {};
155
+ if (before.trim()) {
156
+ try {
157
+ doc = JSON.parse(before);
158
+ }
159
+ catch {
160
+ doc = {};
161
+ }
162
+ }
163
+ doc.mcpServers = doc.mcpServers ?? {};
164
+ doc.mcpServers[SERVER_NAME] = entry;
165
+ return JSON.stringify(doc, null, 2) + "\n";
166
+ }
167
+ // --- Claude Code ------------------------------------------------------------
168
+ /**
169
+ * Claude Code is the one harness with a real CLI for this, and `claude mcp add` is
170
+ * the ONLY sanctioned way to edit `~/.claude.json`: a running Claude Code rewrites
171
+ * that file constantly, so a read-modify-write from here would race it and could
172
+ * drop unrelated servers.
173
+ *
174
+ * Scope `user` here, which `normalizeScope` refuses everywhere else (RTSC-91). The
175
+ * refusal is about a global entry that NAMES an org — an id or a key in a
176
+ * machine-wide file routes every folder to one project. The `auto` marker names
177
+ * nothing, so the objection does not apply to it, and the whole point of RTSC-780
178
+ * is that this entry is written once and serves every folder.
179
+ */
180
+ const claudeCode = {
181
+ id: "claude-code",
182
+ label: "Claude Code",
183
+ configPath: () => join(home(), ".claude.json"),
184
+ // A test home means "do not touch this machine's real config". `claude mcp add`
185
+ // writes ~/.claude.json wherever HOME points, so the override has to gate DETECTION,
186
+ // not just the path: an undetected harness is never installed into.
187
+ detect: () => !process.env.RETASC_HOME && onPath("claude"),
188
+ install(entry) {
189
+ const args = [
190
+ "mcp", "add",
191
+ SERVER_NAME,
192
+ "--transport", "stdio",
193
+ "--scope", "user",
194
+ ...Object.entries(entry.env).flatMap(([k, v]) => ["--env", `${k}=${v}`]),
195
+ "--", entry.command, ...entry.args,
196
+ ];
197
+ // An existing user-scope entry makes `add` fail rather than replace, so clear it
198
+ // first. Best effort: a missing entry exits non-zero and that is fine.
199
+ //
200
+ // This does open a remove-then-fail window: if `add` fails after a successful
201
+ // remove, the user is left with no retasc entry rather than the one they had. The
202
+ // blast radius is bounded to OUR entry (the name is fixed, and `-s user` scopes it),
203
+ // the outcome is reported rather than swallowed, and `retasc setup` re-runs cleanly.
204
+ // Restoring the previous entry would mean reading ~/.claude.json ourselves, which is
205
+ // the one thing that file must never have done to it: a running Claude Code rewrites
206
+ // it constantly and the read-modify-write would race it.
207
+ const had = spawnSync("claude", ["mcp", "remove", SERVER_NAME, "-s", "user"], {
208
+ encoding: "utf8",
209
+ });
210
+ const r = spawnSync("claude", args, { encoding: "utf8" });
211
+ if (r.error)
212
+ return { ok: false, reason: r.error.message };
213
+ if (r.status !== 0) {
214
+ const msg = (r.stderr || r.stdout || "").trim().split("\n")[0] || `exited ${r.status}`;
215
+ return { ok: false, reason: msg };
216
+ }
217
+ return { ok: true, path: "Claude Code (user scope)", replaced: had.status === 0 };
218
+ },
219
+ };
220
+ // --- the registry -----------------------------------------------------------
221
+ export const HARNESSES = [
222
+ claudeCode,
223
+ tomlHarness("codex", "Codex", () => join(home(), ".codex", "config.toml"), "codex"),
224
+ tomlHarness("grok", "Grok", () => join(home(), ".grok", "config.toml"), "grok"),
225
+ ];
226
+ /** Every harness actually present on this machine. */
227
+ export function detectHarnesses(list = HARNESSES) {
228
+ return list.filter((h) => {
229
+ try {
230
+ return h.detect();
231
+ }
232
+ catch {
233
+ return false;
234
+ }
235
+ });
236
+ }
@@ -1,5 +1,5 @@
1
1
  import { homedir } from "node:os";
2
- import { join } from "node:path";
2
+ import { dirname, join, resolve } from "node:path";
3
3
  import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
4
4
  import { randomUUID } from "node:crypto";
5
5
  /** The dir the keystore lives in. RETASC_DIR overrides it (tests, sandboxes). */
@@ -52,12 +52,87 @@ export function removeBinding(workspaceId) {
52
52
  export function newWorkspaceId() {
53
53
  return `ws_${randomUUID()}`;
54
54
  }
55
+ /**
56
+ * RTSC-780: the sentinel that means "work out the workspace from where you were
57
+ * spawned", rather than naming one.
58
+ *
59
+ * A marker carrying a real `ws_…` id names ONE workspace, so an entry holding it
60
+ * is only ever correct in the folder it was written for. That is why there is no
61
+ * global scope today (RTSC-91): a machine-wide entry with an id in it would route
62
+ * every folder on the disk to one org. `auto` carries no identity at all, so an
63
+ * entry holding it IS safe to write once, machine-wide, into every harness — the
64
+ * proxy resolves the folder it was spawned in against the keystore at startup and
65
+ * the org still comes from the folder, exactly as before.
66
+ *
67
+ * Not a possible collision: ids are `ws_`-prefixed uuids.
68
+ */
69
+ export const AUTO_WORKSPACE = "auto";
70
+ /**
71
+ * Which binding covers this directory? (RTSC-780)
72
+ *
73
+ * Walks UP from `dir`, matching `boundPath` at each level, so an agent spawned in
74
+ * a subdirectory of a bound project resolves to that project instead of to
75
+ * nothing. The walk is what makes `auto` usable in practice: harnesses spawn a
76
+ * stdio server in the project root, but not every one of them, and a human running
77
+ * `retasc claim` from `src/` should not be told the folder is unbound.
78
+ *
79
+ * Paths are compared resolved, so `/tmp` vs `/private/tmp` and a trailing slash
80
+ * don't produce a false miss.
81
+ *
82
+ * Newest wins when two ids claim the same path. A re-bind normally REUSES the
83
+ * folder's existing id (bind.ts), but a folder bound, unbound and bound again
84
+ * leaves an older entry behind, and the fresher one is the live binding.
85
+ *
86
+ * The walk STOPS at the repository root, and that bound is load-bearing rather than
87
+ * tidiness. An unbounded walk means binding a directory binds everything beneath it:
88
+ * run `retasc bind` once in `~/Development` (or `~`) and every project under it
89
+ * silently starts acting in that org, with no marker in those folders saying so and
90
+ * nothing on screen to notice. That is the cross-org leak this whole design exists to
91
+ * avoid (RTSC-91), reintroduced through the back door. A repository is the unit a
92
+ * person means by "project", so it is the unit the walk is allowed to cross: `src/lib`
93
+ * resolves to its repo, and a sibling repo checked out inside a bound directory
94
+ * resolves to nothing.
95
+ *
96
+ * Detected by the presence of `.git`, which is a FILE in a worktree and a directory in
97
+ * a normal clone, so `existsSync` is the right test for both.
98
+ */
99
+ export function findBindingByPath(dir) {
100
+ const bindings = loadKeystore().bindings;
101
+ // Index by resolved boundPath once, keeping the newest per path.
102
+ const byPath = new Map();
103
+ for (const [workspaceId, entry] of Object.entries(bindings)) {
104
+ if (!entry?.boundPath)
105
+ continue;
106
+ const key = resolve(entry.boundPath);
107
+ const prev = byPath.get(key);
108
+ if (!prev || (entry.createdAt ?? 0) > (prev.entry.createdAt ?? 0)) {
109
+ byPath.set(key, { workspaceId, entry });
110
+ }
111
+ }
112
+ let cur = resolve(dir);
113
+ // Bounded twice: by the repository root (below), and by the filesystem root, since
114
+ // `dirname("/")` is `"/"` and ends the loop for a directory outside any repo.
115
+ for (;;) {
116
+ const hit = byPath.get(cur);
117
+ if (hit)
118
+ return hit;
119
+ // Checked AFTER the lookup so the repo root itself, the folder `bind` actually
120
+ // writes, is always eligible.
121
+ if (existsSync(join(cur, ".git")))
122
+ return undefined;
123
+ const up = dirname(cur);
124
+ if (up === cur)
125
+ return undefined;
126
+ cur = up;
127
+ }
128
+ }
55
129
  /**
56
130
  * RTSC-98: the ONE place that resolves a workspace's key + MCP url. Shared by the
57
131
  * proxy (env-based) and the direct commands `claim`/`tidy`/`done` (.mcp.json-based)
58
132
  * so the two paths can never drift again. Precedence: explicit `RETASC_MCP_KEY`
59
133
  * env → legacy inline key in the marker (env or Authorization header) → canonical
60
- * secret-free marker (`RETASC_WORKSPACE` → home keystore). `mcpEntry` is a
134
+ * secret-free marker (`RETASC_WORKSPACE` → home keystore, or `auto` this
135
+ * process's cwd → home keystore). `mcpEntry` is a
61
136
  * workspace `.mcp.json`'s `mcpServers.retasc` entry, or undefined when only the
62
137
  * environment carries the binding (the spawned proxy).
63
138
  *
@@ -97,15 +172,23 @@ export function resolveConn(opts) {
97
172
  }
98
173
  // Canonical (RTSC-92): secret-free marker → workspace id → home keystore.
99
174
  // The entry's url is deliberately NOT consulted on this branch.
175
+ //
176
+ // RTSC-780 adds one more step in front of the id: `auto` names no workspace, so
177
+ // the folder we were spawned in picks it. Same trust rule either way — the key
178
+ // came from the keystore, so it travels only to the keystore's url. That is what
179
+ // lets `auto` be written into a GLOBAL harness config: the entry is identical on
180
+ // every machine and in every folder, and carries nothing worth stealing.
100
181
  if (!key) {
101
182
  const wsId = env.RETASC_WORKSPACE || entry?.env?.RETASC_WORKSPACE;
102
- if (wsId) {
103
- const b = getBinding(String(wsId));
104
- if (b) {
105
- key = b.key;
106
- if (!url)
107
- url = b.url;
108
- }
183
+ const b = wsId === AUTO_WORKSPACE
184
+ ? findBindingByPath(opts.cwd ?? process.cwd())?.entry
185
+ : wsId
186
+ ? getBinding(String(wsId))
187
+ : undefined;
188
+ if (b) {
189
+ key = b.key;
190
+ if (!url)
191
+ url = b.url;
109
192
  }
110
193
  }
111
194
  return { key, url: url || opts.defaultUrl || "" };
package/dist/proxy.js CHANGED
@@ -12,7 +12,7 @@ import { spawn, spawnSync } from "node:child_process";
12
12
  import { dirname, resolve } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { applyObservation, heartbeatRequest, isClaimLost, isUnauthorized, shouldReapOnClose, untrackedLeaseWarning, } from "./lib/watchdog.js";
15
- import { resolveConn } from "./lib/keystore.js";
15
+ import { AUTO_WORKSPACE, resolveConn } from "./lib/keystore.js";
16
16
  import { toolResult as parseTool } from "./lib/toolresult.js";
17
17
  import { mintSessionKey, appendFallbackNotice } from "./lib/session.js";
18
18
  import { attachRoot, isLocalAttachCall, mergeAttachTool, resolveAttachPath, uploadFailureMessage, uploadUrlWith, } from "./lib/attachFile.js";
@@ -23,6 +23,21 @@ import { MAX_FETCH_BYTES, downloadFailureMessage, existingDownload, isLocalFetch
23
23
  const resolved = resolveConn({ env: process.env, defaultUrl: "https://mcp.retasc.com/mcp" });
24
24
  let MCP_URL = resolved.url;
25
25
  const KEY = resolved.key;
26
+ // RTSC-780: with the `auto` marker the SAME entry is wired into every harness, once, and
27
+ // is therefore present in folders that were never bound. That is no longer a broken
28
+ // install, it is the ordinary state of a folder you haven't run `retasc bind` in yet, so
29
+ // it needs an answer rather than a 401.
30
+ //
31
+ // Getting this wrong is what RTSC-781 is about. Forwarding keyless requests produces an
32
+ // auth failure, and the CLI's own login is global and still fine, so the agent reports
33
+ // "signed in, but the server is unreachable" and a human goes and checks a server that
34
+ // was serving other clients the whole time. The folder is the thing that is missing, so
35
+ // the folder is what the error has to name.
36
+ const UNBOUND = !KEY && (process.env.RETASC_WORKSPACE ?? "") === AUTO_WORKSPACE ? process.cwd() : null;
37
+ const UNBOUND_MESSAGE = `This folder (${UNBOUND ?? process.cwd()}) is not bound to a Retasc project, so there is ` +
38
+ `nothing to pull work from. Nothing is wrong with your sign-in or with the Retasc server. ` +
39
+ `Run \`retasc bind\` in this folder to say which org and project it belongs to, then restart ` +
40
+ `this agent so it picks the binding up.`;
26
41
  const HEARTBEAT_MS = Number(process.env.RETASC_HEARTBEAT_MS) || 10 * 60 * 1000;
27
42
  const leases = new Map();
28
43
  let hbSeq = -1; // out-of-band heartbeat ids are negative — never collide with the harness's
@@ -408,6 +423,18 @@ async function handleLine(line) {
408
423
  // context), so every call to the tool is served here rather than forwarded.
409
424
  if (isLocalFetchCall(msg))
410
425
  return await handleLocalFetch(msg);
426
+ // RTSC-780: an unbound folder answers every tool call itself. `initialize` and
427
+ // `tools/list` still go remote, so the agent starts cleanly and can SEE the tools —
428
+ // the failure has to arrive at the moment one is used, worded as the folder problem
429
+ // it is, rather than as a startup crash the harness reports as "server unreachable".
430
+ if (UNBOUND && msg.method === "tools/call" && msg.id !== undefined) {
431
+ process.stdout.write(JSON.stringify({
432
+ jsonrpc: "2.0",
433
+ id: msg.id,
434
+ result: { content: [{ type: "text", text: UNBOUND_MESSAGE }], isError: true },
435
+ }) + "\n");
436
+ return;
437
+ }
411
438
  let resp;
412
439
  try {
413
440
  resp = await postRemote(msg);
@@ -505,7 +532,9 @@ async function heartbeatAll() {
505
532
  }
506
533
  /** Run the stdio proxy. Started by the harness; lives as long as the session. */
507
534
  export async function runProxy() {
508
- if (!KEY)
535
+ if (UNBOUND)
536
+ log(UNBOUND_MESSAGE);
537
+ else if (!KEY)
509
538
  log("warning: RETASC_MCP_KEY is empty — forwarded requests will be unauthorized");
510
539
  // Adopt a per-session key BEFORE serving traffic, so even the first claim is
511
540
  // attributed to this session. stdin buffers in the OS pipe meanwhile.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.37.0",
3
+ "version": "1.38.1",
4
4
  "description": "Retasc CLI — 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": {
@@ -40,7 +40,7 @@
40
40
  "scripts": {
41
41
  "build": "tsc",
42
42
  "dev": "tsc --watch",
43
- "test": "npm run build && node --test test/*.test.mjs",
43
+ "test": "npm run build && node --test --import ./test/_test-home.mjs test/*.test.mjs",
44
44
  "prepublishOnly": "npm run build"
45
45
  },
46
46
  "dependencies": {