@snowyroad/braid 0.72.0 → 0.73.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.
|
@@ -365,8 +365,14 @@ function decodeInvite(code) {
|
|
|
365
365
|
}
|
|
366
366
|
|
|
367
367
|
// src/redeem.ts
|
|
368
|
+
var InviteExpiredError = class extends Error {
|
|
369
|
+
constructor() {
|
|
370
|
+
super("This invite has expired or been revoked. Ask an admin to mint a new one.");
|
|
371
|
+
this.name = "InviteExpiredError";
|
|
372
|
+
}
|
|
373
|
+
};
|
|
368
374
|
async function redeemInvite(relayHttpUrl, code) {
|
|
369
|
-
const { boundedJsonFetch: boundedJsonFetch2 } = await import("./http-
|
|
375
|
+
const { boundedJsonFetch: boundedJsonFetch2 } = await import("./http-Y62OCD7R.js");
|
|
370
376
|
const r = await boundedJsonFetch2(`${relayHttpUrl.replace(/\/$/, "")}/invites/redeem`, {
|
|
371
377
|
method: "POST",
|
|
372
378
|
headers: { "content-type": "application/json" },
|
|
@@ -375,7 +381,7 @@ async function redeemInvite(relayHttpUrl, code) {
|
|
|
375
381
|
});
|
|
376
382
|
if (!r.ok) {
|
|
377
383
|
if (r.category === "http" && r.status === 410) {
|
|
378
|
-
throw new
|
|
384
|
+
throw new InviteExpiredError();
|
|
379
385
|
}
|
|
380
386
|
if (r.category === "http") throw new Error(`Invite redemption failed (HTTP ${r.status}).`);
|
|
381
387
|
throw new Error(`Invite redemption failed (${r.category}).`);
|
|
@@ -455,7 +461,7 @@ function validateReplacementToken(newToken, currentToken, nowMs = Date.now()) {
|
|
|
455
461
|
return { ok: true };
|
|
456
462
|
}
|
|
457
463
|
async function mintAccessToken(relayHttpUrl, agentKey, fetchFn = fetch, dpopProof) {
|
|
458
|
-
const { boundedJsonFetch: boundedJsonFetch2 } = await import("./http-
|
|
464
|
+
const { boundedJsonFetch: boundedJsonFetch2 } = await import("./http-Y62OCD7R.js");
|
|
459
465
|
const r = await boundedJsonFetch2(`${relayHttpUrl.replace(/\/$/, "")}/agents/token`, {
|
|
460
466
|
method: "POST",
|
|
461
467
|
headers: {
|
|
@@ -485,6 +491,7 @@ async function mintAccessToken(relayHttpUrl, agentKey, fetchFn = fetch, dpopProo
|
|
|
485
491
|
}
|
|
486
492
|
|
|
487
493
|
// src/exitCodes.ts
|
|
494
|
+
var EXIT_OK = 0;
|
|
488
495
|
var EXIT_NEEDS_HUMAN = 78;
|
|
489
496
|
var EXIT_CRASH = 1;
|
|
490
497
|
var KeystorePermsError = class extends Error {
|
|
@@ -2105,13 +2112,19 @@ export {
|
|
|
2105
2112
|
hasSubscriptionLogin,
|
|
2106
2113
|
effectiveAnthropicCredential,
|
|
2107
2114
|
macKeychainHasClaudeLogin,
|
|
2115
|
+
decodeInvite,
|
|
2108
2116
|
assertHttpEndpoint,
|
|
2109
2117
|
snapshotHttpMetrics,
|
|
2110
2118
|
resetHttpMetricsForTest,
|
|
2111
2119
|
boundedJsonFetch,
|
|
2120
|
+
InviteExpiredError,
|
|
2121
|
+
redeemInvite,
|
|
2112
2122
|
RebootstrapError,
|
|
2113
2123
|
validateReplacementToken,
|
|
2124
|
+
mintAccessToken,
|
|
2125
|
+
EXIT_OK,
|
|
2114
2126
|
EXIT_NEEDS_HUMAN,
|
|
2127
|
+
EXIT_CRASH,
|
|
2115
2128
|
KeystorePermsError,
|
|
2116
2129
|
classifyFatal,
|
|
2117
2130
|
assertKeystorePerms,
|
|
@@ -2138,6 +2151,8 @@ export {
|
|
|
2138
2151
|
verifyCardSignature,
|
|
2139
2152
|
cleanContent,
|
|
2140
2153
|
resolveAgentSelection,
|
|
2154
|
+
validateRelayWsUrl,
|
|
2155
|
+
wsToHttp,
|
|
2141
2156
|
resolveConfig,
|
|
2142
2157
|
redactConfig
|
|
2143
2158
|
};
|
package/dist/cli.js
CHANGED
|
@@ -15,7 +15,10 @@ import {
|
|
|
15
15
|
utext
|
|
16
16
|
} from "./chunk-LGYVCFGB.js";
|
|
17
17
|
import {
|
|
18
|
+
EXIT_CRASH,
|
|
18
19
|
EXIT_NEEDS_HUMAN,
|
|
20
|
+
EXIT_OK,
|
|
21
|
+
InviteExpiredError,
|
|
19
22
|
KeystorePermsError,
|
|
20
23
|
RebootstrapError,
|
|
21
24
|
acquireAgentLock,
|
|
@@ -24,6 +27,7 @@ import {
|
|
|
24
27
|
classifyFatal,
|
|
25
28
|
cleanContent,
|
|
26
29
|
configDir,
|
|
30
|
+
decodeInvite,
|
|
27
31
|
effectiveAnthropicCredential,
|
|
28
32
|
evaluateAcpPermission,
|
|
29
33
|
evaluateSdkTool,
|
|
@@ -33,6 +37,7 @@ import {
|
|
|
33
37
|
listAgents,
|
|
34
38
|
loadAgent,
|
|
35
39
|
macKeychainHasClaudeLogin,
|
|
40
|
+
mintAccessToken,
|
|
36
41
|
narrowestMode,
|
|
37
42
|
narrowestOverride,
|
|
38
43
|
newBeyondFile,
|
|
@@ -41,6 +46,7 @@ import {
|
|
|
41
46
|
pinEndpoint,
|
|
42
47
|
readPathWithFallback,
|
|
43
48
|
redactConfig,
|
|
49
|
+
redeemInvite,
|
|
44
50
|
relayHostSegment,
|
|
45
51
|
resolveAgentSelection,
|
|
46
52
|
resolveConfig,
|
|
@@ -52,9 +58,11 @@ import {
|
|
|
52
58
|
takeDenialHint,
|
|
53
59
|
toolModeLabel,
|
|
54
60
|
toolStatusLine,
|
|
61
|
+
validateRelayWsUrl,
|
|
55
62
|
validateReplacementToken,
|
|
56
|
-
verifyCardSignature
|
|
57
|
-
|
|
63
|
+
verifyCardSignature,
|
|
64
|
+
wsToHttp
|
|
65
|
+
} from "./chunk-7BBB4CRN.js";
|
|
58
66
|
|
|
59
67
|
// src/health.ts
|
|
60
68
|
var CEILING_STRIKES_TO_DEAD = 3;
|
|
@@ -6780,6 +6788,10 @@ var USAGE = `Usage: braid <command>
|
|
|
6780
6788
|
|
|
6781
6789
|
Commands:
|
|
6782
6790
|
join <code> Join a relay with an invite code (saves the credential)
|
|
6791
|
+
enroll --invite-stdin [--json]
|
|
6792
|
+
One-shot enrollment: reads the invite from stdin (never as an
|
|
6793
|
+
argument), redeems it, saves and verifies the credential, then
|
|
6794
|
+
exits without starting a session (--json for machine output)
|
|
6783
6795
|
start [name] Start the bridge from a saved credential
|
|
6784
6796
|
(env-driven config like BRAID_INVITE / BRAID_TOKEN is honored)
|
|
6785
6797
|
list List saved agents and their tool access
|
|
@@ -6919,6 +6931,32 @@ function parseCliArgs(argv) {
|
|
|
6919
6931
|
if (first === "version" || first === "--version" || first === "-v") {
|
|
6920
6932
|
return { kind: "version" };
|
|
6921
6933
|
}
|
|
6934
|
+
if (first === "enroll") {
|
|
6935
|
+
let inviteStdin = false;
|
|
6936
|
+
let json = false;
|
|
6937
|
+
for (const a of argv.slice(1)) {
|
|
6938
|
+
if (a === "--invite-stdin") {
|
|
6939
|
+
inviteStdin = true;
|
|
6940
|
+
continue;
|
|
6941
|
+
}
|
|
6942
|
+
if (a === "--json") {
|
|
6943
|
+
json = true;
|
|
6944
|
+
continue;
|
|
6945
|
+
}
|
|
6946
|
+
if (a.startsWith("-")) return { kind: "usage", error: `Unknown flag for enroll: ${a}` };
|
|
6947
|
+
return {
|
|
6948
|
+
kind: "usage",
|
|
6949
|
+
error: "enroll does not accept the invite as an argument. Pipe it on stdin: <command that prints the invite> | npx @snowyroad/braid enroll --invite-stdin"
|
|
6950
|
+
};
|
|
6951
|
+
}
|
|
6952
|
+
if (!inviteStdin) {
|
|
6953
|
+
return {
|
|
6954
|
+
kind: "usage",
|
|
6955
|
+
error: "enroll requires --invite-stdin (the invite is read from stdin, never from arguments)."
|
|
6956
|
+
};
|
|
6957
|
+
}
|
|
6958
|
+
return { kind: "enroll", json };
|
|
6959
|
+
}
|
|
6922
6960
|
if (first === "join" || first === "start") {
|
|
6923
6961
|
const parsed = parseRunFlags(argv);
|
|
6924
6962
|
if (!parsed.ok) return { kind: "usage", error: parsed.error };
|
|
@@ -6928,6 +6966,114 @@ function parseCliArgs(argv) {
|
|
|
6928
6966
|
return { kind: "usage", error: `Unknown command: ${first}` };
|
|
6929
6967
|
}
|
|
6930
6968
|
|
|
6969
|
+
// src/enroll.ts
|
|
6970
|
+
var ENROLL_STDIN_MAX_BYTES = 16 * 1024;
|
|
6971
|
+
function readBoundedStdin(stream, maxBytes) {
|
|
6972
|
+
return new Promise((resolve2, reject) => {
|
|
6973
|
+
const chunks = [];
|
|
6974
|
+
let total = 0;
|
|
6975
|
+
let settled = false;
|
|
6976
|
+
const finish = (fn) => {
|
|
6977
|
+
if (settled) return;
|
|
6978
|
+
settled = true;
|
|
6979
|
+
stream.removeListener("data", onData);
|
|
6980
|
+
stream.removeListener("end", onEnd);
|
|
6981
|
+
stream.removeListener("error", onError);
|
|
6982
|
+
fn();
|
|
6983
|
+
};
|
|
6984
|
+
const onData = (chunk) => {
|
|
6985
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
6986
|
+
total += buf.length;
|
|
6987
|
+
if (total > maxBytes) {
|
|
6988
|
+
finish(() => reject(new Error(`stdin exceeded the ${maxBytes / 1024}KiB invite limit`)));
|
|
6989
|
+
return;
|
|
6990
|
+
}
|
|
6991
|
+
chunks.push(buf);
|
|
6992
|
+
};
|
|
6993
|
+
const onEnd = () => finish(() => resolve2(Buffer.concat(chunks).toString("utf8").trim()));
|
|
6994
|
+
const onError = (err) => finish(() => reject(err));
|
|
6995
|
+
stream.on("data", onData);
|
|
6996
|
+
stream.on("end", onEnd);
|
|
6997
|
+
stream.on("error", onError);
|
|
6998
|
+
});
|
|
6999
|
+
}
|
|
7000
|
+
function persistOrNeedsHuman(dir, agent) {
|
|
7001
|
+
try {
|
|
7002
|
+
return saveAgent(dir, agent);
|
|
7003
|
+
} catch (err) {
|
|
7004
|
+
const code = err?.code;
|
|
7005
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
7006
|
+
throw new KeystorePermsError(
|
|
7007
|
+
`cannot write the keystore under ${dir} (${code}). Fix its ownership/permissions (chmod 700 ${dir}) and run enroll again.`
|
|
7008
|
+
);
|
|
7009
|
+
}
|
|
7010
|
+
throw err;
|
|
7011
|
+
}
|
|
7012
|
+
}
|
|
7013
|
+
async function runEnroll(opts, io, env) {
|
|
7014
|
+
const outLine = (s) => void io.stdout.write(s + "\n");
|
|
7015
|
+
const errLine = (s) => void io.stderr.write(s + "\n");
|
|
7016
|
+
if (io.stdin.isTTY === true) {
|
|
7017
|
+
errLine(
|
|
7018
|
+
"[braid] enroll reads the invite from stdin and nothing is piped in. Run it as: <command that prints the invite> | npx @snowyroad/braid enroll --invite-stdin"
|
|
7019
|
+
);
|
|
7020
|
+
return EXIT_CRASH;
|
|
7021
|
+
}
|
|
7022
|
+
let blob;
|
|
7023
|
+
try {
|
|
7024
|
+
blob = await readBoundedStdin(io.stdin, ENROLL_STDIN_MAX_BYTES);
|
|
7025
|
+
} catch (err) {
|
|
7026
|
+
errLine(`[braid] enroll failed: ${sanitizeForTty(err instanceof Error ? err.message : String(err))}`);
|
|
7027
|
+
return EXIT_CRASH;
|
|
7028
|
+
}
|
|
7029
|
+
if (blob === "") {
|
|
7030
|
+
errLine(
|
|
7031
|
+
"[braid] enroll received an empty stdin. Pipe the invite in: <command that prints the invite> | npx @snowyroad/braid enroll --invite-stdin"
|
|
7032
|
+
);
|
|
7033
|
+
return EXIT_CRASH;
|
|
7034
|
+
}
|
|
7035
|
+
try {
|
|
7036
|
+
const inv = decodeInvite(blob);
|
|
7037
|
+
validateRelayWsUrl(inv.relayUrl, env, "invite");
|
|
7038
|
+
const relayHttpUrl = wsToHttp(inv.relayUrl);
|
|
7039
|
+
const bundle = await redeemInvite(relayHttpUrl, inv.code);
|
|
7040
|
+
const dir = configDir(env);
|
|
7041
|
+
const stored = {
|
|
7042
|
+
relayUrl: inv.relayUrl,
|
|
7043
|
+
agentId: bundle.agentId,
|
|
7044
|
+
agentName: bundle.agentName,
|
|
7045
|
+
agentUuid: bundle.agentUuid,
|
|
7046
|
+
agentKey: bundle.agentKey
|
|
7047
|
+
};
|
|
7048
|
+
const file = persistOrNeedsHuman(dir, stored);
|
|
7049
|
+
assertKeystorePerms(dir, file);
|
|
7050
|
+
const minted = await mintAccessToken(relayHttpUrl, stored.agentKey);
|
|
7051
|
+
persistOrNeedsHuman(dir, { ...stored, agentKey: minted.agentKey });
|
|
7052
|
+
const name = sanitizeForTty(bundle.agentName);
|
|
7053
|
+
const cmdName = isShellSafeName(bundle.agentName) ? bundle.agentName : "<agent-name>";
|
|
7054
|
+
if (opts.json) {
|
|
7055
|
+
outLine(JSON.stringify({
|
|
7056
|
+
ok: true,
|
|
7057
|
+
agentName: bundle.agentName,
|
|
7058
|
+
agentUuid: bundle.agentUuid,
|
|
7059
|
+
relayUrl: inv.relayUrl,
|
|
7060
|
+
keystoreFile: file
|
|
7061
|
+
}));
|
|
7062
|
+
} else {
|
|
7063
|
+
outLine(`[braid] enrolled: ${name}`);
|
|
7064
|
+
outLine(`[braid] relay: ${inv.relayUrl}`);
|
|
7065
|
+
outLine(`[braid] credential saved to ${file} (verified: token mint ok)`);
|
|
7066
|
+
outLine(`[braid] start it with: npx @snowyroad/braid start ${cmdName}`);
|
|
7067
|
+
}
|
|
7068
|
+
return EXIT_OK;
|
|
7069
|
+
} catch (err) {
|
|
7070
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
7071
|
+
errLine(`[braid] enroll failed: ${sanitizeForTty(e.message)}`);
|
|
7072
|
+
if (e instanceof InviteExpiredError) return EXIT_NEEDS_HUMAN;
|
|
7073
|
+
return classifyFatal(e);
|
|
7074
|
+
}
|
|
7075
|
+
}
|
|
7076
|
+
|
|
6931
7077
|
// src/connectedBanner.ts
|
|
6932
7078
|
function connectedBannerLines(opts) {
|
|
6933
7079
|
const lines = [
|
|
@@ -7259,16 +7405,24 @@ async function runHandoff(opts) {
|
|
|
7259
7405
|
const label = opts.channelLabel ?? `#${tag}`;
|
|
7260
7406
|
const sessionId = session.liveSessionId();
|
|
7261
7407
|
if (!sessionId) {
|
|
7262
|
-
out.write(
|
|
7408
|
+
out.write(
|
|
7409
|
+
`
|
|
7263
7410
|
[handoff] no live session yet for ${label} - nothing to call up
|
|
7264
|
-
`
|
|
7411
|
+
`
|
|
7412
|
+
);
|
|
7265
7413
|
return;
|
|
7266
7414
|
}
|
|
7267
|
-
const spec = (opts.specFor ?? interactiveResumeSpecFor)(
|
|
7415
|
+
const spec = (opts.specFor ?? interactiveResumeSpecFor)(
|
|
7416
|
+
provider,
|
|
7417
|
+
sessionId,
|
|
7418
|
+
cwd
|
|
7419
|
+
);
|
|
7268
7420
|
if (!spec) {
|
|
7269
|
-
out.write(
|
|
7421
|
+
out.write(
|
|
7422
|
+
`
|
|
7270
7423
|
[handoff] not available for this provider yet (verified for Claude Code, Codex, Grok, and opencode so far)
|
|
7271
|
-
`
|
|
7424
|
+
`
|
|
7425
|
+
);
|
|
7272
7426
|
return;
|
|
7273
7427
|
}
|
|
7274
7428
|
const spawnFn = opts.spawnFn ?? nodeSpawn;
|
|
@@ -7277,34 +7431,38 @@ async function runHandoff(opts) {
|
|
|
7277
7431
|
{ scope: opts.scope },
|
|
7278
7432
|
provider
|
|
7279
7433
|
);
|
|
7280
|
-
out.write(
|
|
7281
|
-
"
|
|
7282
|
-
|
|
7283
|
-
|
|
7284
|
-
"WARNING: changes you make inside PERSIST after you leave -",
|
|
7285
|
-
"switching model or mode (or compacting/clearing) carries over into",
|
|
7286
|
-
"how this agent serves the channel afterwards.",
|
|
7287
|
-
// BL-173: the natural moment to grab the session is exactly while the agent is
|
|
7288
|
-
// visibly working — tell the owner what taking over does to the in-flight turn.
|
|
7289
|
-
...session.hasPendingWork?.() === true ? [
|
|
7434
|
+
out.write(
|
|
7435
|
+
banner("handoff", `TAKING CONTROL of ${label}`, [
|
|
7436
|
+
"pausing the bridge and opening the agent's REAL interactive session",
|
|
7437
|
+
"on this terminal. Quit it (Ctrl-C or /exit) to hand control back.",
|
|
7290
7438
|
"",
|
|
7291
|
-
"
|
|
7292
|
-
"
|
|
7293
|
-
|
|
7294
|
-
|
|
7439
|
+
"WARNING: changes you make inside PERSIST after you leave -",
|
|
7440
|
+
"switching model or mode (or compacting/clearing) carries over into",
|
|
7441
|
+
"how this agent serves the channel afterwards.",
|
|
7442
|
+
// BL-173: the natural moment to grab the session is exactly while the agent is
|
|
7443
|
+
// visibly working — tell the owner what taking over does to the in-flight turn.
|
|
7444
|
+
...session.hasPendingWork?.() === true ? [
|
|
7445
|
+
"",
|
|
7446
|
+
"NOTE: a reply is being worked on right now; taking control interrupts",
|
|
7447
|
+
"it. The interrupted message will re-run when you hand back."
|
|
7448
|
+
] : []
|
|
7449
|
+
])
|
|
7450
|
+
);
|
|
7295
7451
|
await session.handoffPause();
|
|
7296
7452
|
try {
|
|
7297
7453
|
await new Promise((resolve2) => {
|
|
7298
7454
|
const child = spawnFn(scoped.command, scoped.args, {
|
|
7299
7455
|
stdio: "inherit",
|
|
7300
7456
|
cwd: scoped.cwd,
|
|
7301
|
-
env:
|
|
7457
|
+
env: buildAcpEnv(process.env, scoped.env ?? void 0)
|
|
7302
7458
|
});
|
|
7303
7459
|
child.on("exit", () => resolve2());
|
|
7304
7460
|
child.on("error", (e) => {
|
|
7305
|
-
out.write(
|
|
7461
|
+
out.write(
|
|
7462
|
+
`
|
|
7306
7463
|
[handoff] failed to open the session: ${sanitizeForTty(String(e?.message ?? e))}
|
|
7307
|
-
`
|
|
7464
|
+
`
|
|
7465
|
+
);
|
|
7308
7466
|
resolve2();
|
|
7309
7467
|
});
|
|
7310
7468
|
});
|
|
@@ -8778,6 +8936,15 @@ async function main() {
|
|
|
8778
8936
|
printStatus(invocation.agent, invocation.json);
|
|
8779
8937
|
return;
|
|
8780
8938
|
}
|
|
8939
|
+
if (invocation.kind === "enroll") {
|
|
8940
|
+
process.exit(
|
|
8941
|
+
await runEnroll(
|
|
8942
|
+
{ json: invocation.json },
|
|
8943
|
+
{ stdin: process.stdin, stdout: process.stdout, stderr: process.stderr },
|
|
8944
|
+
process.env
|
|
8945
|
+
)
|
|
8946
|
+
);
|
|
8947
|
+
}
|
|
8781
8948
|
if (invocation.kind === "service") {
|
|
8782
8949
|
process.exit(await runServiceVerb(invocation.verb, invocation.agent, invocation.json));
|
|
8783
8950
|
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@snowyroad/braid",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.73.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@snowyroad/braid",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.73.0",
|
|
10
10
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@agentclientprotocol/sdk": "0.25.1",
|
|
@@ -2546,9 +2546,9 @@
|
|
|
2546
2546
|
}
|
|
2547
2547
|
},
|
|
2548
2548
|
"node_modules/postcss": {
|
|
2549
|
-
"version": "8.5.
|
|
2550
|
-
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.
|
|
2551
|
-
"integrity": "sha512-
|
|
2549
|
+
"version": "8.5.19",
|
|
2550
|
+
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
|
|
2551
|
+
"integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
|
|
2552
2552
|
"dev": true,
|
|
2553
2553
|
"funding": [
|
|
2554
2554
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@snowyroad/braid",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.73.0",
|
|
4
4
|
"description": "Braid: connect your own coding agent (Claude Code, Codex, Gemini, Grok) to a Braid channel and collaborate with other agents and humans.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "SnowyRoad",
|