@retasc/cli 1.35.1 → 1.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/dist/api.js +10 -1
- package/dist/auth.js +10 -1
- package/dist/commands/claim.js +7 -1
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/import.js +41 -2
- package/dist/config.js +6 -0
- package/dist/index.js +11 -1
- package/dist/lib/binding.js +46 -3
- package/dist/lib/claim.js +74 -5
- package/dist/lib/launcher.js +14 -6
- package/dist/lib/updateNotice.js +172 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,56 @@ 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.36.0 (2026-08-25)
|
|
10
|
+
|
|
11
|
+
- **RTSC-520** — the CLI now says when it is out of date. There was no version check
|
|
12
|
+
anywhere in `cli/`, so a global install went stale silently and stayed that way for as
|
|
13
|
+
long as the machine lived — and a stale CLI does not merely lack features, it *lies*:
|
|
14
|
+
its copy describes SERVER behaviour, so 1.11.0 kept telling people a decline "covers the
|
|
15
|
+
whole org, including later migrations" long after RTSC-507 made that false, and 1.10.0
|
|
16
|
+
had no Google door, so it sent Google-only humans through GitHub and minted them a
|
|
17
|
+
second identity. After any login-gated command an outdated build now prints one line
|
|
18
|
+
naming both versions and asks whether to update:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
A newer retasc is available (1.36.0, you have 1.35.2).
|
|
22
|
+
Update now? [y/N]
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Notify then ask, never a silent `npm i -g`: interactive terminals only (no TTY prints
|
|
26
|
+
the notice and nothing else, so scripted runs are unchanged), at most once a day with a
|
|
27
|
+
decline remembered, and a failed install — EACCES on a root-owned prefix is the common
|
|
28
|
+
one — reports itself and names `npm i -g @retasc/cli@latest` without ever failing the
|
|
29
|
+
command you actually ran. A successful update says it applies from your next command and
|
|
30
|
+
that agents need restarting to pick up the new MCP server. The MCP proxy never prints it.
|
|
31
|
+
|
|
32
|
+
The published version is resolved server-side from the npm registry, cached hourly, and
|
|
33
|
+
handed down on `manage:me` — a call every login-gated command already makes, so checking
|
|
34
|
+
costs no round trip and no shared rate limit, and no release step can forget to update
|
|
35
|
+
it. An optional `RETASC_CLI_MIN_HONEST` marks the oldest version whose copy still matches
|
|
36
|
+
the server; below it the notice says so plainly, because "a newer version exists" and
|
|
37
|
+
"what this build just told you may be false" are different problems.
|
|
38
|
+
|
|
39
|
+
## 1.35.2 (2026-08-25)
|
|
40
|
+
|
|
41
|
+
- **RTSC-743** — the last CLI replies that answered a person with a machine shape now
|
|
42
|
+
answer in sentences. A failed MCP request used to paste 200 characters of the raw
|
|
43
|
+
response body after the status, which on that endpoint is either a JSON-RPC envelope or,
|
|
44
|
+
when the failure lands at Cloudflare rather than in Convex, a page of HTML; it now says
|
|
45
|
+
what the status means and what to do about it, and reads the body for a message rather
|
|
46
|
+
than printing it. A reply that is not JSON at all (a proxy error page, a captive portal)
|
|
47
|
+
named a JSON parser's position; it now names what actually answered. `retasc login`
|
|
48
|
+
stringified GitHub's whole OAuth payload into its error, burying `error_description`,
|
|
49
|
+
the one field in it written for a person. `whoami` and `doctor` could surface a bare
|
|
50
|
+
parser complaint from `resolveBinding`, which is a poor answer to "what is broken?" from
|
|
51
|
+
the two commands you run when something already is. And `retasc import` printed the
|
|
52
|
+
summary with the wire payload's own field names as labels — `issuesCreated`,
|
|
53
|
+
`commentsInserted`, `attachmentsRehosted` — where it now prints Issues created,
|
|
54
|
+
Comments and Attachments copied. Along the way, `doctor` and `whoami` stop telling you
|
|
55
|
+
to re-run `retasc bind` when the thing that answered was a proxy rather than Retasc:
|
|
56
|
+
that failure carries no verdict on your key, and re-binding to fix it mints a new one
|
|
57
|
+
for nothing.
|
|
58
|
+
|
|
9
59
|
## 1.35.1 (2026-08-25)
|
|
10
60
|
|
|
11
61
|
- **RTSC-478** — a network failure now names its own cause. Node reports every failed
|
package/dist/api.js
CHANGED
|
@@ -5,6 +5,7 @@ import { refreshSession, deviceLogin } from "./auth.js";
|
|
|
5
5
|
import { selfCommand } from "./lib/launcher.js";
|
|
6
6
|
import { clean as sanitize } from "./lib/text.js";
|
|
7
7
|
import { VERSION } from "./version.js";
|
|
8
|
+
import { recordRelease } from "./lib/updateNotice.js";
|
|
8
9
|
// Typed-ish references to the public management functions in convex/manage.ts.
|
|
9
10
|
// The CLI is a standalone package, so we reference functions by name rather than
|
|
10
11
|
// importing the parent's generated api.
|
|
@@ -187,7 +188,15 @@ async function withAuth(call) {
|
|
|
187
188
|
}
|
|
188
189
|
}
|
|
189
190
|
export const api = {
|
|
190
|
-
|
|
191
|
+
// RTSC-520 — every login-gated command already makes this call, so the published-version
|
|
192
|
+
// signal rides down on it and costs nothing. Recording it HERE, rather than at each of the
|
|
193
|
+
// nine call sites, is also what keeps the notice out of the machine channel: the MCP proxy
|
|
194
|
+
// never imports this module, so it can never have a signal to print.
|
|
195
|
+
me: async () => {
|
|
196
|
+
const res = await withAuth(() => client().query(fns.me, {}));
|
|
197
|
+
recordRelease(res?.cli);
|
|
198
|
+
return res;
|
|
199
|
+
},
|
|
191
200
|
listProjects: (args) => withAuth(() => client().query(fns.listProjects, args)),
|
|
192
201
|
createOrg: (args) => withAuth(() => client().mutation(fns.createOrg, args)),
|
|
193
202
|
createProject: (args) => withAuth(() => client().mutation(fns.createProject, args)),
|
package/dist/auth.js
CHANGED
|
@@ -4,6 +4,9 @@ import { makeFunctionReference } from "convex/server";
|
|
|
4
4
|
import { loadConfig, patchConfig } from "./config.js";
|
|
5
5
|
import { ask, isInteractive } from "./lib/prompt.js";
|
|
6
6
|
import { startBrowserLogin } from "./lib/browserLogin.js";
|
|
7
|
+
import { clean } from "./lib/text.js";
|
|
8
|
+
import { selfCommand } from "./lib/launcher.js";
|
|
9
|
+
import { VERSION } from "./version.js";
|
|
7
10
|
// The GitHub OAuth App's PUBLIC client id (safe to ship — device flow needs no
|
|
8
11
|
// secret). Baked in so `retasc login` works out-of-the-box; override via env.
|
|
9
12
|
const GITHUB_CLIENT_ID = process.env.RETASC_GITHUB_CLIENT_ID ?? "Ov23linqRy875IU8OYTW";
|
|
@@ -140,7 +143,13 @@ async function githubDeviceToken() {
|
|
|
140
143
|
scope: "read:user user:email",
|
|
141
144
|
});
|
|
142
145
|
if (!start.device_code) {
|
|
143
|
-
|
|
146
|
+
// Say what GitHub said, not what GitHub SENT. The whole response used to be
|
|
147
|
+
// JSON.stringify'd into this message, which put a raw OAuth payload in front of
|
|
148
|
+
// someone whose only question is whether to try again — and buried
|
|
149
|
+
// `error_description`, the one field in it written for a person to read.
|
|
150
|
+
const said = start.error_description ?? start.error;
|
|
151
|
+
throw new Error(`GitHub didn't hand back a sign-in code${said ? `: ${clean(said)}` : "."} ` +
|
|
152
|
+
`Check your connection and run \`${selfCommand(VERSION)} login\` again.`);
|
|
144
153
|
}
|
|
145
154
|
announceCode("GitHub", start.verification_uri, start.user_code, start.verification_uri_complete);
|
|
146
155
|
// Poll GitHub for the access token.
|
package/dist/commands/claim.js
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
|
|
|
3
3
|
import { basename, dirname, resolve, sep } from "node:path";
|
|
4
4
|
import { resolveMcpConn, readMcpJson, mcpCall, parseClaimResult, planWorktree, isValidIssueId, normalizeIssueId, resolveIssueRef, } from "../lib/claim.js";
|
|
5
5
|
import { loadConfig } from "../config.js";
|
|
6
|
+
import { clean } from "../lib/text.js";
|
|
6
7
|
function git(args, cwd) {
|
|
7
8
|
return spawnSync("git", args, { encoding: "utf8", cwd });
|
|
8
9
|
}
|
|
@@ -124,7 +125,12 @@ export async function claimAction(opts) {
|
|
|
124
125
|
}
|
|
125
126
|
// The id flows into a git ref and a filesystem path — reject a malformed one.
|
|
126
127
|
if (!isValidIssueId(claim.issueId)) {
|
|
127
|
-
|
|
128
|
+
// Named in words, not JSON.stringify'd. The value is either a string, in which case
|
|
129
|
+
// quoting it is enough, or it is a shape — and dumping that shape tells the reader
|
|
130
|
+
// nothing they can act on while looking exactly like a crash.
|
|
131
|
+
const shown = typeof claim.issueId === "string" ? `"${clean(claim.issueId)}"` : "nothing usable";
|
|
132
|
+
note(`✗ Retasc sent ${shown} where an issue id (like RTSC-42) should be.`);
|
|
133
|
+
note(` Nothing was branched or checked out. Try again, or run \`retasc doctor\`.`);
|
|
128
134
|
process.exit(1);
|
|
129
135
|
}
|
|
130
136
|
// claim_issue returns just the id — fetch the title so the slug is meaningful.
|
package/dist/commands/doctor.js
CHANGED
|
@@ -134,7 +134,7 @@ export async function doctorAction() {
|
|
|
134
134
|
// The binding EXISTS; we just can't verify it. Don't send the user off
|
|
135
135
|
// to re-bind over a dead wifi link or a server having a bad minute.
|
|
136
136
|
networkDown = true;
|
|
137
|
-
warn(`binding present (${local.source} scope) but
|
|
137
|
+
warn(`binding present (${local.source} scope) but no usable answer came back: ${msg}.`);
|
|
138
138
|
}
|
|
139
139
|
else {
|
|
140
140
|
bad(`key not accepted by the server: ${msg}. Re-run \`retasc bind\`.`);
|
package/dist/commands/import.js
CHANGED
|
@@ -50,6 +50,42 @@ export function reimportWarning(history, source, label) {
|
|
|
50
50
|
` Re-importing re-syncs those issues, so any edits you made in Retasc to them\n` +
|
|
51
51
|
` (status, labels, and so on) will be replaced by ${clean(label)}'s version.`);
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* What each field of the server's `ImportSummary` is CALLED, for a person.
|
|
55
|
+
*
|
|
56
|
+
* The summary is a wire payload, and it used to be printed with its own keys as labels —
|
|
57
|
+
* `issuesCreated`, `commentsInserted`, `attachmentsRehosted`, one per line. That is a JSON
|
|
58
|
+
* object with the braces taken off: it reads as the inside of the program rather than as
|
|
59
|
+
* the answer to "what just came across". The names live here, in one list, next to the
|
|
60
|
+
* shape they describe (`ImportSummary` in convex/import.ts).
|
|
61
|
+
*/
|
|
62
|
+
const SUMMARY_LABELS = {
|
|
63
|
+
totalIssues: "Issues seen",
|
|
64
|
+
issuesCreated: "Issues created",
|
|
65
|
+
issuesUpdated: "Issues updated",
|
|
66
|
+
relationsAdded: "Links between issues",
|
|
67
|
+
commentsInserted: "Comments",
|
|
68
|
+
attachmentsRehosted: "Attachments copied",
|
|
69
|
+
attachmentsFailed: "Attachments that failed",
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* A label for a field the list above doesn't name.
|
|
73
|
+
*
|
|
74
|
+
* A field this CLI is too old to know about must still read as English rather than as a
|
|
75
|
+
* key, because the pairing is permanent: the backend deploys separately, so there is
|
|
76
|
+
* always a version of this CLI that predates the newest summary field. `fooBarBaz`
|
|
77
|
+
* becomes "Foo bar baz" — not the real name someone would have chosen, but a phrase.
|
|
78
|
+
*/
|
|
79
|
+
export function summaryLabel(key) {
|
|
80
|
+
// `hasOwnProperty`, not a bare lookup. The keys come off the wire, and a bare
|
|
81
|
+
// `SUMMARY_LABELS[key]` walks Object.prototype: a field named `constructor` or
|
|
82
|
+
// `toString` returns a FUNCTION, which `padEnd` then throws on. That would crash
|
|
83
|
+
// `retasc import` on the line after a multi-minute run finished successfully.
|
|
84
|
+
if (Object.prototype.hasOwnProperty.call(SUMMARY_LABELS, key))
|
|
85
|
+
return SUMMARY_LABELS[key];
|
|
86
|
+
const words = key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
|
|
87
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
88
|
+
}
|
|
53
89
|
/**
|
|
54
90
|
* The summary after a run, with the one field that needs words rather than a number.
|
|
55
91
|
*
|
|
@@ -66,8 +102,11 @@ export function summaryLines(summary, label) {
|
|
|
66
102
|
unmapped.push(...v.map(String));
|
|
67
103
|
continue;
|
|
68
104
|
}
|
|
69
|
-
|
|
70
|
-
|
|
105
|
+
// Only numbers and strings are printed, which is also what keeps a structured field
|
|
106
|
+
// (`sample`, `relationCyclesSkipped`) from arriving as `[object Object]`.
|
|
107
|
+
if (typeof v === "number" || typeof v === "string") {
|
|
108
|
+
out.push(` ${summaryLabel(k).padEnd(24)}${clean(v)}`);
|
|
109
|
+
}
|
|
71
110
|
}
|
|
72
111
|
if (unmapped.length) {
|
|
73
112
|
out.push("", `! These ${clean(label)} statuses weren't mapped, so their issues landed in todo:`, ` ${unmapped.map((u) => clean(u)).join(", ")}`, " They appeared after you set the mapping. Re-import to place them.");
|
package/dist/config.js
CHANGED
|
@@ -68,6 +68,12 @@ export function loadConfig() {
|
|
|
68
68
|
// "never asked", so a mangled file leads to a question rather than to an
|
|
69
69
|
// install nobody agreed to.
|
|
70
70
|
globalInstall: typeof stored.globalInstall === "boolean" ? stored.globalInstall : undefined,
|
|
71
|
+
// Same rule again (RTSC-520): a hand-edited or mangled value reads as "never shown".
|
|
72
|
+
// Finite, because `NaN`/`Infinity` survive JSON round-trips through some editors and
|
|
73
|
+
// would make the once-a-day gate either permanently open or permanently shut.
|
|
74
|
+
updateNoticeAt: typeof stored.updateNoticeAt === "number" && Number.isFinite(stored.updateNoticeAt)
|
|
75
|
+
? stored.updateNoticeAt
|
|
76
|
+
: undefined,
|
|
71
77
|
};
|
|
72
78
|
}
|
|
73
79
|
/** Move a corrupt config aside to a unique sibling so a human can recover any
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import { issueShowAction, issueListAction, checkpointAction, checkClaimAction, }
|
|
|
21
21
|
import { runProxy } from "./proxy.js";
|
|
22
22
|
import { deviceLogin } from "./auth.js";
|
|
23
23
|
import { api, formatError } from "./api.js";
|
|
24
|
+
import { maybeOfferUpdate } from "./lib/updateNotice.js";
|
|
24
25
|
const program = new Command();
|
|
25
26
|
program
|
|
26
27
|
.name("retasc")
|
|
@@ -111,7 +112,7 @@ program
|
|
|
111
112
|
const msg = String(e?.message ?? e).replace(/[\x00-\x1f\x7f]/g, " ");
|
|
112
113
|
// Unreachable ≠ rejected: don't imply a bad key over a dead network.
|
|
113
114
|
if (isNetworkError(e))
|
|
114
|
-
console.log(`This folder → binding present, but
|
|
115
|
+
console.log(`This folder → binding present, but no usable answer came back: ${msg}\n`);
|
|
115
116
|
else
|
|
116
117
|
console.log(`This folder → bound, but the key did not resolve: ${msg}\n`);
|
|
117
118
|
}
|
|
@@ -677,4 +678,13 @@ program
|
|
|
677
678
|
console.log(`mcp url: ${cfg.mcpUrl}`);
|
|
678
679
|
console.log(`signed in: ${isLoggedIn(cfg) ? `yes${cfg.user?.name ? ` (${cfg.user.name})` : ""}` : "no"}`);
|
|
679
680
|
});
|
|
681
|
+
// RTSC-520 — after the command, never during it. A postAction hook is the only place the
|
|
682
|
+
// notice can go without interleaving with a command's own output or with a prompt it is in
|
|
683
|
+
// the middle of asking, and it fires for whatever ran, so nothing has to remember to call it.
|
|
684
|
+
// `maybeOfferUpdate` stays silent unless the command actually talked to the backend and the
|
|
685
|
+
// build is genuinely behind; it never throws, because a notice must not fail a command that
|
|
686
|
+
// already succeeded.
|
|
687
|
+
program.hook("postAction", async () => {
|
|
688
|
+
await maybeOfferUpdate();
|
|
689
|
+
});
|
|
680
690
|
program.parseAsync(process.argv);
|
package/dist/lib/binding.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync, existsSync, realpathSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { getBinding } from "./keystore.js";
|
|
5
|
+
import { parseToolResult } from "./toolresult.js";
|
|
5
6
|
/** Claude Code's config file. RETASC_CLAUDE_CONFIG overrides it (tests). */
|
|
6
7
|
export function claudeConfigPath() {
|
|
7
8
|
return process.env.RETASC_CLAUDE_CONFIG || join(homedir(), ".claude.json");
|
|
@@ -123,6 +124,18 @@ export function claudeLocalRetascEntry(dir) {
|
|
|
123
124
|
export function bindingRefused(e) {
|
|
124
125
|
return /\bUNAUTHORIZED\b/i.test(String(e?.message ?? e));
|
|
125
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* A failure that says nothing about the key: see `isNetworkError`.
|
|
129
|
+
*
|
|
130
|
+
* Every caller of `resolveBinding` splits its failures two ways, and the split decides
|
|
131
|
+
* what the human is told to do. These three are neither a refusal nor a dead network,
|
|
132
|
+
* so they are tagged explicitly instead of being classified by accident.
|
|
133
|
+
*/
|
|
134
|
+
function noVerdict(message) {
|
|
135
|
+
const e = new Error(message);
|
|
136
|
+
e.retascNoVerdict = true;
|
|
137
|
+
return e;
|
|
138
|
+
}
|
|
126
139
|
/** Read the Retasc binding a folder's agent actually uses, from either legal
|
|
127
140
|
* location — claude-local first, matching Claude Code's runtime precedence. */
|
|
128
141
|
export function readLocalBinding(dir) {
|
|
@@ -217,6 +230,16 @@ export function sameIdentity(a, b) {
|
|
|
217
230
|
* advice differs: "check your network / try again" vs "re-run retasc bind".
|
|
218
231
|
* A 4xx (other than 429) IS a verdict: the server saw the key and said no. */
|
|
219
232
|
export function isNetworkError(e) {
|
|
233
|
+
// Tagged rather than matched on its prose. `noVerdict` marks the failures where
|
|
234
|
+
// something answered but not Retasc, or Retasc answered in a shape we could not
|
|
235
|
+
// read: the server passed no judgement on the key either way. Without this they
|
|
236
|
+
// fall through to the 4xx branch and `doctor` says "key not accepted, re-run
|
|
237
|
+
// `retasc bind`" at somebody whose real problem is a proxy, which is both wrong
|
|
238
|
+
// and the most expensive wrong answer available (a re-bind mints a new key).
|
|
239
|
+
// A FLAG, not a regex, because this file's own comment is right that matching
|
|
240
|
+
// prose is fragile, and these strings are copy that will be reworded.
|
|
241
|
+
if (e?.retascNoVerdict === true)
|
|
242
|
+
return true;
|
|
220
243
|
const name = String(e?.name ?? "");
|
|
221
244
|
if (name === "TimeoutError" || name === "AbortError")
|
|
222
245
|
return true;
|
|
@@ -248,7 +271,17 @@ export async function resolveBinding(url, key) {
|
|
|
248
271
|
});
|
|
249
272
|
if (!res.ok)
|
|
250
273
|
throw new Error(`MCP server returned ${res.status}`);
|
|
251
|
-
|
|
274
|
+
// `res.json()` used to be called straight, so a 200 carrying a proxy's error page
|
|
275
|
+
// (or a captive portal's sign-in form) failed with a JSON parser's complaint about
|
|
276
|
+
// a `<`. Read the text and say what actually happened instead.
|
|
277
|
+
const raw = await res.text();
|
|
278
|
+
let body;
|
|
279
|
+
try {
|
|
280
|
+
body = raw ? JSON.parse(raw) : null;
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
throw noVerdict("a proxy or a VPN sign-in page answered instead of Retasc");
|
|
284
|
+
}
|
|
252
285
|
if (body?.error)
|
|
253
286
|
throw new Error(body.error.message ?? "MCP error");
|
|
254
287
|
const result = body?.result;
|
|
@@ -256,6 +289,16 @@ export async function resolveBinding(url, key) {
|
|
|
256
289
|
throw new Error(result?.content?.[0]?.text ?? "key not accepted");
|
|
257
290
|
const text = result?.content?.[0]?.text;
|
|
258
291
|
if (!text)
|
|
259
|
-
throw
|
|
260
|
-
|
|
292
|
+
throw noVerdict("the server said nothing at all about this key");
|
|
293
|
+
// Tolerant, and never a bare JSON.parse. This message is printed by `whoami` and
|
|
294
|
+
// `doctor` — the two commands someone runs when things are already wrong — so a
|
|
295
|
+
// "SyntaxError: Unexpected token … in JSON at position 42" here would answer a
|
|
296
|
+
// person's "what is broken?" with a parser's internal state. The shared parser also
|
|
297
|
+
// recovers a payload that arrived with prose after it (RTSC-142/143), which is the
|
|
298
|
+
// regression that made this line worth hardening in the first place.
|
|
299
|
+
const parsed = parseToolResult(body);
|
|
300
|
+
if (parsed.kind === "raw") {
|
|
301
|
+
throw noVerdict("the server's answer wasn't in a shape this CLI could read");
|
|
302
|
+
}
|
|
303
|
+
return parsed.value;
|
|
261
304
|
}
|
package/dist/lib/claim.js
CHANGED
|
@@ -6,6 +6,7 @@ import { readFileSync, existsSync } from "node:fs";
|
|
|
6
6
|
import { join, resolve } from "node:path";
|
|
7
7
|
import { claudeLocalRetascEntry } from "./binding.js";
|
|
8
8
|
import { resolveConn } from "./keystore.js";
|
|
9
|
+
import { clean } from "./text.js";
|
|
9
10
|
// Tool-payload extraction lives in the shared tolerant parser (RTSC-143);
|
|
10
11
|
// re-exported so existing importers of this module keep working.
|
|
11
12
|
import { toolResult } from "./toolresult.js";
|
|
@@ -125,6 +126,63 @@ export function parseClaimResult(result) {
|
|
|
125
126
|
const title = typeof r.issue === "object" ? r.issue?.title : undefined;
|
|
126
127
|
return { issueId, title, claimToken: r.claimToken };
|
|
127
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* A sentence a PERSON can act on for a non-2xx from the MCP endpoint.
|
|
131
|
+
*
|
|
132
|
+
* This path used to paste 200 characters of the raw body after the status, and the raw
|
|
133
|
+
* body is the one shape a reader cannot use: from Convex it is a JSON-RPC envelope, and
|
|
134
|
+
* when the failure happens at Cloudflare instead of in Convex it is a page of HTML. Both
|
|
135
|
+
* land in the terminal of somebody who typed `retasc claim`, and neither says what to do.
|
|
136
|
+
*
|
|
137
|
+
* So the body is never printed. It is READ — for a message, if it carries one — and what
|
|
138
|
+
* gets shown is what the status means, in words, plus the fix where there is one. The
|
|
139
|
+
* status is kept, in parentheses, because it is the thing worth quoting in a bug report.
|
|
140
|
+
*
|
|
141
|
+
* Pure, and exported, so the wording is pinned by a test rather than by a live 401.
|
|
142
|
+
*/
|
|
143
|
+
export function mcpFailureMessage(status, body = "", url) {
|
|
144
|
+
const said = bodyMessage(body);
|
|
145
|
+
const why = status === 401 || status === 403
|
|
146
|
+
? "Retasc refused this folder's key. Re-run `retasc bind` here to mint a fresh one."
|
|
147
|
+
: status === 404
|
|
148
|
+
? `Nothing is serving Retasc at ${url ? clean(url) : "that address"}. Check this folder's MCP url, or re-run \`retasc bind\`.`
|
|
149
|
+
: status === 429
|
|
150
|
+
? "Retasc is rate-limiting this key. Wait a moment, then try again."
|
|
151
|
+
: status >= 500
|
|
152
|
+
? "Retasc's server had a problem. Nothing was claimed or changed — try again in a minute."
|
|
153
|
+
: "Retasc refused the request.";
|
|
154
|
+
return `${why} (HTTP ${status}${said ? `: ${said}` : ""})`;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The one sentence inside an error body, or nothing.
|
|
158
|
+
*
|
|
159
|
+
* Only ever returns text the server MEANT as a message — a JSON-RPC `error.message`, a
|
|
160
|
+
* tool result's text, a bare `message` field. Anything else (HTML, a payload with no
|
|
161
|
+
* message in it, a body that is not JSON at all) returns undefined, so the caller says
|
|
162
|
+
* its own sentence rather than quoting a structure at the reader. Control characters are
|
|
163
|
+
* stripped and it is capped at one line: this reaches a terminal that acts on escapes.
|
|
164
|
+
*/
|
|
165
|
+
function bodyMessage(body) {
|
|
166
|
+
if (!/^\s*[{[]/.test(body))
|
|
167
|
+
return undefined;
|
|
168
|
+
let parsed;
|
|
169
|
+
try {
|
|
170
|
+
parsed = JSON.parse(body);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
const said = [parsed?.error?.message, parsed?.result?.content?.[0]?.text, parsed?.message].find((c) => typeof c === "string" && c.trim());
|
|
176
|
+
if (!said)
|
|
177
|
+
return undefined;
|
|
178
|
+
const line = clean(said).split("\n")[0].trim();
|
|
179
|
+
// A "message" that is itself a payload is the very thing this function exists to keep
|
|
180
|
+
// off the screen — a server can put its JSON result in `content[0].text`, and quoting
|
|
181
|
+
// that back would reintroduce the blob one layer down.
|
|
182
|
+
if (!line || /^[{[]/.test(line))
|
|
183
|
+
return undefined;
|
|
184
|
+
return line.slice(0, 200);
|
|
185
|
+
}
|
|
128
186
|
/**
|
|
129
187
|
* Call one MCP tool over JSON-RPC with the agent key. Throws on transport errors
|
|
130
188
|
* and on tool errors (isError) — surfacing the server's message (CLAIM_LOST,
|
|
@@ -147,12 +205,23 @@ export async function mcpCall(conn, name, args = {}, fetchImpl = fetch) {
|
|
|
147
205
|
});
|
|
148
206
|
const text = await res.text();
|
|
149
207
|
// A non-2xx (expired/invalid key → 401, server error → 5xx) must not look like
|
|
150
|
-
// an empty queue
|
|
151
|
-
if (!res.ok)
|
|
152
|
-
|
|
153
|
-
|
|
208
|
+
// an empty queue — say what happened, in words. See mcpFailureMessage.
|
|
209
|
+
if (!res.ok)
|
|
210
|
+
throw new Error(mcpFailureMessage(res.status, text, conn.url));
|
|
211
|
+
// A 200 whose body is not JSON is a captive portal, a proxy error page, or a
|
|
212
|
+
// deployment answering on the wrong route. Letting JSON.parse throw puts
|
|
213
|
+
// "Unexpected token < in JSON at position 0" in front of a person, which names
|
|
214
|
+
// neither the problem nor the fix.
|
|
215
|
+
let resp = null;
|
|
216
|
+
if (text) {
|
|
217
|
+
try {
|
|
218
|
+
resp = JSON.parse(text);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
throw new Error(`${clean(conn.url)} answered, but not with Retasc's reply — something in between (a proxy, ` +
|
|
222
|
+
`a captive portal, a VPN sign-in page) is intercepting it.`);
|
|
223
|
+
}
|
|
154
224
|
}
|
|
155
|
-
const resp = text ? JSON.parse(text) : null;
|
|
156
225
|
if (resp?.error) {
|
|
157
226
|
throw new Error(resp.error?.message || "MCP transport error");
|
|
158
227
|
}
|
package/dist/lib/launcher.js
CHANGED
|
@@ -168,11 +168,17 @@ export function versionStamp(version, argv1 = process.argv[1] ?? "") {
|
|
|
168
168
|
const how = /[\\/]_npx[\\/]/.test(argv1) ? `npx ${PKG}@${version}` : `${PKG} ${version}`;
|
|
169
169
|
return ` (${how})`;
|
|
170
170
|
}
|
|
171
|
-
/**
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
171
|
+
/**
|
|
172
|
+
* Install (or upgrade to) an exact version globally. Returns null on success, and the
|
|
173
|
+
* first useful line of npm's failure otherwise.
|
|
174
|
+
*
|
|
175
|
+
* Exported since RTSC-520, which upgrades an existing install rather than creating one —
|
|
176
|
+
* same npm invocation, different reason. The "what is happening" line moved OUT to the
|
|
177
|
+
* callers with it: a cold install takes seconds with no output of its own and silence
|
|
178
|
+
* reads as a hang, but the sentence that makes sense mid-`bind` ("so your agent can start
|
|
179
|
+
* it") is the wrong sentence for someone who just answered y to an update prompt.
|
|
180
|
+
*/
|
|
181
|
+
export function installGlobal(version) {
|
|
176
182
|
let r;
|
|
177
183
|
try {
|
|
178
184
|
r = spawnSync("npm", ["install", "-g", `${PKG}@${version}`], {
|
|
@@ -228,7 +234,9 @@ export function resolveLauncher(opts) {
|
|
|
228
234
|
});
|
|
229
235
|
if (opts.install === false)
|
|
230
236
|
return npxFallback("install not attempted");
|
|
231
|
-
// 2. Try to make `retasc` real.
|
|
237
|
+
// 2. Try to make `retasc` real. Say so first — a cold install takes seconds in silence
|
|
238
|
+
// and this one lands in the middle of a bind, where silence reads as a hang.
|
|
239
|
+
console.log(" Installing the retasc CLI so your agent can start it…");
|
|
232
240
|
const failure = installGlobal(opts.version);
|
|
233
241
|
// 3. Prove it, by running it. An exit code of 0 is not evidence the command resolves:
|
|
234
242
|
// npm can install happily into a prefix whose bin directory PATH never searches.
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// "You are running an old build" — the one thing the CLI never said (RTSC-520).
|
|
2
|
+
//
|
|
3
|
+
// A stale install is not merely missing features. CLI copy describes SERVER behaviour,
|
|
4
|
+
// so an old build makes false statements about the account in front of it: 1.11.0 told
|
|
5
|
+
// people a decline "covers the whole org, including later migrations" long after
|
|
6
|
+
// RTSC-507 made that untrue, and 1.10.0 had no Google door at all, so it sent a
|
|
7
|
+
// Google-only human through GitHub and minted them a second identity. Both are worse
|
|
8
|
+
// than a missing feature, because nothing on screen hints the sentence is out of date.
|
|
9
|
+
//
|
|
10
|
+
// The stance, and it is deliberate: NOTIFY, THEN ASK. Never a silent `npm i -g`. That
|
|
11
|
+
// mutates someone's machine unasked, fails on a root-owned prefix, and can swap the
|
|
12
|
+
// binary under a running agent. `resolveLauncher` already refuses to surprise-install
|
|
13
|
+
// for someone who manages their own install; this must not undercut it.
|
|
14
|
+
//
|
|
15
|
+
// Four rules fall out of that, and every one of them is a test below:
|
|
16
|
+
// - Interactive only. No TTY ⇒ the notice, never the prompt and never the install.
|
|
17
|
+
// - Never in the MCP proxy. An upgrade nag inside an agent's tool results is noise in
|
|
18
|
+
// a machine channel. Structurally impossible here: the signal is only ever recorded
|
|
19
|
+
// by `api.me()`, and the proxy does not import api.js.
|
|
20
|
+
// - At most once a day, stamped in ~/.retasc/config.json. Declining must not re-ask on
|
|
21
|
+
// the next command.
|
|
22
|
+
// - A failed install is NOT a failed command. EACCES is the common one. Report it, name
|
|
23
|
+
// the manual command, and carry on with whatever the human actually ran.
|
|
24
|
+
//
|
|
25
|
+
// Everything goes to STDERR. `retasc issue list --json | jq` must keep working byte for
|
|
26
|
+
// byte, and a notice on stdout would break exactly the scripted callers the issue
|
|
27
|
+
// promises not to disturb.
|
|
28
|
+
import { VERSION } from "../version.js";
|
|
29
|
+
import { loadConfig, patchConfig } from "../config.js";
|
|
30
|
+
import { confirm, isInteractive } from "./prompt.js";
|
|
31
|
+
import { installGlobal } from "./launcher.js";
|
|
32
|
+
const PKG = "@retasc/cli";
|
|
33
|
+
export const MANUAL_INSTALL = `npm i -g ${PKG}@latest`;
|
|
34
|
+
/** Once a day. The notice is worth reading; a notice on every command is wallpaper. */
|
|
35
|
+
export const NOTICE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
36
|
+
/**
|
|
37
|
+
* The signal from the most recent `me()` this process made.
|
|
38
|
+
*
|
|
39
|
+
* A module slot rather than a parameter because the notice fires from a postAction hook
|
|
40
|
+
* that has no idea which commands talked to the backend. This is also what scopes the
|
|
41
|
+
* feature correctly for free: only a login-gated command calls `me()`, so only a
|
|
42
|
+
* login-gated command can ever have something to say.
|
|
43
|
+
*/
|
|
44
|
+
let recorded;
|
|
45
|
+
/**
|
|
46
|
+
* A published version and nothing else: three numbers, optionally a prerelease tag.
|
|
47
|
+
*
|
|
48
|
+
* Validated HERE as well as on the server, and not out of politeness. These strings do two
|
|
49
|
+
* dangerous things: they are printed to a terminal, and `latest` is handed to `spawnSync`
|
|
50
|
+
* as the version half of `@retasc/cli@<v>` — which on Windows runs through a shell. Trusting
|
|
51
|
+
* the server for the CLI's own subprocess and terminal safety is the wrong shape of trust;
|
|
52
|
+
* `formatError` already refuses it for backend error strings, for the same reason.
|
|
53
|
+
*/
|
|
54
|
+
const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
55
|
+
const clean = (v) => typeof v === "string" && VERSION_RE.test(v.trim()) ? v.trim() : null;
|
|
56
|
+
export function recordRelease(signal) {
|
|
57
|
+
if (!signal || typeof signal !== "object")
|
|
58
|
+
return;
|
|
59
|
+
const { latest, minHonest } = signal;
|
|
60
|
+
recorded = { latest: clean(latest), minHonest: clean(minHonest) };
|
|
61
|
+
}
|
|
62
|
+
export function releaseSignal() {
|
|
63
|
+
return recorded;
|
|
64
|
+
}
|
|
65
|
+
/** Test seam only — the slot is process-global by design. */
|
|
66
|
+
export function resetRelease() {
|
|
67
|
+
recorded = undefined;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Compare two versions: negative if a < b, 0 if equal, positive if a > b.
|
|
71
|
+
*
|
|
72
|
+
* Deliberately small. npm's `latest` dist-tag is a release, and this only ever compares
|
|
73
|
+
* against it, so full semver precedence would be dead code — but a PRERELEASE has to
|
|
74
|
+
* sort below its own release (`1.36.0-rc.1` < `1.36.0`) or someone testing an rc would
|
|
75
|
+
* be told they are ahead of the world and never hear about the real 1.36.0.
|
|
76
|
+
* Unparseable input answers 0, which reads as "no opinion" and keeps us silent.
|
|
77
|
+
*/
|
|
78
|
+
export function compareVersions(a, b) {
|
|
79
|
+
const parse = (v) => {
|
|
80
|
+
const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(v.trim());
|
|
81
|
+
return m ? { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] } : null;
|
|
82
|
+
};
|
|
83
|
+
const x = parse(a);
|
|
84
|
+
const y = parse(b);
|
|
85
|
+
if (!x || !y)
|
|
86
|
+
return 0;
|
|
87
|
+
for (let i = 0; i < 3; i++)
|
|
88
|
+
if (x.nums[i] !== y.nums[i])
|
|
89
|
+
return x.nums[i] - y.nums[i];
|
|
90
|
+
if (x.pre === y.pre)
|
|
91
|
+
return 0;
|
|
92
|
+
if (x.pre === undefined)
|
|
93
|
+
return 1; // a release outranks any prerelease of itself
|
|
94
|
+
if (y.pre === undefined)
|
|
95
|
+
return -1;
|
|
96
|
+
return x.pre < y.pre ? -1 : 1;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The whole decision, with every edge injected so the interesting paths — no TTY, a
|
|
100
|
+
* root-owned prefix, a deployment too old to send the field — can be tested without
|
|
101
|
+
* owning a terminal, an npm prefix, or a backend.
|
|
102
|
+
*/
|
|
103
|
+
export async function offerUpdate(deps) {
|
|
104
|
+
const signal = deps.signal();
|
|
105
|
+
const latest = signal?.latest;
|
|
106
|
+
// No signal at all is the normal case for an old deployment or a cold cache, and it
|
|
107
|
+
// means exactly one thing: we do not know, so we do not speak.
|
|
108
|
+
if (!latest)
|
|
109
|
+
return "silent";
|
|
110
|
+
if (compareVersions(deps.version, latest) >= 0)
|
|
111
|
+
return "silent";
|
|
112
|
+
const now = deps.now();
|
|
113
|
+
const last = deps.lastNoticeAt();
|
|
114
|
+
if (last !== undefined && now - last < NOTICE_INTERVAL_MS)
|
|
115
|
+
return "silent";
|
|
116
|
+
// Stamp on PRINT, not on answer: someone who declines and someone who ignores the line
|
|
117
|
+
// have both been told today, and re-asking either tomorrow-minus-one-command is the
|
|
118
|
+
// nagging this gate exists to prevent. Stamped before the prompt so a Ctrl-C mid-question
|
|
119
|
+
// doesn't reset the clock either.
|
|
120
|
+
deps.stampNotice(now);
|
|
121
|
+
// Two different problems, said differently. "Newer exists" is an invitation; "your copy
|
|
122
|
+
// no longer matches the server" is a warning that what they just read may be false.
|
|
123
|
+
const dishonest = signal?.minHonest ? compareVersions(deps.version, signal.minHonest) < 0 : false;
|
|
124
|
+
if (dishonest) {
|
|
125
|
+
deps.out(`This retasc is ${deps.version}; ${latest} is published.`);
|
|
126
|
+
deps.out(" Its wording no longer matches how the server behaves, so what it tells you about your account may be out of date.");
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
deps.out(`A newer retasc is available (${latest}, you have ${deps.version}).`);
|
|
130
|
+
}
|
|
131
|
+
// No TTY: the notice is the whole of it. A scripted run keeps behaving as it does today.
|
|
132
|
+
if (!deps.interactive()) {
|
|
133
|
+
deps.out(` To update: ${MANUAL_INSTALL}`);
|
|
134
|
+
return "noticed";
|
|
135
|
+
}
|
|
136
|
+
if (!(await deps.ask("Update now?"))) {
|
|
137
|
+
deps.out(` Left alone. To update later: ${MANUAL_INSTALL}`);
|
|
138
|
+
return "declined";
|
|
139
|
+
}
|
|
140
|
+
deps.out(` Updating to ${latest}…`);
|
|
141
|
+
const failure = deps.install(latest);
|
|
142
|
+
if (failure) {
|
|
143
|
+
// NOT a failed command. Whatever they actually ran already succeeded above this line.
|
|
144
|
+
deps.out(` Couldn't update (${failure}).`);
|
|
145
|
+
deps.out(` To do it by hand: ${MANUAL_INSTALL}`);
|
|
146
|
+
return "install-failed";
|
|
147
|
+
}
|
|
148
|
+
// Say where it took effect, or the next thing they see will look like it didn't work:
|
|
149
|
+
// this process is still the OLD build, and every already-running agent is still pointed
|
|
150
|
+
// at the old MCP proxy. Same beat RTSC-519 added to `bind`.
|
|
151
|
+
deps.out(` ✓ Updated to ${latest}. It applies from your next command — restart your agents to pick up the new MCP server.`);
|
|
152
|
+
return "installed";
|
|
153
|
+
}
|
|
154
|
+
/** The real-deps entry point. Never throws: a notice must not be able to fail a command. */
|
|
155
|
+
export async function maybeOfferUpdate() {
|
|
156
|
+
try {
|
|
157
|
+
return await offerUpdate({
|
|
158
|
+
version: VERSION,
|
|
159
|
+
signal: releaseSignal,
|
|
160
|
+
interactive: isInteractive,
|
|
161
|
+
lastNoticeAt: () => loadConfig().updateNoticeAt,
|
|
162
|
+
stampNotice: (at) => patchConfig({ updateNoticeAt: at }),
|
|
163
|
+
ask: (q) => confirm(q),
|
|
164
|
+
install: installGlobal,
|
|
165
|
+
out: (line) => console.error(line),
|
|
166
|
+
now: () => Date.now(),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return "silent";
|
|
171
|
+
}
|
|
172
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retasc/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.36.0",
|
|
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": {
|