@retasc/cli 1.7.2 → 1.9.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/api.js +23 -0
- package/dist/commands/bind.js +265 -116
- package/dist/commands/claim.js +3 -0
- package/dist/commands/doctor.js +81 -1
- package/dist/commands/join.js +200 -0
- package/dist/commands/mcp.js +46 -24
- package/dist/index.js +23 -27
- package/dist/lib/binding.js +7 -2
- package/dist/lib/invite.js +38 -0
- package/dist/lib/launcher.js +200 -0
- package/dist/lib/text.js +21 -0
- package/dist/version.js +11 -0
- package/package.json +1 -1
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { api, formatError } from "../api.js";
|
|
2
|
+
import { loadConfig } from "../config.js";
|
|
3
|
+
import { parseInviteCode } from "../lib/invite.js";
|
|
4
|
+
import { selfCommand } from "../lib/launcher.js";
|
|
5
|
+
import { clean } from "../lib/text.js";
|
|
6
|
+
import { VERSION } from "../version.js";
|
|
7
|
+
import { ask, confirm, ensureSignedIn, isInteractive, pickExisting, rebindGuard, completeWorkspaceSetup, } from "./bind.js";
|
|
8
|
+
/** A ghost whose origin predates `sourceLabel`. Never invent a tool we don't know. */
|
|
9
|
+
const UNKNOWN_SOURCE = "another tool";
|
|
10
|
+
/**
|
|
11
|
+
* A stop that cannot loop forever.
|
|
12
|
+
*
|
|
13
|
+
* The loop is driven by a server list that shrinks on every claim, so it terminates on its
|
|
14
|
+
* own — but it is a `while (true)` around a network call, and a backend that ever returned
|
|
15
|
+
* an unchanged list would spin a human's terminal rather than fail. Well above any real
|
|
16
|
+
* number of migrated tools.
|
|
17
|
+
*/
|
|
18
|
+
const MAX_IDENTITY_ROUNDS = 10;
|
|
19
|
+
/** How one row reads: the person, and the tool they were carried in from. */
|
|
20
|
+
function ghostRow(g) {
|
|
21
|
+
// BOTH fields are third-party text: the name was typed by someone in ClickUp/Jira/Asana
|
|
22
|
+
// and carried across verbatim, and `sourceLabel` is the adapter's own record of where.
|
|
23
|
+
// They print into a terminal that acts on escape sequences, right above a prompt.
|
|
24
|
+
return `${clean(g.name)} from ${clean(g.sourceLabel ?? UNKNOWN_SOURCE)}`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* "Which of these imported people is you?" — the CLI half of RTSC-433/473.
|
|
28
|
+
*
|
|
29
|
+
* Mirrors the Dash notice (`dash/src/components/GhostClaim.tsx`) deliberately, down to the
|
|
30
|
+
* confirm wording in `dash/src/lib/ghostPrompt.ts`. Two surfaces asking the same
|
|
31
|
+
* irreversible question in two different voices is how someone ends up answering the one
|
|
32
|
+
* they trust less.
|
|
33
|
+
*
|
|
34
|
+
* Never fatal. Everything here is about attribution; the bind that follows is what makes
|
|
35
|
+
* the folder work, and a failure to offer a claim must not cost someone their workspace.
|
|
36
|
+
*/
|
|
37
|
+
export async function identityLoop(orgId, opts, orgLabel,
|
|
38
|
+
// Injected so the question itself can be pinned by tests. It is asked once per human per
|
|
39
|
+
// org and it is irreversible, so "we ran it live and it looked right" is not evidence —
|
|
40
|
+
// the live orgs available to us have all already answered, which exercises only the
|
|
41
|
+
// silent path. What has to be provable is the loud one.
|
|
42
|
+
deps = {}) {
|
|
43
|
+
const d = {
|
|
44
|
+
claimableGhosts: api.claimableGhosts,
|
|
45
|
+
claimGhost: api.claimGhost,
|
|
46
|
+
dismissGhostPrompt: api.dismissGhostPrompt,
|
|
47
|
+
askFn: ask,
|
|
48
|
+
confirmFn: confirm,
|
|
49
|
+
interactive: isInteractive,
|
|
50
|
+
...deps,
|
|
51
|
+
};
|
|
52
|
+
// `--yes` may SKIP this question. It may never answer it: claiming pulls another human's
|
|
53
|
+
// authorship AND their dispatch lane onto your account, irreversibly, and there is no
|
|
54
|
+
// CLI path back. A scripted run has nobody to be wrong on behalf of.
|
|
55
|
+
if (!d.interactive() || opts.yes)
|
|
56
|
+
return;
|
|
57
|
+
for (let round = 0; round < MAX_IDENTITY_ROUNDS; round++) {
|
|
58
|
+
let ghosts;
|
|
59
|
+
try {
|
|
60
|
+
const res = await d.claimableGhosts({ orgId });
|
|
61
|
+
if (res.asked || !res.ghosts.length)
|
|
62
|
+
return;
|
|
63
|
+
ghosts = res.ghosts;
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
const { message } = formatError(e);
|
|
67
|
+
console.error(` ! Couldn't check for imported history (${message}). Carrying on.`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
console.log(round === 0
|
|
71
|
+
? `\nWhen ${orgLabel ?? "this org"} migrated, some people were carried across.\n` +
|
|
72
|
+
"One of them may be you:"
|
|
73
|
+
: "\nAnything else here you recognise?");
|
|
74
|
+
// "Don't ask again" is not decoration. Declining writes `ghostPromptDismissedAt` on the
|
|
75
|
+
// member row and `claimGhost` refuses for good afterwards, org-wide — so a bare "none
|
|
76
|
+
// of these are me" would read as "none of these ClickUp ones" and quietly close the
|
|
77
|
+
// door on a second tool's placeholder too.
|
|
78
|
+
// Compared by REFERENCE below, not by a magic id value: a sentinel that is merely a
|
|
79
|
+
// row with an empty id would let any future ghost with a falsy id read as a decline —
|
|
80
|
+
// and a decline is permanent.
|
|
81
|
+
const NONE = { id: "", name: "None of these are me (don't ask again)", sourceLabel: null };
|
|
82
|
+
// No label: the heading above is the prompt, and `choose` would print a second one.
|
|
83
|
+
const chosen = await pickExisting("", [...ghosts, NONE], (g) => (g === NONE ? g.name : ghostRow(g)), d.askFn);
|
|
84
|
+
if (chosen === NONE) {
|
|
85
|
+
try {
|
|
86
|
+
await d.dismissGhostPrompt({ orgId });
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
console.error(` ! ${formatError(e).message}`);
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// Confirm before claiming — it is irreversible and it moves dispatch routing, so it
|
|
94
|
+
// must not be a single keystroke on a row in a list. Same qualitative wording as the
|
|
95
|
+
// Dash: counting "12 issues, 4 comments" would mean an org-wide scan on exactly the
|
|
96
|
+
// orgs that just ingested a migration.
|
|
97
|
+
const from = clean(chosen.sourceLabel ?? UNKNOWN_SOURCE);
|
|
98
|
+
const question = `\n Link "${clean(chosen.name)}" to your account? Everything they wrote, commented on,\n` +
|
|
99
|
+
` or were assigned in ${from} becomes yours. This can't be undone.`;
|
|
100
|
+
if (!(await d.confirmFn(question))) {
|
|
101
|
+
// NOT a dismissal. They declined THIS row, which is not the same as "none of these
|
|
102
|
+
// are me" — and dismissing is permanent, so it only ever happens when said outright.
|
|
103
|
+
console.log(" Left unlinked.");
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const res = await d.claimGhost({ orgId, memberId: chosen.id });
|
|
108
|
+
console.log(`✓ Linked ${clean(res.name)}.`);
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
// SOURCE_ALREADY_CLAIMED, IMPORT_RUNNING, NOT_CLAIMABLE — all readable, all reachable
|
|
112
|
+
// by racing the Dash, and none of them a reason to abandon the setup.
|
|
113
|
+
const { code, message, hint } = formatError(e);
|
|
114
|
+
console.error(` ✗ ${code ? `${code}: ` : ""}${message}`);
|
|
115
|
+
if (hint)
|
|
116
|
+
console.error(` → ${hint}`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
// Claiming one ClickUp identity removes EVERY remaining ClickUp row (`claimableGhosts`
|
|
120
|
+
// filters by spent source), so the next round can only ever offer a different tool.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export async function joinAction(link, opts) {
|
|
124
|
+
// Client-side, before anything else: the code is what the server matches, and someone
|
|
125
|
+
// who pasted the wrong thing should hear it here rather than as "not recognized".
|
|
126
|
+
const code = parseInviteCode(link);
|
|
127
|
+
await ensureSignedIn();
|
|
128
|
+
// --- 2. redeem ------------------------------------------------------------
|
|
129
|
+
// Any failure here stops the run. A dead invite means there is no org to bind to, and
|
|
130
|
+
// binding this folder to nothing would be worse than stopping.
|
|
131
|
+
const res = (await api.acceptInvite({ code }));
|
|
132
|
+
const orgLabel = res.slug ? clean(res.slug) : undefined;
|
|
133
|
+
const where = orgLabel ? ` "${orgLabel}"` : "";
|
|
134
|
+
if (res.alreadyMember) {
|
|
135
|
+
// Not a failure: the code stays unconsumed by design, so the person it was meant for
|
|
136
|
+
// can still use it. Say so and carry on into the folder half.
|
|
137
|
+
console.log(`• You're already a member of org${where} (${res.role}).`);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
console.log(`✓ Joined org${where} as a ${res.role}.`);
|
|
141
|
+
}
|
|
142
|
+
if (opts.bind === false) {
|
|
143
|
+
// `--no-bind` is redeem-only, exactly as `join` behaved before RTSC-492 — including
|
|
144
|
+
// asking nothing, so anything scripted against it sees the same output and the same
|
|
145
|
+
// exit code. The identity prompt still reaches them through the Dash.
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
// --- 3. identity ----------------------------------------------------------
|
|
149
|
+
// BEFORE anything about this folder, including the re-bind guard. Ghosts are rows in
|
|
150
|
+
// `members`, which carries an `orgId` and no `projectId`: the question belongs to the org
|
|
151
|
+
// just joined, and this is the only time `join` asks it. Gating it behind the folder half
|
|
152
|
+
// would mean someone whose folder is already bound elsewhere — who declines the re-bind —
|
|
153
|
+
// silently never sees it, and their imported history stays stranded.
|
|
154
|
+
await identityLoop(res.orgId, opts, orgLabel);
|
|
155
|
+
// --- the folder half ------------------------------------------------------
|
|
156
|
+
const cfg = loadConfig();
|
|
157
|
+
const self = selfCommand(VERSION);
|
|
158
|
+
const resume = `${self} bind --org-id ${res.orgId}`;
|
|
159
|
+
const guard = await rebindGuard({
|
|
160
|
+
cwd: process.cwd(),
|
|
161
|
+
mcpUrl: cfg.mcpUrl,
|
|
162
|
+
orgId: res.orgId,
|
|
163
|
+
projectId: opts.projectId,
|
|
164
|
+
yes: opts.yes,
|
|
165
|
+
});
|
|
166
|
+
if (!guard.proceed) {
|
|
167
|
+
// The membership landed either way — never let a declined or no-op re-bind read as a
|
|
168
|
+
// failed join. `rebindGuard` has already said what it did with the folder, INCLUDING
|
|
169
|
+
// setting a non-zero exit code for the non-interactive refusal, so don't paper over
|
|
170
|
+
// that with a line that reads like everything is finished.
|
|
171
|
+
if (process.exitCode)
|
|
172
|
+
console.error(` You did join org${where}; only the folder was left alone.`);
|
|
173
|
+
else
|
|
174
|
+
console.log(` You're a member of org${where}. Nothing else to do here.`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
await completeWorkspaceSetup({
|
|
179
|
+
orgId: res.orgId,
|
|
180
|
+
orgLabel,
|
|
181
|
+
opts,
|
|
182
|
+
existing: guard.existing,
|
|
183
|
+
// `createProject` is `requireOwner`, and an invite only ever grants `member`. Offering
|
|
184
|
+
// "+ create new" here would be offering a refusal dressed as a choice.
|
|
185
|
+
canCreateProject: false,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
catch (e) {
|
|
189
|
+
// Say what SUCCEEDED before reporting the failure, and name the command that resumes.
|
|
190
|
+
// The membership (and any identity claim) already landed and cannot be re-done: the
|
|
191
|
+
// invite is consumed now, so re-running `join` with the same link would report
|
|
192
|
+
// "already used" and read like the whole thing failed.
|
|
193
|
+
console.error(`\n You did join org${where} — only this folder's setup didn't finish.`);
|
|
194
|
+
console.error(` Resume with: ${resume}`);
|
|
195
|
+
throw e;
|
|
196
|
+
}
|
|
197
|
+
if (isInteractive()) {
|
|
198
|
+
console.log("\nStart your agent in this folder and it'll pull from the queue.");
|
|
199
|
+
}
|
|
200
|
+
}
|
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,30 @@ 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 = opts.launcher ?? resolveLauncher({ version: VERSION, install: opts.install });
|
|
215
|
+
// Only announce a resolve we did ourselves — a caller that passed one in already printed
|
|
216
|
+
// its note at the point in ITS sequence where the install actually happened.
|
|
217
|
+
const note = opts.launcher ? null : launcherNote(resolved);
|
|
218
|
+
if (note)
|
|
219
|
+
console.log(note);
|
|
220
|
+
// `local` scope lands in this machine's own Claude config, where the absolute path is
|
|
221
|
+
// both correct and fastest. `project` scope, and the ./.mcp.json fallback below, are
|
|
222
|
+
// SHARED — a path under this user's home directory would leak their username into a
|
|
223
|
+
// committed file and break every teammate who clones it. See portableLauncher.
|
|
224
|
+
const shared = portableLauncher(resolved, VERSION);
|
|
225
|
+
const res = tryClaudeCliMarker(opts.workspaceId, scope, scope === "project" ? shared : resolved.launcher);
|
|
205
226
|
if (res.ok) {
|
|
206
227
|
console.log(`✓ Registered Retasc watchdog (secret-free marker, scope: ${scope}).`);
|
|
207
228
|
}
|
|
208
229
|
else {
|
|
209
|
-
|
|
230
|
+
// Always ./.mcp.json, whatever scope was asked for, so always the shared form.
|
|
231
|
+
const path = writeProjectMcpJson(mcpMarkerEntry(opts.workspaceId, shared));
|
|
210
232
|
console.log(`✓ Wrote secret-free watchdog marker to ${path}`);
|
|
211
233
|
console.log(` ${fallbackNote(res)}`);
|
|
212
234
|
}
|
|
213
235
|
console.log("\nMarker block (any stdio MCP client) — no secret, safe to commit:\n");
|
|
214
|
-
console.log(mcpMarkerConfigBlock(opts.workspaceId));
|
|
236
|
+
console.log(mcpMarkerConfigBlock(opts.workspaceId, shared));
|
|
215
237
|
console.log("\nThe key lives in your home keystore (~/.retasc/bindings.json), not in the repo.");
|
|
216
238
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
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";
|
|
9
7
|
import { claimAction } from "./commands/claim.js";
|
|
10
8
|
import { bindAction } from "./commands/bind.js";
|
|
9
|
+
import { joinAction } from "./commands/join.js";
|
|
11
10
|
import { doctorAction } from "./commands/doctor.js";
|
|
12
11
|
import { billingAction } from "./commands/billing.js";
|
|
13
12
|
import { isNetworkError, readLocalBinding, resolveBinding } from "./lib/binding.js";
|
|
@@ -15,16 +14,11 @@ import { tidyAction, doneAction } from "./commands/tidy.js";
|
|
|
15
14
|
import { runProxy } from "./proxy.js";
|
|
16
15
|
import { deviceLogin } from "./auth.js";
|
|
17
16
|
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
17
|
const program = new Command();
|
|
24
18
|
program
|
|
25
19
|
.name("retasc")
|
|
26
20
|
.description("Retasc — sign in, create projects, mint agent API keys, and wire your agent to the MCP server.")
|
|
27
|
-
.version(
|
|
21
|
+
.version(VERSION);
|
|
28
22
|
function requireLogin() {
|
|
29
23
|
if (!isLoggedIn()) {
|
|
30
24
|
console.error("Not signed in. Run `retasc login` first.");
|
|
@@ -327,7 +321,11 @@ members
|
|
|
327
321
|
const res = (await api.createInvite({ orgId: opts.orgId, expiresInDays: opts.expiresDays }));
|
|
328
322
|
console.log(`✓ Invite code: ${res.code}`);
|
|
329
323
|
console.log(` Expires ${new Date(res.expiresAt).toISOString().slice(0, 10)}. Single-use — shown once.`);
|
|
330
|
-
|
|
324
|
+
// RTSC-492: one command, and one that needs nothing installed first. `join` signs
|
|
325
|
+
// them in, binds the folder and wires their agent, so this must not tell them to run
|
|
326
|
+
// `retasc login` first — nor name a `retasc` binary they don't have yet.
|
|
327
|
+
console.log(` Share it. In the folder their agent works in, they run:`);
|
|
328
|
+
console.log(` npx @retasc/cli join ${res.code}`);
|
|
331
329
|
}
|
|
332
330
|
catch (e) {
|
|
333
331
|
fail(e);
|
|
@@ -361,25 +359,23 @@ members
|
|
|
361
359
|
fail(e);
|
|
362
360
|
}
|
|
363
361
|
});
|
|
362
|
+
// RTSC-492 — the WHOLE of an invited teammate's setup, in one command run from the folder
|
|
363
|
+
// their agent will work in: sign in, redeem, claim any imported history, pick the project,
|
|
364
|
+
// make `retasc` durable, mint a key, bind the folder and wire the MCP marker. It signs
|
|
365
|
+
// itself in (no `requireLogin` gate) because "run this, but run the other one first" is
|
|
366
|
+
// not a single instruction.
|
|
364
367
|
program
|
|
365
368
|
.command("join")
|
|
366
|
-
.description("
|
|
367
|
-
.argument("<
|
|
368
|
-
.
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
else {
|
|
377
|
-
console.log(`✓ Joined org${where} as ${res.role}. It'll show up in \`retasc whoami\`.`);
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
catch (e) {
|
|
381
|
-
fail(e);
|
|
382
|
-
}
|
|
369
|
+
.description("Join an org from an invite link and set this folder up completely — one command.")
|
|
370
|
+
.argument("<link>", "The invite link you were given (or just the rtscinv_… code)")
|
|
371
|
+
.option("--no-bind", "Redeem only — don't set this folder up")
|
|
372
|
+
.option("--project-id <id>", "Which project to bind to (skips the picker)")
|
|
373
|
+
.option("--agent <name>", "Agent member name (default: auto)")
|
|
374
|
+
.option("--runtime <runtime>", "Agent runtime", "claude-code")
|
|
375
|
+
.option("-y, --yes", "Don't prompt to replace an existing binding, and skip the identity question")
|
|
376
|
+
.allowExcessArguments(false)
|
|
377
|
+
.action(async (link, opts) => {
|
|
378
|
+
await joinAction(link, opts).catch(fail);
|
|
383
379
|
});
|
|
384
380
|
// --- mcp wiring ------------------------------------------------------------
|
|
385
381
|
const mcp = program.command("mcp").description("Wire the Retasc MCP server into your agent.");
|
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,38 @@
|
|
|
1
|
+
// RTSC-492 — turn whatever the person was handed into the code the server matches.
|
|
2
|
+
//
|
|
3
|
+
// An invite reaches someone as a LINK (`https://dash.retasc.com/join#rtscinv_…`), and a
|
|
4
|
+
// link is what they paste. `acceptInvite` matches an exact string, and it should stay that
|
|
5
|
+
// way: normalising a paste is a property of the edge that accepts typed input, not of the
|
|
6
|
+
// mutation that grants membership. So the unwrapping happens here.
|
|
7
|
+
/** Every invite code the product has ever minted starts with this. */
|
|
8
|
+
const INVITE_PREFIX = "rtscinv_";
|
|
9
|
+
/**
|
|
10
|
+
* The invite code inside a link, a bare code, or a code someone pasted with prose
|
|
11
|
+
* around it.
|
|
12
|
+
*
|
|
13
|
+
* One rule rather than a parser: find the first token that LOOKS like an invite code,
|
|
14
|
+
* anywhere in the input. That covers the fragment (`…/join#rtscinv_x`), a query string
|
|
15
|
+
* (`?code=rtscinv_x`), a path segment, an email client's `<…>` wrapper and a trailing
|
|
16
|
+
* period from a sentence, without needing to know which of those the sender used — and
|
|
17
|
+
* without the URL-shape guessing that gets one of them wrong.
|
|
18
|
+
*
|
|
19
|
+
* A token that isn't our shape is still passed through UNCHANGED as long as it can't be a
|
|
20
|
+
* link. A future code format then keeps working with no CLI release, and the server's
|
|
21
|
+
* "That invite code isn't recognized" stays the authority on what's valid. Something
|
|
22
|
+
* link-shaped with no code in it is the one case we refuse locally, because forwarding a
|
|
23
|
+
* URL as a code can only ever produce that same message with none of the diagnosis.
|
|
24
|
+
*/
|
|
25
|
+
export function parseInviteCode(input) {
|
|
26
|
+
const raw = String(input ?? "").trim();
|
|
27
|
+
// Hex today, but accept the wider token charset a future format might use rather than
|
|
28
|
+
// pinning this to `[0-9a-f]{48}` and having to ship a release to read a new code.
|
|
29
|
+
const match = raw.match(new RegExp(`${INVITE_PREFIX}[A-Za-z0-9_-]+`));
|
|
30
|
+
if (match)
|
|
31
|
+
return match[0];
|
|
32
|
+
const bare = raw.replace(/^[<"'(]+|[>"'.,)]+$/g, "");
|
|
33
|
+
if (bare && !/[\s/:]/.test(bare))
|
|
34
|
+
return bare;
|
|
35
|
+
throw new Error(raw
|
|
36
|
+
? "That doesn't contain an invite code. Paste the whole invite link, or just the `rtscinv_…` code."
|
|
37
|
+
: "No invite code given. Pass the invite link, or just the `rtscinv_…` code.");
|
|
38
|
+
}
|