nexrall-code 0.5.185 → 0.5.187
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/index.js +220 -8
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -40550,9 +40550,9 @@ var require_react_development = __commonJS({
|
|
|
40550
40550
|
false === didWarnAboutMessageChannel && (didWarnAboutMessageChannel = true, "undefined" === typeof MessageChannel && console.error(
|
|
40551
40551
|
"This browser does not have a MessageChannel implementation, so enqueuing tasks via await act(async () => ...) will fail. Please file an issue at https://github.com/facebook/react/issues if you encounter this warning."
|
|
40552
40552
|
));
|
|
40553
|
-
var
|
|
40554
|
-
|
|
40555
|
-
|
|
40553
|
+
var channel2 = new MessageChannel();
|
|
40554
|
+
channel2.port1.onmessage = callback;
|
|
40555
|
+
channel2.port2.postMessage(void 0);
|
|
40556
40556
|
};
|
|
40557
40557
|
}
|
|
40558
40558
|
return enqueueTaskImpl(task);
|
|
@@ -41524,8 +41524,8 @@ var require_scheduler_production = __commonJS({
|
|
|
41524
41524
|
localSetImmediate(performWorkUntilDeadline);
|
|
41525
41525
|
};
|
|
41526
41526
|
else if ("undefined" !== typeof MessageChannel) {
|
|
41527
|
-
|
|
41528
|
-
|
|
41527
|
+
channel2 = new MessageChannel(), port = channel2.port2;
|
|
41528
|
+
channel2.port1.onmessage = performWorkUntilDeadline;
|
|
41529
41529
|
schedulePerformWorkUntilDeadline = function() {
|
|
41530
41530
|
port.postMessage(null);
|
|
41531
41531
|
};
|
|
@@ -41533,7 +41533,7 @@ var require_scheduler_production = __commonJS({
|
|
|
41533
41533
|
schedulePerformWorkUntilDeadline = function() {
|
|
41534
41534
|
localSetTimeout(performWorkUntilDeadline, 0);
|
|
41535
41535
|
};
|
|
41536
|
-
var
|
|
41536
|
+
var channel2;
|
|
41537
41537
|
var port;
|
|
41538
41538
|
function requestHostTimeout(callback, ms) {
|
|
41539
41539
|
taskTimeoutID = localSetTimeout(function() {
|
|
@@ -41797,8 +41797,8 @@ var require_scheduler_development = __commonJS({
|
|
|
41797
41797
|
localSetImmediate(performWorkUntilDeadline);
|
|
41798
41798
|
};
|
|
41799
41799
|
else if ("undefined" !== typeof MessageChannel) {
|
|
41800
|
-
var
|
|
41801
|
-
|
|
41800
|
+
var channel2 = new MessageChannel(), port = channel2.port2;
|
|
41801
|
+
channel2.port1.onmessage = performWorkUntilDeadline;
|
|
41802
41802
|
schedulePerformWorkUntilDeadline = function() {
|
|
41803
41803
|
port.postMessage(null);
|
|
41804
41804
|
};
|
|
@@ -66643,6 +66643,157 @@ async function scheduleRunsCommand(id, opts = {}) {
|
|
|
66643
66643
|
}
|
|
66644
66644
|
}
|
|
66645
66645
|
|
|
66646
|
+
// src/commands/channelCmd.ts
|
|
66647
|
+
var CHANNEL_WAIT_EXIT = { OK: 0, FAILED: 1, DISCARDED: 3, TIMEOUT: 4 };
|
|
66648
|
+
function mapRelayError(status, error) {
|
|
66649
|
+
if (status === 400)
|
|
66650
|
+
return "the relay refused the request shape (bad session id, nonce or text).";
|
|
66651
|
+
if (status === 401)
|
|
66652
|
+
return "missing token \u2014 pass --token or set NEXRALL_CHANNEL_TOKEN.";
|
|
66653
|
+
if (status === 404)
|
|
66654
|
+
return "no such channel \u2014 it is closed, the terminal is gone, or the token is wrong.";
|
|
66655
|
+
if (status === 413)
|
|
66656
|
+
return "the message is too large for a push (8 KB max).";
|
|
66657
|
+
if (status === 429)
|
|
66658
|
+
return "rate limited \u2014 this channel is being pushed faster than it allows.";
|
|
66659
|
+
if (status === 503)
|
|
66660
|
+
return "the relay is unavailable \u2014 try again in a moment.";
|
|
66661
|
+
return `the relay refused the request (HTTP ${status}${error ? `: ${error}` : ""}).`;
|
|
66662
|
+
}
|
|
66663
|
+
async function channelPushCommand(textParts, opts, deps = {}) {
|
|
66664
|
+
const fetchFn = deps.fetchFn ?? fetch;
|
|
66665
|
+
const stdout = deps.stdout ?? ((line) => console.log(line));
|
|
66666
|
+
const stderr = deps.stderr ?? ((line) => console.error(paint.dim(line)));
|
|
66667
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve14) => setTimeout(resolve14, ms)));
|
|
66668
|
+
const pollMs = deps.pollMs ?? 2e3;
|
|
66669
|
+
const message = (textParts ?? []).join(" ").trim();
|
|
66670
|
+
const base = String(opts.url ?? "").replace(/\/+$/, "");
|
|
66671
|
+
const token = opts.token && opts.token.trim() || (process.env.NEXRALL_CHANNEL_TOKEN ?? "").trim();
|
|
66672
|
+
const timeoutSec = (() => {
|
|
66673
|
+
const t2 = parseFloat(String(opts.timeout ?? ""));
|
|
66674
|
+
return Number.isFinite(t2) && t2 > 0 ? t2 : 180;
|
|
66675
|
+
})();
|
|
66676
|
+
if (!message) {
|
|
66677
|
+
stderr("Usage: nex channel push --url <push URL> [--token <token>] [--wait] <message...>");
|
|
66678
|
+
return CHANNEL_WAIT_EXIT.FAILED;
|
|
66679
|
+
}
|
|
66680
|
+
if (!/^https?:\/\//.test(base)) {
|
|
66681
|
+
stderr("--url must be the push URL printed by `/channel open` (https://\u2026/api/cli-sessions/<id>/push).");
|
|
66682
|
+
return CHANNEL_WAIT_EXIT.FAILED;
|
|
66683
|
+
}
|
|
66684
|
+
if (!token) {
|
|
66685
|
+
stderr("No channel token \u2014 pass --token or set NEXRALL_CHANNEL_TOKEN (env keeps it out of `ps`).");
|
|
66686
|
+
return CHANNEL_WAIT_EXIT.FAILED;
|
|
66687
|
+
}
|
|
66688
|
+
if (opts.wait && !/\/push$/.test(base)) {
|
|
66689
|
+
stderr("--wait needs the \u2026/push URL itself, so the answer can be fetched back from <push URL>/<nonce>.");
|
|
66690
|
+
return CHANNEL_WAIT_EXIT.FAILED;
|
|
66691
|
+
}
|
|
66692
|
+
let res;
|
|
66693
|
+
try {
|
|
66694
|
+
res = await fetchFn(base, {
|
|
66695
|
+
method: "POST",
|
|
66696
|
+
headers: { "content-type": "application/json", "x-channel-token": token },
|
|
66697
|
+
body: JSON.stringify({ text: message, ...opts.source ? { source: opts.source } : {} })
|
|
66698
|
+
});
|
|
66699
|
+
} catch (e2) {
|
|
66700
|
+
stderr(`Push failed: ${e2.message}`);
|
|
66701
|
+
return CHANNEL_WAIT_EXIT.FAILED;
|
|
66702
|
+
}
|
|
66703
|
+
if (res.status !== 202) {
|
|
66704
|
+
let body = {};
|
|
66705
|
+
try {
|
|
66706
|
+
body = await res.json();
|
|
66707
|
+
} catch {
|
|
66708
|
+
}
|
|
66709
|
+
stderr(`\u2716 ${mapRelayError(res.status, body.error)}`);
|
|
66710
|
+
return CHANNEL_WAIT_EXIT.FAILED;
|
|
66711
|
+
}
|
|
66712
|
+
const pushed = await res.json();
|
|
66713
|
+
const nonce = typeof pushed.nonce === "string" ? pushed.nonce : "";
|
|
66714
|
+
if (!nonce) {
|
|
66715
|
+
stderr("The relay answered 202 without a nonce \u2014 there is nothing to watch.");
|
|
66716
|
+
return CHANNEL_WAIT_EXIT.FAILED;
|
|
66717
|
+
}
|
|
66718
|
+
if (!opts.wait) {
|
|
66719
|
+
if (opts.json)
|
|
66720
|
+
stdout(JSON.stringify({ ok: true, nonce, ts: pushed.ts ?? null, status: "pending" }));
|
|
66721
|
+
else
|
|
66722
|
+
stdout(nonce);
|
|
66723
|
+
stderr(`Pushed. The session decides what to do with it (a person, maybe; read the outcome at ${base}/${nonce}).`);
|
|
66724
|
+
return CHANNEL_WAIT_EXIT.OK;
|
|
66725
|
+
}
|
|
66726
|
+
stderr(`Waiting for the session's answer (nonce ${nonce}, up to ${timeoutSec}s)\u2026`);
|
|
66727
|
+
const deadline = Date.now() + timeoutSec * 1e3;
|
|
66728
|
+
let lastStatus = "pending";
|
|
66729
|
+
let lastError = "";
|
|
66730
|
+
for (; ; ) {
|
|
66731
|
+
if (Date.now() >= deadline) {
|
|
66732
|
+
if (opts.json)
|
|
66733
|
+
stdout(JSON.stringify({ ok: false, error: "timeout", nonce, status: lastStatus }));
|
|
66734
|
+
stderr(
|
|
66735
|
+
`\u2716 No answer after ${timeoutSec}s (still ${lastStatus}${lastError ? `; last error: ${lastError}` : ""}). The push was delivered \u2014 the session may still be working on it.`
|
|
66736
|
+
);
|
|
66737
|
+
return CHANNEL_WAIT_EXIT.TIMEOUT;
|
|
66738
|
+
}
|
|
66739
|
+
await sleep(Math.min(pollMs, Math.max(10, deadline - Date.now())));
|
|
66740
|
+
let status;
|
|
66741
|
+
let body = {};
|
|
66742
|
+
try {
|
|
66743
|
+
const r2 = await fetchFn(`${base}/${nonce}`, { headers: { "x-channel-token": token } });
|
|
66744
|
+
status = r2.status;
|
|
66745
|
+
try {
|
|
66746
|
+
body = await r2.json();
|
|
66747
|
+
} catch {
|
|
66748
|
+
}
|
|
66749
|
+
} catch (e2) {
|
|
66750
|
+
lastError = e2.message;
|
|
66751
|
+
continue;
|
|
66752
|
+
}
|
|
66753
|
+
if (status === 404) {
|
|
66754
|
+
if (opts.json)
|
|
66755
|
+
stdout(JSON.stringify({ ok: false, error: body.error ?? "no_channel", nonce }));
|
|
66756
|
+
stderr("\u2716 The channel is gone (closed, terminal exited, or the token changed). The answer died with it.");
|
|
66757
|
+
return CHANNEL_WAIT_EXIT.FAILED;
|
|
66758
|
+
}
|
|
66759
|
+
if (status === 400 || status === 401) {
|
|
66760
|
+
if (opts.json)
|
|
66761
|
+
stdout(JSON.stringify({ ok: false, error: body.error ?? "refused", nonce }));
|
|
66762
|
+
stderr(`\u2716 ${mapRelayError(status, body.error)}`);
|
|
66763
|
+
return CHANNEL_WAIT_EXIT.FAILED;
|
|
66764
|
+
}
|
|
66765
|
+
if (status === 429 || status >= 500) {
|
|
66766
|
+
lastError = `HTTP ${status}`;
|
|
66767
|
+
continue;
|
|
66768
|
+
}
|
|
66769
|
+
if (status !== 200) {
|
|
66770
|
+
lastError = `HTTP ${status}`;
|
|
66771
|
+
continue;
|
|
66772
|
+
}
|
|
66773
|
+
lastStatus = typeof body.status === "string" ? body.status : "pending";
|
|
66774
|
+
if (lastStatus === "discarded") {
|
|
66775
|
+
if (opts.json)
|
|
66776
|
+
stdout(JSON.stringify(body));
|
|
66777
|
+
else
|
|
66778
|
+
stderr("\u{1F4E8} The session discarded the message \u2014 a person there refused it.");
|
|
66779
|
+
return CHANNEL_WAIT_EXIT.DISCARDED;
|
|
66780
|
+
}
|
|
66781
|
+
const answered = lastStatus === "accepted" && (typeof body.reply === "string" || body.noText === true);
|
|
66782
|
+
if (answered) {
|
|
66783
|
+
if (opts.json)
|
|
66784
|
+
stdout(JSON.stringify(body));
|
|
66785
|
+
else if (typeof body.reply === "string")
|
|
66786
|
+
stdout(body.reply);
|
|
66787
|
+
else {
|
|
66788
|
+
stderr(
|
|
66789
|
+
`(The session accepted the message, but the turn produced no text${body.interrupted ? " \u2014 it was interrupted" : ""}.)`
|
|
66790
|
+
);
|
|
66791
|
+
}
|
|
66792
|
+
return CHANNEL_WAIT_EXIT.OK;
|
|
66793
|
+
}
|
|
66794
|
+
}
|
|
66795
|
+
}
|
|
66796
|
+
|
|
66646
66797
|
// src/commands/chat.ts
|
|
66647
66798
|
import * as readline3 from "readline";
|
|
66648
66799
|
import * as path31 from "path";
|
|
@@ -75417,7 +75568,9 @@ import { createHash as createHash2, randomBytes as randomBytes2 } from "node:cry
|
|
|
75417
75568
|
var CHANNEL_TOKEN_PREFIX = "nxch_";
|
|
75418
75569
|
var CHANNEL_QUEUE_CAP = 10;
|
|
75419
75570
|
var CHANNEL_TEXT_MAX_CHARS = 8192;
|
|
75571
|
+
var CHANNEL_REPLY_MAX_CHARS = 8192;
|
|
75420
75572
|
var SEEN_NONCE_CAP = 512;
|
|
75573
|
+
var AWAITING_REPLY_CAP = 64;
|
|
75421
75574
|
var NONCE_RE = /^[a-f0-9]{16}$/;
|
|
75422
75575
|
var SOURCE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
|
|
75423
75576
|
function defaultMintToken() {
|
|
@@ -75451,6 +75604,8 @@ var SessionChannel = class {
|
|
|
75451
75604
|
policy = "ask";
|
|
75452
75605
|
queue = [];
|
|
75453
75606
|
seenNonces = /* @__PURE__ */ new Set();
|
|
75607
|
+
/** S13c: nonces delivered but not yet answered by a finished turn. */
|
|
75608
|
+
awaitingReply = /* @__PURE__ */ new Set();
|
|
75454
75609
|
/** Minted once per open; rotated by a re-open after /channel close. */
|
|
75455
75610
|
token = null;
|
|
75456
75611
|
tokenHash = null;
|
|
@@ -75584,6 +75739,7 @@ var SessionChannel = class {
|
|
|
75584
75739
|
return;
|
|
75585
75740
|
this.stopped = true;
|
|
75586
75741
|
this.queue = [];
|
|
75742
|
+
this.awaitingReply.clear();
|
|
75587
75743
|
if (this.socket?.connected)
|
|
75588
75744
|
this.emitTo("cli:channel-close", { id: this.id, reason });
|
|
75589
75745
|
this.token = null;
|
|
@@ -75609,6 +75765,39 @@ var SessionChannel = class {
|
|
|
75609
75765
|
}
|
|
75610
75766
|
this.notice({ kind: "delivered", msg, auto, folded: verdict === "queued" });
|
|
75611
75767
|
this.ack(msg, verdict);
|
|
75768
|
+
if (this.awaitingReply.size >= AWAITING_REPLY_CAP) {
|
|
75769
|
+
this.awaitingReply.delete(this.awaitingReply.values().next().value);
|
|
75770
|
+
}
|
|
75771
|
+
this.awaitingReply.add(msg.nonce);
|
|
75772
|
+
}
|
|
75773
|
+
/**
|
|
75774
|
+
* S13c — close the loop: hand every delivered-but-unanswered push the
|
|
75775
|
+
* closing text of the turn that consumed it (chat.ts calls this when a turn
|
|
75776
|
+
* ends; `null`/blank means the turn said nothing useful, which is reported
|
|
75777
|
+
* as an EXPLICIT no-text marker rather than silence). Frames are keyed by
|
|
75778
|
+
* nonce and stored by the relay, readable only with the channel token.
|
|
75779
|
+
* Returns how many nonces were answered.
|
|
75780
|
+
*/
|
|
75781
|
+
publishReplies(reply, opts = {}) {
|
|
75782
|
+
if (this.stopped || this.awaitingReply.size === 0)
|
|
75783
|
+
return 0;
|
|
75784
|
+
const text = (reply ?? "").trim();
|
|
75785
|
+
let body;
|
|
75786
|
+
if (!text)
|
|
75787
|
+
body = { noText: true };
|
|
75788
|
+
else if (text.length > CHANNEL_REPLY_MAX_CHARS) {
|
|
75789
|
+
body = { reply: text.slice(0, CHANNEL_REPLY_MAX_CHARS), truncated: true };
|
|
75790
|
+
} else
|
|
75791
|
+
body = { reply: text };
|
|
75792
|
+
if (opts.interrupted)
|
|
75793
|
+
body.interrupted = true;
|
|
75794
|
+
let answered = 0;
|
|
75795
|
+
for (const nonce of this.awaitingReply) {
|
|
75796
|
+
this.emitTo("cli:channel-result", { id: this.id, nonce, ...body });
|
|
75797
|
+
answered += 1;
|
|
75798
|
+
}
|
|
75799
|
+
this.awaitingReply.clear();
|
|
75800
|
+
return answered;
|
|
75612
75801
|
}
|
|
75613
75802
|
/** Shape-check a pushed frame — the relay is a pipe, delivery trusts nothing. */
|
|
75614
75803
|
validateMessage(payload) {
|
|
@@ -97681,6 +97870,7 @@ async function runTurn(messages, modelAlias, workDir, abortSignal, env3, nexrall
|
|
|
97681
97870
|
const subPrefix = (meta) => paint.dim(` \u21B3 ${meta?.agentName ?? "sub-agent"} `);
|
|
97682
97871
|
let thinkingTokens = 0;
|
|
97683
97872
|
let axTextBlockOpen = false;
|
|
97873
|
+
let turnReply = "";
|
|
97684
97874
|
let liveThinkText = "";
|
|
97685
97875
|
const pushStatus = () => {
|
|
97686
97876
|
spinner.setThinking(thinkingTokens, liveThinkText);
|
|
@@ -97782,6 +97972,9 @@ async function runTurn(messages, modelAlias, workDir, abortSignal, env3, nexrall
|
|
|
97782
97972
|
axTextBlockOpen = true;
|
|
97783
97973
|
}
|
|
97784
97974
|
mdRender.feed(text);
|
|
97975
|
+
turnReply += text;
|
|
97976
|
+
if (turnReply.length > 32768)
|
|
97977
|
+
turnReply = turnReply.slice(-16384);
|
|
97785
97978
|
},
|
|
97786
97979
|
// System notices (mid-run auto-prune/auto-compact housekeeping) are NOT part
|
|
97787
97980
|
// of the model's own reply — used to go through onText, which spliced
|
|
@@ -97801,6 +97994,7 @@ async function runTurn(messages, modelAlias, workDir, abortSignal, env3, nexrall
|
|
|
97801
97994
|
if (abortSignal.aborted)
|
|
97802
97995
|
return;
|
|
97803
97996
|
mdRender.flush();
|
|
97997
|
+
turnReply = "";
|
|
97804
97998
|
spinner.stop();
|
|
97805
97999
|
if (meta?.id)
|
|
97806
98000
|
toolCalls.set(meta.id, {
|
|
@@ -98063,6 +98257,10 @@ async function runTurn(messages, modelAlias, workDir, abortSignal, env3, nexrall
|
|
|
98063
98257
|
});
|
|
98064
98258
|
if (sessionTurns.length > MAX_SESSION_TURNS)
|
|
98065
98259
|
sessionTurns.splice(0, sessionTurns.length - MAX_SESSION_TURNS);
|
|
98260
|
+
const channel2 = activeSessionChannel();
|
|
98261
|
+
const replied = channel2?.publishReplies(turnReply, { interrupted: abortSignal.aborted }) ?? 0;
|
|
98262
|
+
if (replied > 0)
|
|
98263
|
+
console.log(paint.dim(` \u21A9 channel result sent (${replied})`));
|
|
98066
98264
|
return { messages: result, usage: lastUsage };
|
|
98067
98265
|
}
|
|
98068
98266
|
function toolIds(isSubTask, meta) {
|
|
@@ -101466,6 +101664,11 @@ ${dirList}`;
|
|
|
101466
101664
|
` Channel ${st.state === "live" ? paint.green("live") : paint.yellow(st.state)} \xB7 policy ${ch.activePolicy} \xB7 ${pending2.length} waiting`
|
|
101467
101665
|
);
|
|
101468
101666
|
console.log(paint.dim(` Push: ${ch.pushUrl}`));
|
|
101667
|
+
console.log(
|
|
101668
|
+
paint.dim(
|
|
101669
|
+
" Replies: the pusher reads the turn's answer back \u2014 GET <push URL>/<nonce>, or `nex channel push --wait`."
|
|
101670
|
+
)
|
|
101671
|
+
);
|
|
101469
101672
|
pending2.forEach(
|
|
101470
101673
|
(m2, i2) => console.log(paint.dim(` #${i2 + 1} (${m2.source}) ${channelPreview(m2.text)}`))
|
|
101471
101674
|
);
|
|
@@ -102911,6 +103114,11 @@ ${text}
|
|
|
102911
103114
|
worktree: worktreeState,
|
|
102912
103115
|
disableHooks: noDiscovery,
|
|
102913
103116
|
_extraAgentTypes: _cliAgentTypes,
|
|
103117
|
+
// Threaded exactly as runTurn does: a typed fork must not silently
|
|
103118
|
+
// lose the MCP servers the session has connected, and a billed
|
|
103119
|
+
// model run belongs in the audit trail like any other.
|
|
103120
|
+
mcpManager: _mcpManager ?? void 0,
|
|
103121
|
+
audit: currentAudit(),
|
|
102914
103122
|
backgroundAgents: willBackground ? cliBackgroundAgents : void 0,
|
|
102915
103123
|
// Background: it runs while the user keeps typing, so it can never print
|
|
102916
103124
|
// into the transcript. Foreground: the user is waiting and the prompt is
|
|
@@ -104094,6 +104302,10 @@ schedule.command("runs <id>").description("Recent runs of a schedule, newest fir
|
|
|
104094
104302
|
schedule.action(async () => {
|
|
104095
104303
|
await scheduleListCommand();
|
|
104096
104304
|
});
|
|
104305
|
+
var channel = program2.command("channel").description("Push messages into a running session from CI or a script (channels)");
|
|
104306
|
+
channel.command("push <text...>").description("Push a message into a live session; --wait prints the answer back").requiredOption("--url <url>", "The push URL printed by `/channel open` inside the session").option("--token <token>", "Channel token (prefer NEXRALL_CHANNEL_TOKEN \u2014 argv is visible in `ps`)").option("--source <slug>", "Where it came from, e.g. ci (default: channel)").option("-w, --wait", "Wait for the session's answer and print it (exit 0 answered, 3 discarded, 4 timeout)").option("--timeout <seconds>", "How long --wait waits (default 180)").option("--json", "Machine-readable output").action(async (text, opts) => {
|
|
104307
|
+
process.exitCode = await channelPushCommand(text, opts);
|
|
104308
|
+
});
|
|
104097
104309
|
program2.command("update").description("Update nex to the latest version").option("-c, --check", "Check for updates without installing").option("-y, --yes", "Skip confirmation prompt").action(async (opts) => {
|
|
104098
104310
|
await updateCommand({ check: opts.check, yes: opts.yes });
|
|
104099
104311
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexrall-code",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "Nexrall Code
|
|
3
|
+
"version": "0.5.187",
|
|
4
|
+
"description": "Nexrall Code \u2014 AI coding assistant for your terminal (headless agent for scripts, CI and automation)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
7
7
|
"cli",
|