cortad 0.3.6 → 0.3.8
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/lib/reach.mjs +1 -1
- package/lib/verbs.mjs +2 -2
- package/local.mjs +39 -7
- package/package.json +1 -1
- package/skill/SKILL.md +7 -0
package/lib/reach.mjs
CHANGED
|
@@ -21,7 +21,7 @@ const appUrl = (runner) => new URL(`http://${runner.host === "::1" ? "[::1]" : r
|
|
|
21
21
|
// Why nothing can be sent to the app this record names, or null.
|
|
22
22
|
export function notUp(runner) {
|
|
23
23
|
if (runner?.state !== "up") {
|
|
24
|
-
return !runner ? "No connect command is running for this repository, so no app is up on this machine."
|
|
24
|
+
return !runner ? "No connect command is running for this repository, so no app is up on this machine. Run npx cortad in this folder as a background command to connect it again."
|
|
25
25
|
: runner.state === "failed" ? upper(runner.error) : "Your app is still starting.";
|
|
26
26
|
}
|
|
27
27
|
const host = runner.host ?? "127.0.0.1";
|
package/lib/verbs.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { fieldConnectText, fieldText, findingsText, finished, linesOf, notStarte
|
|
|
16
16
|
// the run before them has its numbers in and its job is still ending: the server holds them until it
|
|
17
17
|
// has, up to the same 45 seconds.
|
|
18
18
|
|
|
19
|
-
const NOT_CONNECTED = "This folder is not connected to Cortad.\nFor the person:
|
|
19
|
+
const NOT_CONNECTED = "This folder is not connected to Cortad. Run npx cortad in this folder as a background command: it prints a link for the person to approve.\nFor the person: approve the link npx cortad prints, in a browser where you are signed in to Cortad.";
|
|
20
20
|
const HOLD_S = 45;
|
|
21
21
|
// `wait`: how often it asks whether a run has started, how long the connect command may be gone
|
|
22
22
|
// before no run can start, and how long it waits for one at all.
|
|
@@ -204,7 +204,7 @@ export function makeVerbs({ api, token, runner = () => null, fetchImpl = fetch,
|
|
|
204
204
|
let goneAt = null;
|
|
205
205
|
for (;;) {
|
|
206
206
|
if (runner()) goneAt = null;
|
|
207
|
-
else if (now() - (goneAt ??= now()) >= GONE_MS) return { text: "The connect command stopped, so no run can start from the browser
|
|
207
|
+
else if (now() - (goneAt ??= now()) >= GONE_MS) return { text: "The connect command stopped, so no run can start from the browser. Run npx cortad in this folder again as a background command: it reconnects with the key it kept.\nnext: status", isError: true };
|
|
208
208
|
if (now() - began >= IDLE_MS) return { text: `No run ended in 6 hours.\nnext: npx -y ${spec} wait` };
|
|
209
209
|
const res = id ? await call("GET", `/mcp/run/${encodeURIComponent(id)}?wait=${HOLD_S}`, undefined, (HOLD_S + 13) * 1000) : await call("GET", "/mcp/status?quiet=1");
|
|
210
210
|
// A key that no longer opens this repository, or a run that is gone, does not come back by asking again.
|
package/local.mjs
CHANGED
|
@@ -61,7 +61,7 @@ if (unknownVerb(argv[0])) {
|
|
|
61
61
|
const flag = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
|
|
62
62
|
const verbose = argv.includes("--verbose");
|
|
63
63
|
// A code is eight characters from the connect screen's alphabet, which has no I, O, 0 or 1.
|
|
64
|
-
|
|
64
|
+
let code = (argv.find((a) => /^[A-HJ-NP-Za-hj-np-z2-9-]{8,9}$/.test(a) && !a.startsWith("-")) ?? "").toUpperCase().replace(/-/g, "");
|
|
65
65
|
const say = (line) => console.log(`cortad ${line}`);
|
|
66
66
|
const fail = (line) => { console.error(`cortad ${line}`); process.exit(1); };
|
|
67
67
|
|
|
@@ -82,7 +82,10 @@ const step = (line) => {
|
|
|
82
82
|
};
|
|
83
83
|
const clearStep = () => { if (process.stdout.isTTY) process.stdout.write("\r\x1b[K"); };
|
|
84
84
|
const stepDone = (line) => { clearStep(); say(line); };
|
|
85
|
-
|
|
85
|
+
// `npx cortad` alone connects too: with the key this folder kept from its last connect, or, with none,
|
|
86
|
+
// by one approval in the browser (pairing below). A word that is neither a code nor a flag is refused.
|
|
87
|
+
const words = argv.filter((a, i) => !a.startsWith("-") && !["--port", "--start"].includes(argv[i - 1] ?? ""));
|
|
88
|
+
if (!explain && !viaToken && words.length && !/^[A-Z0-9]{8}$/.test(code)) fail(`usage: npx cortad <code from the connect screen> [--port N] [--start \"cmd\"] [--proxy] | npx cortad --explain | npx cortad ${[...VERBS, "wait"].join(" | ")}`);
|
|
86
89
|
// Where Brainsless is. The host is not on the command line: a code cannot point at an impostor.
|
|
87
90
|
const origin = new URL(process.env.CORTAD_ORIGIN || "https://cortad.com");
|
|
88
91
|
// Ours, and only ours. brainsless.com is the same service under its earlier name and stays trusted
|
|
@@ -300,6 +303,27 @@ const mask = (text) => maskWith(String(text ?? ""), secrets);
|
|
|
300
303
|
// ---- the wire
|
|
301
304
|
let box = "";
|
|
302
305
|
let key = "";
|
|
306
|
+
// The browser approves this computer: a link to open where the person is signed in, and four
|
|
307
|
+
// characters the page shows too. The terminal waits, and is handed a code once it is approved.
|
|
308
|
+
async function paired() {
|
|
309
|
+
const machine = hostname().replace(/[^\w.-]/g, "-").slice(0, 80);
|
|
310
|
+
const opened = await call("POST", "/local/pair", { name: basename(root).replace(/[^\w.-]/g, "-").slice(0, 80), machine }).catch(unreachable);
|
|
311
|
+
if (!opened.ok) fail(opened.data?.error ?? `could not start connecting (${opened.status})`);
|
|
312
|
+
const link = `${origin.origin}/approve/${opened.data.id}`;
|
|
313
|
+
say(`approve this computer in your browser, where you are signed in to Cortad: ${link}`);
|
|
314
|
+
say(`the page shows ${opened.data.words} and ${machine}; approve only if both match`);
|
|
315
|
+
if (process.stdout.isTTY) {
|
|
316
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
|
|
317
|
+
try { spawn(opener, [link], { stdio: "ignore", detached: true }).on("error", () => {}).unref(); } catch { /* the link is printed */ }
|
|
318
|
+
}
|
|
319
|
+
for (const until = Date.now() + 10 * 60_000; Date.now() < until; await new Promise((r) => setTimeout(r, 2_000))) {
|
|
320
|
+
const got = await call("POST", `/local/pair/${opened.data.id}/collect`, { secret: opened.data.secret }).catch(() => null);
|
|
321
|
+
if (got?.ok && typeof got.data?.code === "string") { say("approved"); return got.data.code; }
|
|
322
|
+
if (got && got.status === 410) break;
|
|
323
|
+
}
|
|
324
|
+
fail("the link was not approved within ten minutes. Run npx cortad again for a new one.");
|
|
325
|
+
}
|
|
326
|
+
|
|
303
327
|
async function call(method, path, body, { raw = false, timeoutMs = 60_000, headers = {} } = {}) {
|
|
304
328
|
const res = await fetch(`${api}${path}`, {
|
|
305
329
|
method,
|
|
@@ -1188,15 +1212,21 @@ if (!files.length) fail("no source files here to read.");
|
|
|
1188
1212
|
try { copyFileSync(join(homeOf(project), "tools.json"), join(work, "tools.json")); } catch { /* no run has read this project's tools yet */ }
|
|
1189
1213
|
// The key the last connect left for this project, if any: the token face signs in with it. A connect
|
|
1190
1214
|
// from the screen always asks for a fresh one, since the code may belong to another account or site.
|
|
1191
|
-
|
|
1192
|
-
if (viaToken && !stored) fail("this project has no stored key. Run
|
|
1215
|
+
let stored = readToken(project);
|
|
1216
|
+
if (viaToken && !stored) fail("this project has no stored key. Run npx cortad in this folder once.");
|
|
1193
1217
|
// A network that drops while connecting ends here in a sentence, never a stack trace: running the
|
|
1194
1218
|
// command again starts a clean connection.
|
|
1195
1219
|
const unreachable = (err) => fail(`could not reach ${origin.host}: ${err?.name === "TimeoutError" ? "it did not answer in time" : "the connection failed"}. Check your connection and run the command again.`);
|
|
1196
1220
|
// The key goes up beside a code too: a code that has lapsed while this project's key still holds is
|
|
1197
1221
|
// the same person coming back, and the server signs them in by the key instead of refusing.
|
|
1198
|
-
|
|
1222
|
+
let attach = !code && !viaToken && !stored ? null : await call("POST", "/local/attach", { ...(viaToken ? {} : { code }), name: basename(root), project },
|
|
1199
1223
|
stored ? { headers: { authorization: `Bearer ${stored}` } } : {}).catch(unreachable);
|
|
1224
|
+
// No code and no key that still signs in: the person approves this computer in their browser.
|
|
1225
|
+
if (!viaToken && !code && !attach?.ok) {
|
|
1226
|
+
if (attach && stored) { say("the key this folder kept no longer signs in, so this computer is connected again"); stored = null; }
|
|
1227
|
+
code = await paired();
|
|
1228
|
+
attach = await call("POST", "/local/attach", { code, name: basename(root), project }).catch(unreachable);
|
|
1229
|
+
}
|
|
1200
1230
|
if (!attach.ok) fail(attach.data?.error ?? `could not sign in (${attach.status})`);
|
|
1201
1231
|
box = attach.data.box;
|
|
1202
1232
|
key = attach.data.key;
|
|
@@ -1242,9 +1272,11 @@ for (let off = 0; off < bytes.length; off += PART) {
|
|
|
1242
1272
|
const last = off + PART >= bytes.length;
|
|
1243
1273
|
step(parts > 1 ? `connecting, ${Math.floor(off / PART) + 1} of ${parts}` : "connecting");
|
|
1244
1274
|
// The last part asks for this machine's key when none is stored yet: minted once the repository
|
|
1245
|
-
// row exists, kept in ~/.cortad for the runs a coding agent asks for on later days.
|
|
1275
|
+
// row exists, kept in ~/.cortad for the runs a coding agent asks for on later days. A connect by a
|
|
1276
|
+
// code asks too, since the code may be another account's: the key kept from the account before
|
|
1277
|
+
// signed the coding agent's tools into that one, its runs and its counts.
|
|
1246
1278
|
const put = await call("PUT", `/local/${box}/tree?last=${last ? 1 : 0}${last ? `&digest=${treeDigest}${head ? `&head=${head}` : ""}` : ""}`, bytes.subarray(off, off + PART),
|
|
1247
|
-
{ raw: true, timeoutMs: 120_000, ...(last && !viaToken ? { headers: { "x-cortad-machine": hostname().slice(0, 80) } } : {}) }).catch(unreachable);
|
|
1279
|
+
{ raw: true, timeoutMs: 120_000, ...(last && !viaToken && (!stored || code) ? { headers: { "x-cortad-machine": hostname().slice(0, 80) } } : {}) }).catch(unreachable);
|
|
1248
1280
|
if (!put.ok) fail(put.data?.error ?? `upload failed (${put.status})`);
|
|
1249
1281
|
if (last) {
|
|
1250
1282
|
resumed = put.data?.resumed === true;
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -18,6 +18,13 @@ The tools are the `cortad` MCP tools. Where they are missing, each one is `npx {
|
|
|
18
18
|
|
|
19
19
|
## A session, start to finish
|
|
20
20
|
|
|
21
|
+
## Connecting
|
|
22
|
+
|
|
23
|
+
`npx {{cortad}}` in the repository's folder connects it, with nothing to copy. Run it as a background command (in Claude Code: Bash with run_in_background) and leave it running: it starts the app and keeps it reachable from the browser until it is stopped.
|
|
24
|
+
- A folder connected before reconnects at once with the key it kept.
|
|
25
|
+
- A folder connected for the first time prints a link and four characters, good for ten minutes. Give the person both as printed; they approve the link in their browser, signed in to Cortad, and the command goes on by itself.
|
|
26
|
+
- When a result says no connect command is running, run `npx {{cortad}}` again in this folder. No code is needed from the person.
|
|
27
|
+
|
|
21
28
|
The connect command prints "Go back to the browser" once it is connected. Cortad then finds the AI endpoints in the app's code and sends each one a test request itself, from this machine to the app on its port.
|
|
22
29
|
|
|
23
30
|
1. Call `status` and tell the person, in a few plain lines:
|