moshcode 0.65.0 → 0.67.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/README.md +126 -0
- package/bin/moshcode.mjs +15 -3
- package/package.json +2 -2
- package/src/aliases.mjs +65 -0
- package/src/cli-schema.mjs +24 -2
- package/src/commands.mjs +3 -1
- package/src/integrations.mjs +7 -2
- package/src/mcp-catalog.mjs +21 -1
- package/src/name-link.mjs +259 -0
- package/src/tools.mjs +125 -0
- package/src/tui.mjs +153 -4
package/README.md
CHANGED
|
@@ -14,6 +14,28 @@ curl -fsSL https://moshcoding.com/install.sh | sh
|
|
|
14
14
|
Zero-dependency ESM — all it needs is Node.js 18+. Later: `… | sh -s -- update`
|
|
15
15
|
to upgrade, `… | sh -s -- remove` to uninstall.
|
|
16
16
|
|
|
17
|
+
## What is in this repo
|
|
18
|
+
|
|
19
|
+
The CLI is the root package — `bin/`, `src/`, `plugins/` — and that is what
|
|
20
|
+
`npm publish` and the installer above ship. Two apps live alongside it:
|
|
21
|
+
|
|
22
|
+
| path | what it is | deployed as |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| `apps/pwa` | the Moshpit registry and account app | pit.moshcode.sh, app.moshcode.sh |
|
|
25
|
+
| `apps/qryptchat-web` | QryptChat, the post-quantum messenger | qrypt.chat |
|
|
26
|
+
|
|
27
|
+
One repo, but **not one dependency tree**. Each app installs itself, with its
|
|
28
|
+
own lockfile, and the root `pnpm install` does not reach into either. That is
|
|
29
|
+
deliberate on both counts and `pnpm-workspace.yaml` says why. The practical
|
|
30
|
+
rule: run `pnpm install` in the directory you are working in, not at the root
|
|
31
|
+
and not the other way round.
|
|
32
|
+
|
|
33
|
+
The two apps share a feature rather than code — a person can sign in to
|
|
34
|
+
QryptChat by proving they hold a Moshpit name, which `moshcode name link`
|
|
35
|
+
produces the proof for. The shared half of that is still duplicated in both
|
|
36
|
+
trees; see `pnpm-workspace.yaml` for what has to be fixed before it can be
|
|
37
|
+
lifted into `packages/`.
|
|
38
|
+
|
|
17
39
|
## Commands
|
|
18
40
|
|
|
19
41
|
`moshcode help <command>` drills into any of these — flags, examples and all.
|
|
@@ -48,6 +70,7 @@ or miss one that does. A test fails the build when it drifts.
|
|
|
48
70
|
| `moshcode load` | account | bring your saved pit settings onto this machine |
|
|
49
71
|
| `moshcode console` | account | serve or connect to the browser terminal |
|
|
50
72
|
| `moshcode dns` | hosting | resolve Moshpit names on this machine |
|
|
73
|
+
| `moshcode name` | hosting | prove you hold a Moshpit name, so an app can use it as your identity |
|
|
51
74
|
| `moshcode doh` | hosting | run the DNS-over-HTTPS resolver |
|
|
52
75
|
| `moshcode site` <br>`serve` | hosting | install web-server config for a Moshpit name |
|
|
53
76
|
| `moshcode template` <br>`templates` | hosting | scaffold a stack for a Moshpit-hosted service |
|
|
@@ -590,6 +613,40 @@ moshcode ugig --json gigs list # arguments/output go straight to ugig
|
|
|
590
613
|
moshcode coinpay wallet balance # arguments/output go straight to coinpay
|
|
591
614
|
```
|
|
592
615
|
|
|
616
|
+
### BufferOverride — the failure in front of you, already answered
|
|
617
|
+
|
|
618
|
+
[BufferOverride](https://bufferoverride.com) is where humans and agents debug
|
|
619
|
+
together: every answer declares the versions it works on, who or what wrote it,
|
|
620
|
+
and whether anyone independent reproduced it. `bo` is that from a terminal.
|
|
621
|
+
|
|
622
|
+
```sh
|
|
623
|
+
moshcode install bo # npm i -g @profullstack/bufferoverride
|
|
624
|
+
|
|
625
|
+
moshcode bo run -- pnpm test # run it, keep what it printed, search for it
|
|
626
|
+
moshcode bo search "worker exited before finishing"
|
|
627
|
+
moshcode bo get a1b2c3d4e5 --markdown
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
The product is BufferOverride and the binary is `bo` — the same split
|
|
631
|
+
`secrets`/`logicsrc` and `spinifex`/`spx` have, keyed the short way round here
|
|
632
|
+
because this is a command you type every time something fails.
|
|
633
|
+
|
|
634
|
+
`bo run --` wraps a command rather than replacing it: the wrapped command's exit
|
|
635
|
+
code passes straight through, so it can go in front of something already in CI
|
|
636
|
+
without changing what CI sees. It captures stdout, stderr, the exit code, the
|
|
637
|
+
OS, the architecture and the detected dependency versions, redacts what it
|
|
638
|
+
recognises as a secret, and searches for the failure **before** offering to
|
|
639
|
+
publish it. Nothing leaves the machine until you have seen it, and outside a TTY
|
|
640
|
+
nothing is published at all unless you pass `--ask`.
|
|
641
|
+
|
|
642
|
+
Redaction is best effort and cannot be complete — no pattern list catches a
|
|
643
|
+
custom-format secret — so `--dry-run` is the habit its own docs ask for.
|
|
644
|
+
|
|
645
|
+
Reads need no credential: `search` and `get` work before you have ever run `bo
|
|
646
|
+
login`. Publishing needs one, and `bo login` is a device-code exchange, so a
|
|
647
|
+
terminal never handles a browser session. `bo mcp config` prints the MCP
|
|
648
|
+
registration for a coding agent, which is the same graph over a different door.
|
|
649
|
+
|
|
593
650
|
### Cloud + infra CLIs
|
|
594
651
|
|
|
595
652
|
```sh
|
|
@@ -665,6 +722,32 @@ engines, `mcpjam` tells you whether that server is healthy first — health
|
|
|
665
722
|
checks, OAuth conformance, tool-surface diffing, and structured triage from the
|
|
666
723
|
terminal or CI. Re-running `moshcode install mcpjam` is also its upgrade path.
|
|
667
724
|
|
|
725
|
+
### Alchemy — onchain data, wallets, and x402
|
|
726
|
+
|
|
727
|
+
```sh
|
|
728
|
+
moshcode install alchemy # npm i -g @alchemy/cli
|
|
729
|
+
|
|
730
|
+
moshcode alchemy auth # browser login, then pick an app
|
|
731
|
+
moshcode alchemy evm balance --address 0x…
|
|
732
|
+
moshcode alchemy --json --no-interactive wallet send …
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
[Alchemy](https://www.alchemy.com/)'s CLI covers four things from one binary:
|
|
736
|
+
querying onchain data across EVM and Solana (balances, NFTs, transfers, prices,
|
|
737
|
+
blocks, logs, traces, simulations, raw RPC), managing Alchemy apps, networks,
|
|
738
|
+
allowlists and webhooks, driving an agent-ready wallet (sends, swaps, contract
|
|
739
|
+
calls, approvals, cross-chain bridges), and paying third-party x402 APIs in
|
|
740
|
+
USDC under a spend cap.
|
|
741
|
+
|
|
742
|
+
`alchemy auth` opens a browser to link your account and saves the selected app's
|
|
743
|
+
API key; API keys and x402 wallet auth work too, depending on the command. Pass
|
|
744
|
+
`--json --no-interactive` when a script or agent is driving, which is also what
|
|
745
|
+
makes it read like the rest of the roster in a pipeline. It needs Node 22 or
|
|
746
|
+
newer, and re-running `moshcode install alchemy` is its upgrade path.
|
|
747
|
+
|
|
748
|
+
Where CoinPay is the payments product MoshCode ships alongside, Alchemy is the
|
|
749
|
+
read side of the same world — the chain itself rather than one wallet's ledger.
|
|
750
|
+
|
|
668
751
|
`gh`, `supabase`, and `doctl` publish no cross-platform install script, so
|
|
669
752
|
MoshCode resolves the latest GitHub release and drops the binary in
|
|
670
753
|
`$MOSHCODE_BIN` (default `~/.local/bin`) — no sudo, no package manager. Set
|
|
@@ -805,6 +888,29 @@ survive between sessions. A name that is already a pit command, an engine, or a
|
|
|
805
888
|
tool is refused rather than shadowed — built-ins are dispatched first, so such
|
|
806
889
|
an alias would never run.
|
|
807
890
|
|
|
891
|
+
Some workflow tools ship a *set* of commands rather than one binary, and propose
|
|
892
|
+
short words for them. Installing such a tool configures those words too — a
|
|
893
|
+
dispatcher fronting seven commands is not reachable from the pit until the names
|
|
894
|
+
that reach them exist:
|
|
895
|
+
|
|
896
|
+
```text
|
|
897
|
+
/install cli-tools # or /tools install cli-tools
|
|
898
|
+
✓ cli-tools installed. 🤘
|
|
899
|
+
✓ /blog → blog-post
|
|
900
|
+
✓ /free → domainfree
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
`/upgrade` does the same, because an upgrade is where a tool *gains* commands —
|
|
904
|
+
a roster adopted once at install time otherwise goes stale the first time the
|
|
905
|
+
tool ships something new. `/alias install <tool>` (or `--all`) re-runs it on
|
|
906
|
+
demand, for tools you installed before this existed.
|
|
907
|
+
|
|
908
|
+
The tool proposes and the pit disposes: moshcode reads the suggestions and
|
|
909
|
+
writes the file, so nothing else reaches into a config it does not own. A name
|
|
910
|
+
you bound yourself always wins — your `/prs` may carry `--orgs` flags a generic
|
|
911
|
+
suggestion knows nothing about — and a tool that offers nothing, or cannot be
|
|
912
|
+
asked, is silent rather than turning a successful install into an error.
|
|
913
|
+
|
|
808
914
|
### Social posting from the pit
|
|
809
915
|
|
|
810
916
|
The pit can hand a prepared post to Bluesky or Nostr without storing either
|
|
@@ -989,6 +1095,7 @@ Some MCP servers are worth remembering by name rather than by npx invocation:
|
|
|
989
1095
|
```sh
|
|
990
1096
|
moshcode mcp catalog # what we know how to run
|
|
991
1097
|
moshcode mcp add porkbun # expands to: npx -y @porkbunllc/mcp-server
|
|
1098
|
+
moshcode mcp add bufferoverride # expands to: https://bufferoverride.com/mcp
|
|
992
1099
|
```
|
|
993
1100
|
|
|
994
1101
|
That registers it across every engine that supports MCP (claude, gemini, qwen,
|
|
@@ -1010,6 +1117,23 @@ from and five to rotate. Porkbun's API access is off by default and enabled
|
|
|
1010
1117
|
per-domain — and its documentation tools work with no keys at all, which is a
|
|
1011
1118
|
sensible way to try the server before trusting it with DNS writes.
|
|
1012
1119
|
|
|
1120
|
+
**BufferOverride is the useful-unauthenticated one.** Five read tools
|
|
1121
|
+
(`search_questions`, `get_question`, `list_questions`, `list_tags`, `whoami`)
|
|
1122
|
+
work with no credential, and the write tools are gated on the scopes a key
|
|
1123
|
+
actually carries — `tools/list` advertises only what your key can use. So the
|
|
1124
|
+
bare `mcp add bufferoverride` above is a complete, working registration. To
|
|
1125
|
+
publish from an engine, add the credential as a header:
|
|
1126
|
+
|
|
1127
|
+
```sh
|
|
1128
|
+
moshcode mcp add bufferoverride -H "Authorization: Bearer bo_..."
|
|
1129
|
+
```
|
|
1130
|
+
|
|
1131
|
+
`bo mcp config` prints the same thing from a terminal that has already signed
|
|
1132
|
+
in, and `bo mcp config --no-token` prints a form safe to paste in public. It is
|
|
1133
|
+
registered under the name the CLI uses, so both routes produce one server rather
|
|
1134
|
+
than two. See [BufferOverride](#bufferoverride--the-failure-in-front-of-you-already-answered)
|
|
1135
|
+
above for the CLI itself.
|
|
1136
|
+
|
|
1013
1137
|
## Claude Code plugins
|
|
1014
1138
|
|
|
1015
1139
|
MoshCode publishes its own plugin marketplace, so the pit's slash commands work
|
|
@@ -1322,6 +1446,7 @@ chmod +x deploy.mosh
|
|
|
1322
1446
|
| `coinpay(args…)` | drive the coinpay workflow CLI |
|
|
1323
1447
|
| `c0mpute(args…)` | drive the c0mpute workflow CLI |
|
|
1324
1448
|
| `c0upons(args…)` | drive the c0upons workflow CLI |
|
|
1449
|
+
| `bo(args…)` | drive the BufferOverride CLI (capture a failure, search, ask, answer, verify) |
|
|
1325
1450
|
| `secrets(args…)` | drive the logicsrc secrets CLI |
|
|
1326
1451
|
| `railway(args…)` | drive the Railway CLI |
|
|
1327
1452
|
| `gh(args…)` | drive the GitHub CLI |
|
|
@@ -1334,6 +1459,7 @@ chmod +x deploy.mosh
|
|
|
1334
1459
|
| `alpaca(args…)` | drive the native Alpaca trading CLI |
|
|
1335
1460
|
| `mcpjam(args…)` | drive the MCPJam CLI (test, debug, and validate MCP servers) |
|
|
1336
1461
|
| `spinifex(args…)` | drive the Spinifex CLI (`spx` — AWS-compatible cloud on your own hardware) |
|
|
1462
|
+
| `alchemy(args…)` | drive the Alchemy CLI (onchain data, apps, wallets, x402) |
|
|
1337
1463
|
| `trade(args…)` | look up tickers, inspect markets, preview/place Alpaca orders |
|
|
1338
1464
|
| `stocks(args…)` | research tickers via advis0r (`stocksRead` returns the data) |
|
|
1339
1465
|
| `crypto(args…)` | research crypto pairs via advis0r (`cryptoRead` returns the data) |
|
package/bin/moshcode.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { runScript } from "../src/runtime.mjs";
|
|
6
|
-
import { moshVocabulary } from "../src/commands.mjs";
|
|
6
|
+
import { isReserved, moshVocabulary } from "../src/commands.mjs";
|
|
7
7
|
import {
|
|
8
8
|
agentLaunchArgs,
|
|
9
9
|
engineList,
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
resolveExecutable,
|
|
15
15
|
runCmd,
|
|
16
16
|
} from "../src/engines.mjs";
|
|
17
|
-
import { TOOLS, toolList, toolStatus, resolveTool, openTool } from "../src/tools.mjs";
|
|
17
|
+
import { TOOLS, toolList, toolStatus, resolveTool, openTool, adoptAliasLines } from "../src/tools.mjs";
|
|
18
18
|
import { tradeArgs, tradeUsage } from "../src/trade.mjs";
|
|
19
19
|
import { runUpgrade } from "../src/upgrade.mjs";
|
|
20
20
|
import { selfUpdateCommand } from "../src/selfupdate.mjs";
|
|
@@ -33,6 +33,7 @@ import { consoleCommand } from "../src/console.mjs";
|
|
|
33
33
|
import { herdCommand, herdStart, splitDetachArgs } from "../src/herd-cli.mjs";
|
|
34
34
|
import { detectSubstrate, substrateNote } from "../src/herd.mjs";
|
|
35
35
|
import { dnsCommand } from "../src/dns.mjs";
|
|
36
|
+
import { nameCommand } from "../src/name-link.mjs";
|
|
36
37
|
import { templateCommand } from "../src/templates.mjs";
|
|
37
38
|
import { serveCommand } from "../src/serve.mjs";
|
|
38
39
|
import { createDohServer, nginxDohSite, parseDohPort, parseGuardArgs, DEFAULT_DOH_PORT, DOH_PATH } from "../src/doh-server.mjs";
|
|
@@ -456,7 +457,14 @@ async function main() {
|
|
|
456
457
|
process.exitCode = 1;
|
|
457
458
|
return;
|
|
458
459
|
}
|
|
459
|
-
if (result.code === 0)
|
|
460
|
+
if (result.code === 0) {
|
|
461
|
+
console.log(`\n✓ ${target} installed. run it with \`${bin}\`. 🤘`);
|
|
462
|
+
// Installing is also configuring: a tool that ships a set of commands is
|
|
463
|
+
// not usable from the pit until the words that reach them exist. Quiet
|
|
464
|
+
// for everything that offers none, and a name already in the file is
|
|
465
|
+
// reported by the adopter rather than replaced.
|
|
466
|
+
for (const line of adoptAliasLines(target, entry, { isReserved })) console.log(line);
|
|
467
|
+
}
|
|
460
468
|
return backToPit(`install ${target}`, result.code);
|
|
461
469
|
}
|
|
462
470
|
if (cmd === "uninstall" || cmd === "remove") {
|
|
@@ -538,6 +546,10 @@ async function main() {
|
|
|
538
546
|
process.exitCode = (await dnsCommand(rest)) || 0;
|
|
539
547
|
return;
|
|
540
548
|
}
|
|
549
|
+
if (cmd === "name") {
|
|
550
|
+
process.exitCode = (await nameCommand(rest)) || 0;
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
541
553
|
if (cmd === "doh") {
|
|
542
554
|
const nameAt = rest.indexOf("--nginx");
|
|
543
555
|
if (nameAt >= 0) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moshcode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.67.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "moshcode — a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
|
|
6
6
|
"repository": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"scripts": {
|
|
19
19
|
"start": "node bin/moshcode.mjs",
|
|
20
20
|
"demo": "node bin/moshcode.mjs run examples/alive.mosh",
|
|
21
|
-
"test": "node --test"
|
|
21
|
+
"test": "node --test \"test/**/*.test.mjs\" \"tests/**/*.test.mjs\" \"apps/pwa/test/**/*.test.mjs\""
|
|
22
22
|
},
|
|
23
23
|
"files": [
|
|
24
24
|
"bin",
|
package/src/aliases.mjs
CHANGED
|
@@ -158,3 +158,68 @@ export function expandAlias(value, args = "") {
|
|
|
158
158
|
const line = `${String(value).trim()}${args ? ` ${args}` : ""}`;
|
|
159
159
|
return /^[/!]/.test(line) ? line : `!${line}`;
|
|
160
160
|
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* How many aliases one tool may offer in a single install.
|
|
164
|
+
*
|
|
165
|
+
* A bound, not a judgement about any tool: the proposal is a subprocess's
|
|
166
|
+
* stdout being written into the operator's config, and an unbounded loop over
|
|
167
|
+
* whatever it printed is how a bug in a tool becomes a thousand-line
|
|
168
|
+
* aliases.json. No CLI here offers more than a handful.
|
|
169
|
+
*/
|
|
170
|
+
export const MAX_PROPOSED = 64;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Merge a tool's proposed aliases into the operator's file, in one write.
|
|
174
|
+
*
|
|
175
|
+
* Existing names always win. The file is the operator's: silently repointing a
|
|
176
|
+
* word they bound themselves is the kind of change nothing surfaces until the
|
|
177
|
+
* wrong command runs — and the pit's own aliases carry flags (`--orgs`,
|
|
178
|
+
* `--apply`) that a tool's generic suggestion does not know about.
|
|
179
|
+
*
|
|
180
|
+
* Names that collide with a pit command, engine, or tool are refused rather
|
|
181
|
+
* than written, for the reason setAlias refuses them: built-ins are resolved
|
|
182
|
+
* first, so such an alias would be silently dead.
|
|
183
|
+
*
|
|
184
|
+
* Returns { ok, error, added, kept, refused } — each a list of
|
|
185
|
+
* { name, value, previous? , reason? } so the caller can say what happened
|
|
186
|
+
* without re-deriving it.
|
|
187
|
+
*/
|
|
188
|
+
export function mergeAliases(proposed, { isReserved = () => false } = {}) {
|
|
189
|
+
const added = [];
|
|
190
|
+
const kept = [];
|
|
191
|
+
const refused = [];
|
|
192
|
+
if (!proposed || typeof proposed !== "object" || Array.isArray(proposed)) {
|
|
193
|
+
return { ok: false, error: "that isn't a set of aliases", added, kept, refused };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const existing = loadAliases();
|
|
197
|
+
const merged = { ...existing };
|
|
198
|
+
// Sorted so the report reads the same way twice regardless of the order the
|
|
199
|
+
// tool happened to print, and truncated rather than refused: a tool that
|
|
200
|
+
// offers too many still gets its first MAX_PROPOSED written, and the caller
|
|
201
|
+
// is told what was dropped.
|
|
202
|
+
const entries = Object.entries(proposed).sort(([a], [b]) => a.localeCompare(b));
|
|
203
|
+
const dropped = Math.max(0, entries.length - MAX_PROPOSED);
|
|
204
|
+
|
|
205
|
+
for (const [rawName, rawValue] of entries.slice(0, MAX_PROPOSED)) {
|
|
206
|
+
const name = normalizeName(rawName);
|
|
207
|
+
const value = typeof rawValue === "string" ? rawValue.trim() : "";
|
|
208
|
+
if (!name) { refused.push({ name: String(rawName), reason: "not a usable alias name" }); continue; }
|
|
209
|
+
if (!value) { refused.push({ name, reason: "nothing to run" }); continue; }
|
|
210
|
+
if (value.includes("\n")) { refused.push({ name, reason: "an alias is a single line" }); continue; }
|
|
211
|
+
if (value.length > MAX_VALUE) { refused.push({ name, reason: `${value.length} characters — the cap is ${MAX_VALUE}` }); continue; }
|
|
212
|
+
if (isReserved(name)) { refused.push({ name, reason: "already a pit command, engine, or tool" }); continue; }
|
|
213
|
+
if (Object.hasOwn(existing, name)) { kept.push({ name, value: existing[name], proposed: value }); continue; }
|
|
214
|
+
merged[name] = value;
|
|
215
|
+
added.push({ name, value });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Nothing new is still a success — "already up to date" is the common case on
|
|
219
|
+
// a second run, and writing the file again to say so would only churn mtime.
|
|
220
|
+
if (added.length) {
|
|
221
|
+
try { saveAliases(merged); }
|
|
222
|
+
catch (e) { return { ok: false, error: `can't write ${aliasFile()}: ${e.message}`, added: [], kept, refused }; }
|
|
223
|
+
}
|
|
224
|
+
return { ok: true, added, kept, refused, dropped };
|
|
225
|
+
}
|
package/src/cli-schema.mjs
CHANGED
|
@@ -214,8 +214,11 @@ export const CORE_CLI_COMMANDS = [
|
|
|
214
214
|
examples: [
|
|
215
215
|
["moshcode install claude", "an engine"],
|
|
216
216
|
["moshcode install gh", "a workflow tool"],
|
|
217
|
+
["moshcode install cli-tools", "a set of commands — its pit aliases come too"],
|
|
217
218
|
],
|
|
218
219
|
seeAlso: ["uninstall", "upgrade", "engines", "tools"],
|
|
220
|
+
note: "a tool that ships a set of commands can propose pit aliases for them; "
|
|
221
|
+
+ "installing it adopts the ones you have not already bound yourself.",
|
|
219
222
|
},
|
|
220
223
|
{
|
|
221
224
|
name: "uninstall",
|
|
@@ -391,6 +394,22 @@ export const CORE_CLI_COMMANDS = [
|
|
|
391
394
|
],
|
|
392
395
|
seeAlso: ["doh", "site"],
|
|
393
396
|
},
|
|
397
|
+
{
|
|
398
|
+
name: "name",
|
|
399
|
+
group: "hosting",
|
|
400
|
+
description: "prove you hold a Moshpit name, so an app can use it as your identity",
|
|
401
|
+
synopsis: [["moshcode name link <name> [--app <url>]", ""]],
|
|
402
|
+
flags: [
|
|
403
|
+
["--app <url>", "the app to prove the name to", "https://qrypt.chat"],
|
|
404
|
+
["--dir <path>", "where the name's key and certificate live", "/etc/ssl/moshpit"],
|
|
405
|
+
["--json", "print the proof bundle as machine-readable JSON", ""],
|
|
406
|
+
],
|
|
407
|
+
examples: [
|
|
408
|
+
["sudo moshcode name link chovy.hacker", "the key is root-owned"],
|
|
409
|
+
["sudo moshcode name link blue.eggs --json", "hand the bundle to a script"],
|
|
410
|
+
],
|
|
411
|
+
seeAlso: ["dns", "site", "whoami"],
|
|
412
|
+
},
|
|
394
413
|
{
|
|
395
414
|
name: "doh",
|
|
396
415
|
group: "hosting",
|
|
@@ -1176,23 +1195,26 @@ export const PIT_COMMANDS = [
|
|
|
1176
1195
|
description: "show the current dir + git repo/branch/origin" },
|
|
1177
1196
|
{ name: "shell", aliases: ["sh"], args: "[cmd]", pitOnly: true,
|
|
1178
1197
|
description: "drop into $SHELL (exit → back to the pit); also !cmd" },
|
|
1179
|
-
{ name: "alias", aliases: ["aliases"], args: 'set <name> "<cmd>" | list | get | rm', pitOnly: true,
|
|
1198
|
+
{ name: "alias", aliases: ["aliases"], args: 'set <name> "<cmd>" | list | get | rm | install <tool>', pitOnly: true,
|
|
1180
1199
|
description: "name a line you keep retyping; /<name> runs it",
|
|
1181
1200
|
synopsis: [
|
|
1182
1201
|
['/alias set <name> "<command>"', "define one (also: /alias <name> \"<command>\")"],
|
|
1183
1202
|
["/alias [list] [--json]", "every alias"],
|
|
1184
1203
|
["/alias get <name>", "what one expands to"],
|
|
1185
1204
|
["/alias rm <name>", "forget one"],
|
|
1205
|
+
["/alias install <tool> | --all", "re-adopt a tool's aliases (/install does it too)"],
|
|
1186
1206
|
],
|
|
1187
1207
|
examples: [
|
|
1188
1208
|
['/alias set gs "git status"', "then /gs — and /gs -sb appends"],
|
|
1189
1209
|
// Deliberately not `cc`: that one is already how the pit spells claude,
|
|
1190
1210
|
// so the example would print a refusal for anyone who typed it.
|
|
1191
1211
|
['/alias set cx "/agents codex"', "a pit command, not a shell one"],
|
|
1212
|
+
["/alias install cli-tools", "/blog, /free, /whois — from the tool itself"],
|
|
1192
1213
|
["/alias rm gs", ""],
|
|
1193
1214
|
],
|
|
1194
1215
|
note: "the command runs in $SHELL unless it starts with / — then it is a pit command. "
|
|
1195
|
-
+ "Aliases live in ~/.moshcode/aliases.json and cannot shadow a pit command, engine, or tool."
|
|
1216
|
+
+ "Aliases live in ~/.moshcode/aliases.json and cannot shadow a pit command, engine, or tool. "
|
|
1217
|
+
+ "/alias install never overwrites a name you bound yourself.",
|
|
1196
1218
|
},
|
|
1197
1219
|
{ name: "help", aliases: ["?", "h"], args: "[command]", pitOnly: true,
|
|
1198
1220
|
description: "this, or one command in detail" },
|
package/src/commands.mjs
CHANGED
|
@@ -81,7 +81,7 @@ function expectNoArgs(name, args) {
|
|
|
81
81
|
* setAlias() takes) rather than an exported list, so this stays the caller's
|
|
82
82
|
* answer and not a second roster to drift from the first.
|
|
83
83
|
*/
|
|
84
|
-
function isReserved(name) {
|
|
84
|
+
export function isReserved(name) {
|
|
85
85
|
const key = String(name).toLowerCase();
|
|
86
86
|
return CORE_CLI_COMMAND_NAMES.includes(key)
|
|
87
87
|
|| PIT_COMMANDS.some((c) => (typeof c === "string" ? c : c.name) === key);
|
|
@@ -668,6 +668,7 @@ const COMMANDS = [
|
|
|
668
668
|
cliVerb("coinpay", "drive the coinpay workflow CLI"),
|
|
669
669
|
cliVerb("c0mpute", "drive the c0mpute workflow CLI"),
|
|
670
670
|
cliVerb("c0upons", "drive the c0upons workflow CLI"),
|
|
671
|
+
cliVerb("bo", "drive the BufferOverride CLI (capture a failure, search, ask, answer, verify)"),
|
|
671
672
|
cliVerb("secrets", "manage/view team secrets via logicsrc (login, teams, credentials)"),
|
|
672
673
|
cliVerb("railway", "drive the Railway CLI (deploys, services, env vars)"),
|
|
673
674
|
cliVerb("gh", "drive the GitHub CLI (repos, PRs, issues, releases)"),
|
|
@@ -681,6 +682,7 @@ const COMMANDS = [
|
|
|
681
682
|
cliVerb("alpaca", "drive the native Alpaca trading CLI"),
|
|
682
683
|
cliVerb("mcpjam", "drive the MCPJam CLI (test, debug, and validate MCP servers)"),
|
|
683
684
|
cliVerb("spinifex", "drive the Spinifex CLI (spx — AWS-compatible cloud on your own hardware)"),
|
|
685
|
+
cliVerb("alchemy", "drive the Alchemy CLI (onchain data, apps, wallets, x402)"),
|
|
684
686
|
cliVerb("trade", "look up tickers, inspect markets, and preview/place Alpaca orders"),
|
|
685
687
|
cliVerb("pwd", "print the current repo/location"),
|
|
686
688
|
|
package/src/integrations.mjs
CHANGED
|
@@ -238,9 +238,14 @@ export async function mcpCommand(tokens, { run, installedSet } = {}) {
|
|
|
238
238
|
const missing = (parsed.catalog?.env || []).filter((k) => !process.env[k]);
|
|
239
239
|
if (missing.length) {
|
|
240
240
|
console.log(ash(` note: ${spec.name} needs ${missing.join(" and ")} in the environment.`));
|
|
241
|
-
if (parsed.catalog?.note) console.log(ash(` ${parsed.catalog.note}`));
|
|
242
|
-
if (parsed.catalog?.docs) console.log(ash(` ${parsed.catalog.docs}`));
|
|
243
241
|
}
|
|
242
|
+
// The catalog's own note and docs are printed whenever the catalog was used,
|
|
243
|
+
// not only when a variable is missing. A server whose credential is a header
|
|
244
|
+
// rather than an environment variable — or one that needs none at all to be
|
|
245
|
+
// useful — has nothing in `env`, and hanging its note off that check is what
|
|
246
|
+
// made the note invisible for exactly the servers it was written for.
|
|
247
|
+
if (parsed.catalog?.note) console.log(ash(` note: ${parsed.catalog.note}`));
|
|
248
|
+
if (parsed.catalog?.docs) console.log(ash(` ${parsed.catalog.docs}`));
|
|
244
249
|
if (spec.headers.length || /^https?:/i.test(spec.target)) {
|
|
245
250
|
console.log(ash(" note: OAuth/HTTP servers may still need per-engine auth (e.g. `opencode mcp auth`, `codex mcp login`)."));
|
|
246
251
|
}
|
package/src/mcp-catalog.mjs
CHANGED
|
@@ -24,6 +24,23 @@ export const MCP_CATALOG = {
|
|
|
24
24
|
// before deciding whether to trust it with keys.
|
|
25
25
|
note: "API access is off by default and must be enabled per-domain; the docs tools work without keys",
|
|
26
26
|
},
|
|
27
|
+
bufferoverride: {
|
|
28
|
+
// A remote HTTP server, so the target is the URL and there are no args —
|
|
29
|
+
// every engine's builder pushes the target alone for a remote server.
|
|
30
|
+
target: "https://bufferoverride.com/mcp",
|
|
31
|
+
args: [],
|
|
32
|
+
desc: "BufferOverride — version-aware technical answers, with provenance and reproductions",
|
|
33
|
+
docs: "https://bufferoverride.com/docs/mcp",
|
|
34
|
+
// Named to match what the CLI's own `bo mcp config` emits, so registering
|
|
35
|
+
// it either way produces one server rather than two under different names.
|
|
36
|
+
//
|
|
37
|
+
// No `env`: the credential is a bearer header, not a variable, and it is
|
|
38
|
+
// deliberately not listed here. Five read tools work with no key at all,
|
|
39
|
+
// and the write tools are gated on the scopes a key actually carries — so
|
|
40
|
+
// the useful default really is unauthenticated. `bo mcp config` prints the
|
|
41
|
+
// header form for a terminal that has signed in.
|
|
42
|
+
note: "reads need no credential; to publish, add -H \"Authorization: Bearer bo_…\" (see `bo mcp config`)",
|
|
43
|
+
},
|
|
27
44
|
};
|
|
28
45
|
|
|
29
46
|
/** Resolve a catalog name to a spec fragment, or null. Own properties only. */
|
|
@@ -44,7 +61,10 @@ export function catalogNames() {
|
|
|
44
61
|
|
|
45
62
|
/** One line per known server, for `mcp catalog`. */
|
|
46
63
|
export function catalogList() {
|
|
64
|
+
// Width from the longest name rather than a fixed pad: `bufferoverride` is
|
|
65
|
+
// wider than the old 10, and a name that overruns the pad loses the column.
|
|
66
|
+
const width = Math.max(10, ...Object.keys(MCP_CATALOG).map((key) => key.length));
|
|
47
67
|
return Object.entries(MCP_CATALOG)
|
|
48
|
-
.map(([key, e]) => ` ${key.padEnd(
|
|
68
|
+
.map(([key, e]) => ` ${key.padEnd(width)} ${e.desc}`)
|
|
49
69
|
.join("\n");
|
|
50
70
|
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// `moshcode name link <name>` — prove you hold a Moshpit name, so an app can
|
|
2
|
+
// use it as your identity.
|
|
3
|
+
//
|
|
4
|
+
// The registry already publishes, per name, the SHA-256 of the SubjectPublicKeyInfo
|
|
5
|
+
// that name's certificate must present (see src/pins.mjs). That pin is a
|
|
6
|
+
// name-to-key binding nobody else can forge, which makes it a credential: sign
|
|
7
|
+
// something with the pinned key and you have proved you hold the name.
|
|
8
|
+
//
|
|
9
|
+
// What this does NOT do is generate the app's encryption key. The pinned key is
|
|
10
|
+
// P-256 and it signs; a messenger's key is ML-KEM-1024 and it encrypts, and a
|
|
11
|
+
// KEM key cannot sign at all. They are two keys with two jobs, and the private
|
|
12
|
+
// half of the second one belongs on the device the person actually reads
|
|
13
|
+
// messages on — not here. So this emits a proof bundle and stops. The app posts
|
|
14
|
+
// it alongside a key it generated itself and never showed anyone.
|
|
15
|
+
//
|
|
16
|
+
// The bundle is single-use and short-lived: the challenge it answers is burned
|
|
17
|
+
// on redemption, so a copy of it is worth nothing once used.
|
|
18
|
+
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import crypto from "node:crypto";
|
|
21
|
+
|
|
22
|
+
/** Where an app that speaks this protocol lives, unless told otherwise. */
|
|
23
|
+
export const DEFAULT_APP = "https://qrypt.chat";
|
|
24
|
+
|
|
25
|
+
/** Where moshcode's own tooling writes a name's key and certificate. */
|
|
26
|
+
export const DEFAULT_KEY_DIR = "/etc/ssl/moshpit";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A name is `<label>.<tld>`, lowercase.
|
|
30
|
+
*
|
|
31
|
+
* Empty segments are refused rather than collapsed. `chovy..hacker` folding
|
|
32
|
+
* into `chovy.hacker` is harmless in a filename and is a way in when the string
|
|
33
|
+
* is an identity.
|
|
34
|
+
*
|
|
35
|
+
* @param {unknown} input
|
|
36
|
+
* @returns {string | null}
|
|
37
|
+
*/
|
|
38
|
+
export function normalizeName(input) {
|
|
39
|
+
const clean = String(input ?? "").trim().toLowerCase().replace(/\.$/, "");
|
|
40
|
+
if (!clean) return null;
|
|
41
|
+
const parts = clean.split(".");
|
|
42
|
+
if (parts.length !== 2 || parts.some((p) => !p)) return null;
|
|
43
|
+
if (!parts.every((p) => /^[a-z0-9-]+$/.test(p) && !p.startsWith("-") && !p.endsWith("-"))) return null;
|
|
44
|
+
return parts.join(".");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Where a name's key and certificate live.
|
|
49
|
+
*
|
|
50
|
+
* Mirrors keyPaths() in src/pins.mjs, including that separators which could
|
|
51
|
+
* climb out of the directory are dropped rather than escaped.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} name
|
|
54
|
+
* @param {string} [dir]
|
|
55
|
+
*/
|
|
56
|
+
export function keyPaths(name, dir = DEFAULT_KEY_DIR) {
|
|
57
|
+
const safe = String(name ?? "")
|
|
58
|
+
.toLowerCase()
|
|
59
|
+
.replace(/[^a-z0-9.-]/g, "")
|
|
60
|
+
.replace(/\.{2,}/g, ".")
|
|
61
|
+
.replace(/^[.-]+|[.-]+$/g, "");
|
|
62
|
+
if (!safe) return null;
|
|
63
|
+
return { key: `${dir}/${safe}.key`, cert: `${dir}/${safe}.crt` };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Sign a challenge with a name's private key.
|
|
68
|
+
*
|
|
69
|
+
* DER, because that is what `crypto.verify` reads back with `dsaEncoding: 'der'`
|
|
70
|
+
* on the other side. A P-1363 signature is the same numbers in a shape the
|
|
71
|
+
* verifier rejects, and the failure looks identical to a wrong key.
|
|
72
|
+
*
|
|
73
|
+
* @param {{keyPem: string, nonce: string}} args
|
|
74
|
+
* @returns {string} base64 signature
|
|
75
|
+
*/
|
|
76
|
+
export function signChallenge({ keyPem, nonce }) {
|
|
77
|
+
const key = crypto.createPrivateKey(keyPem);
|
|
78
|
+
return crypto
|
|
79
|
+
.sign("sha256", Buffer.from(nonce, "utf8"), { key, dsaEncoding: "der" })
|
|
80
|
+
.toString("base64");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The pin a certificate's key hashes to — SHA-256 over the SPKI, base64.
|
|
85
|
+
* Shown so the operator can eyeball it against what the registry publishes
|
|
86
|
+
* before wondering why a proof was refused.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} certPem
|
|
89
|
+
*/
|
|
90
|
+
export function pinOf(certPem) {
|
|
91
|
+
const cert = new crypto.X509Certificate(certPem);
|
|
92
|
+
const der = cert.publicKey.export({ type: "spki", format: "der" });
|
|
93
|
+
return crypto.createHash("sha256").update(der).digest("base64");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Ask an app for a challenge to sign.
|
|
98
|
+
*
|
|
99
|
+
* @param {{app: string, name: string, fetchImpl?: typeof fetch}} args
|
|
100
|
+
*/
|
|
101
|
+
export async function fetchChallenge({ app, name, fetchImpl = fetch }) {
|
|
102
|
+
const url = `${app.replace(/\/+$/, "")}/api/auth/moshpit/challenge`;
|
|
103
|
+
const res = await fetchImpl(url, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers: { "content-type": "application/json" },
|
|
106
|
+
body: JSON.stringify({ name }),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
let body = null;
|
|
110
|
+
try {
|
|
111
|
+
body = await res.json();
|
|
112
|
+
} catch {
|
|
113
|
+
body = null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!res.ok) {
|
|
117
|
+
throw new Error(body?.error || `${app} answered ${res.status}`);
|
|
118
|
+
}
|
|
119
|
+
if (!body?.jti || !body?.nonce) {
|
|
120
|
+
throw new Error(`${app} did not return a challenge`);
|
|
121
|
+
}
|
|
122
|
+
return body;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Read a name's key and certificate off disk, with the failure a person can act on.
|
|
127
|
+
*
|
|
128
|
+
* The key is root-owned, so "permission denied" is the expected first
|
|
129
|
+
* experience and deserves the fix rather than the errno.
|
|
130
|
+
*
|
|
131
|
+
* @param {{name: string, keyPath: string, certPath: string, readFile?: (p: string, e: string) => string}} args
|
|
132
|
+
*/
|
|
133
|
+
export function readNameKey({ name, keyPath, certPath, readFile = (p, e) => fs.readFileSync(p, e) }) {
|
|
134
|
+
let keyPem;
|
|
135
|
+
try {
|
|
136
|
+
keyPem = readFile(keyPath, "utf8");
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error.code === "EACCES") {
|
|
139
|
+
throw new Error(`cannot read ${keyPath} — it is root-owned, so run this with sudo`);
|
|
140
|
+
}
|
|
141
|
+
if (error.code === "ENOENT") {
|
|
142
|
+
throw new Error(`no key for ${name} at ${keyPath} — mint one before linking it`);
|
|
143
|
+
}
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let certPem;
|
|
148
|
+
try {
|
|
149
|
+
certPem = readFile(certPath, "utf8");
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (error.code === "ENOENT") {
|
|
152
|
+
throw new Error(`no certificate for ${name} at ${certPath}`);
|
|
153
|
+
}
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { keyPem, certPem };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Parse `name link <name> [--app url] [--dir path] [--json]`.
|
|
162
|
+
* @param {string[]} argv
|
|
163
|
+
*/
|
|
164
|
+
export function parseArgs(argv) {
|
|
165
|
+
const flag = (name, fallback) => {
|
|
166
|
+
const at = argv.indexOf(`--${name}`);
|
|
167
|
+
return at >= 0 && argv[at + 1] ? argv[at + 1] : fallback;
|
|
168
|
+
};
|
|
169
|
+
const positional = argv.filter((a, i) => {
|
|
170
|
+
if (a.startsWith("--")) return false;
|
|
171
|
+
// Skip a value that belongs to the flag before it.
|
|
172
|
+
return !(i > 0 && argv[i - 1].startsWith("--") && argv[i - 1] !== "--json");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
verb: positional[0] || "",
|
|
177
|
+
name: positional[1] || "",
|
|
178
|
+
app: flag("app", DEFAULT_APP),
|
|
179
|
+
dir: flag("dir", DEFAULT_KEY_DIR),
|
|
180
|
+
json: argv.includes("--json"),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* `moshcode name link <name>` — fetch a challenge, sign it, print the bundle.
|
|
186
|
+
*
|
|
187
|
+
* @param {string[]} argv
|
|
188
|
+
* @param {{out?: (s: string) => void, err?: (s: string) => void, fetchImpl?: typeof fetch,
|
|
189
|
+
* readFile?: (p: string, e: string) => string}} [io]
|
|
190
|
+
* @returns {Promise<number>} exit code
|
|
191
|
+
*/
|
|
192
|
+
export async function nameCommand(argv, io = {}) {
|
|
193
|
+
const out = io.out || ((s) => console.log(s));
|
|
194
|
+
const err = io.err || ((s) => console.error(s));
|
|
195
|
+
const { verb, name: raw, app, dir, json } = parseArgs(argv);
|
|
196
|
+
|
|
197
|
+
if (verb !== "link") {
|
|
198
|
+
err("usage: moshcode name link <name> [--app <url>] [--dir <path>] [--json]");
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const name = normalizeName(raw);
|
|
203
|
+
if (!name) {
|
|
204
|
+
err(`not a Moshpit name: ${raw || "(none)"} — expected <label>.<tld>`);
|
|
205
|
+
return 1;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const paths = keyPaths(name, dir);
|
|
209
|
+
if (!paths) {
|
|
210
|
+
err(`not a Moshpit name: ${raw}`);
|
|
211
|
+
return 1;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let keyPem;
|
|
215
|
+
let certPem;
|
|
216
|
+
try {
|
|
217
|
+
({ keyPem, certPem } = readNameKey({
|
|
218
|
+
name,
|
|
219
|
+
keyPath: paths.key,
|
|
220
|
+
certPath: paths.cert,
|
|
221
|
+
readFile: io.readFile,
|
|
222
|
+
}));
|
|
223
|
+
} catch (error) {
|
|
224
|
+
err(error.message);
|
|
225
|
+
return 1;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let challenge;
|
|
229
|
+
try {
|
|
230
|
+
challenge = await fetchChallenge({ app, name, fetchImpl: io.fetchImpl });
|
|
231
|
+
} catch (error) {
|
|
232
|
+
err(`could not get a challenge from ${app}: ${error.message}`);
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let signature;
|
|
237
|
+
try {
|
|
238
|
+
signature = signChallenge({ keyPem, nonce: challenge.nonce });
|
|
239
|
+
} catch (error) {
|
|
240
|
+
err(`could not sign with ${paths.key}: ${error.message}`);
|
|
241
|
+
return 1;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const bundle = { jti: challenge.jti, name, certPem, signature };
|
|
245
|
+
|
|
246
|
+
if (json) {
|
|
247
|
+
out(JSON.stringify(bundle));
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
out(`Proved ${name} against ${app}`);
|
|
252
|
+
out(` pin ${pinOf(certPem)}`);
|
|
253
|
+
out(` expires ${challenge.expiresAt || "shortly"}`);
|
|
254
|
+
out("");
|
|
255
|
+
out("Paste this into the app to finish linking. It is single-use:");
|
|
256
|
+
out("");
|
|
257
|
+
out(JSON.stringify(bundle));
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
package/src/tools.mjs
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
// c0upons owns community coupons and bounties, the cloud CLIs below own
|
|
5
5
|
// deploys/secrets/infra, Coral owns read-only data access across those systems,
|
|
6
6
|
// and moshcode only conducts their native command lines.
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
7
8
|
import { homedir } from "node:os";
|
|
8
9
|
import path from "node:path";
|
|
9
10
|
import { fileURLToPath } from "node:url";
|
|
10
11
|
|
|
12
|
+
import { mergeAliases } from "./aliases.mjs";
|
|
11
13
|
import { isInstalled, openPassthrough } from "./engines.mjs";
|
|
12
14
|
|
|
13
15
|
// gh, supabase, and doctl publish only GitHub release binaries — no official
|
|
@@ -69,6 +71,29 @@ export const TOOLS = {
|
|
|
69
71
|
// elsewhere, and `cli-tools update` refuses to move a dirty or diverged
|
|
70
72
|
// tree rather than discarding work.
|
|
71
73
|
upgrade: { cmd: "cli-tools", args: ["update"] },
|
|
74
|
+
// The pit aliases this set offers. Read at the end of /install and
|
|
75
|
+
// /upgrade, so installing the set is also configuring it — seven commands
|
|
76
|
+
// behind one dispatcher are not reachable from the pit until the words that
|
|
77
|
+
// reach them exist. `/alias install cli-tools` re-runs it on demand.
|
|
78
|
+
//
|
|
79
|
+
// Declared rather than probed: a command that prints a set of aliases is
|
|
80
|
+
// only safe to run against a tool we already know answers it, and an
|
|
81
|
+
// `aliases --json` guessed at every installed CLI would eventually hit one
|
|
82
|
+
// where those words mean something else entirely.
|
|
83
|
+
aliases: { cmd: "cli-tools", args: ["aliases", "--json"] },
|
|
84
|
+
},
|
|
85
|
+
bo: {
|
|
86
|
+
desc: "BufferOverride — capture a failing command, redact it, and find the answer that already exists",
|
|
87
|
+
// The product is BufferOverride and the binary is `bo`, the same split
|
|
88
|
+
// `secrets` → `logicsrc` and `spinifex` → `spx` have. It is keyed the other
|
|
89
|
+
// way round from those two on purpose: `bo` is what its own documentation
|
|
90
|
+
// tells you to type, and this is a command you run every time something
|
|
91
|
+
// fails, so the short word is the one worth having in the pit.
|
|
92
|
+
bin: "bo",
|
|
93
|
+
// An ordinary global npm package with no dependencies of its own, and
|
|
94
|
+
// `npm install -g` is idempotent, so re-running the install IS the upgrade
|
|
95
|
+
// — no `upgrade` key, the same as mcpjam and railway.
|
|
96
|
+
install: { cmd: "npm", args: ["install", "-g", "@profullstack/bufferoverride"] },
|
|
72
97
|
},
|
|
73
98
|
secrets: {
|
|
74
99
|
desc: "LogicSRC — end-to-end-encrypted team credential sharing (login, teams, credentials)",
|
|
@@ -269,6 +294,22 @@ export const TOOLS = {
|
|
|
269
294
|
// migrations, and restarts the services. toolUpgradeSpec falls back to
|
|
270
295
|
// install, so there is deliberately no upgrade key.
|
|
271
296
|
},
|
|
297
|
+
alchemy: {
|
|
298
|
+
desc: "Alchemy — onchain data, apps and webhooks, agent wallets, and x402 payments (EVM + Solana)",
|
|
299
|
+
bin: "alchemy",
|
|
300
|
+
// An ordinary global npm package, and `npm install -g` is idempotent, so
|
|
301
|
+
// re-running the install IS the upgrade — same shape as mcpjam, hence no
|
|
302
|
+
// upgrade key. Its postinstall only prints a banner on a global install:
|
|
303
|
+
// the skill-sync branch above it needs a skills-lock.json that the
|
|
304
|
+
// published tarball does not ship, so nothing reaches out or is written.
|
|
305
|
+
//
|
|
306
|
+
// No installHelp: that line is for a MISSING installer (alpaca's go,
|
|
307
|
+
// gradient's python3), and npm is already here — moshcode runs on it. The
|
|
308
|
+
// package does declare node >=22, which npm warns about rather than
|
|
309
|
+
// refuses; the CLI then says so itself on first run, which is a better
|
|
310
|
+
// place to hear it than an install that succeeded.
|
|
311
|
+
install: { cmd: "npm", args: ["install", "-g", "@alchemy/cli"] },
|
|
312
|
+
},
|
|
272
313
|
};
|
|
273
314
|
|
|
274
315
|
/** Resolve a name to `[key, tool]`, or null. */
|
|
@@ -306,6 +347,90 @@ export function openTool(tool, args = [], opts = {}) {
|
|
|
306
347
|
return openPassthrough(tool, args, opts);
|
|
307
348
|
}
|
|
308
349
|
|
|
350
|
+
/** The command that prints a tool's proposed pit aliases, or null. */
|
|
351
|
+
export function toolAliasSpec(tool) {
|
|
352
|
+
return tool?.aliases || null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Adopt a tool's aliases and describe the result in plain lines.
|
|
357
|
+
*
|
|
358
|
+
* The unpainted counterpart to the pit's renderer, for `moshcode install` —
|
|
359
|
+
* which is a plain-stdout surface, and may be a script's stdout. Returns [] for
|
|
360
|
+
* everything with nothing to say, so hanging this off an install costs a tool
|
|
361
|
+
* that offers no aliases exactly one function call and no output.
|
|
362
|
+
*
|
|
363
|
+
* Silent on failure by design: an installer that succeeded must not be followed
|
|
364
|
+
* by an error about a nicety, and `/alias install <tool>` says the same thing
|
|
365
|
+
* loudly for anyone who goes looking.
|
|
366
|
+
*/
|
|
367
|
+
export function adoptAliasLines(key, tool, { isReserved = () => false, read = readToolAliases } = {}) {
|
|
368
|
+
if (!toolAliasSpec(tool)) return [];
|
|
369
|
+
const answer = read(tool);
|
|
370
|
+
if (!answer.ok) return [];
|
|
371
|
+
const result = mergeAliases(answer.aliases, { isReserved });
|
|
372
|
+
if (!result.ok || !result.added.length) return [];
|
|
373
|
+
return [
|
|
374
|
+
`\n${key} also offers ${result.added.length} pit alias${result.added.length === 1 ? "" : "es"}:`,
|
|
375
|
+
...result.added.map(({ name, value }) => ` /${name} → ${value}`),
|
|
376
|
+
...(result.kept.length ? [` (kept ${result.kept.length} you had already bound)`] : []),
|
|
377
|
+
];
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Every tool that offers pit aliases, as `[key, tool]`. */
|
|
381
|
+
export function toolsWithAliases() {
|
|
382
|
+
return Object.entries(TOOLS).filter(([, tool]) => toolAliasSpec(tool));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* How long a tool gets to print its aliases before we stop waiting.
|
|
387
|
+
*
|
|
388
|
+
* This runs on an interactive verb, so the failure we care about is a CLI that
|
|
389
|
+
* blocks on something — a login prompt, a network read — rather than one that
|
|
390
|
+
* is merely slow. Printing a constant should take milliseconds.
|
|
391
|
+
*/
|
|
392
|
+
const ALIAS_TIMEOUT_MS = 10_000;
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Ask a tool for the pit aliases it proposes: `{ ok, aliases, error }`.
|
|
396
|
+
*
|
|
397
|
+
* Captured rather than passed through, because the output is data we are about
|
|
398
|
+
* to merge into the operator's config and not something to put on their
|
|
399
|
+
* screen. Every failure is a returned reason instead of a throw — a tool that
|
|
400
|
+
* is not installed, prints nothing, or prints something that is not JSON is an
|
|
401
|
+
* ordinary outcome of this verb, not an error the pit should fall over on.
|
|
402
|
+
*
|
|
403
|
+
* `run` is injectable so the tests do not need seven CLIs on PATH.
|
|
404
|
+
*/
|
|
405
|
+
export function readToolAliases(tool, { run = spawnSync } = {}) {
|
|
406
|
+
const spec = toolAliasSpec(tool);
|
|
407
|
+
if (!spec) return { ok: false, error: "offers no aliases" };
|
|
408
|
+
let result;
|
|
409
|
+
try {
|
|
410
|
+
result = run(spec.cmd, spec.args, {
|
|
411
|
+
encoding: "utf8",
|
|
412
|
+
timeout: ALIAS_TIMEOUT_MS,
|
|
413
|
+
// No inherited stdin: a tool that decides to ask a question here would
|
|
414
|
+
// otherwise hang the pit on a prompt nobody can see.
|
|
415
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
416
|
+
});
|
|
417
|
+
} catch (e) {
|
|
418
|
+
return { ok: false, error: e.message };
|
|
419
|
+
}
|
|
420
|
+
if (result.error) return { ok: false, error: result.error.message };
|
|
421
|
+
if (result.status !== 0) {
|
|
422
|
+
const said = String(result.stderr || "").trim().split("\n")[0];
|
|
423
|
+
return { ok: false, error: said || `${spec.cmd} exited ${result.status}` };
|
|
424
|
+
}
|
|
425
|
+
let parsed;
|
|
426
|
+
try { parsed = JSON.parse(String(result.stdout || "")); }
|
|
427
|
+
catch { return { ok: false, error: `${spec.cmd} didn't print JSON` }; }
|
|
428
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
429
|
+
return { ok: false, error: `${spec.cmd} printed JSON, but not a set of aliases` };
|
|
430
|
+
}
|
|
431
|
+
return { ok: true, aliases: parsed };
|
|
432
|
+
}
|
|
433
|
+
|
|
309
434
|
// Generic utilities used by the app/package surface.
|
|
310
435
|
|
|
311
436
|
/**
|
package/src/tui.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import fs from "node:fs";
|
|
|
8
8
|
import os from "node:os";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { ENGINES, agentLaunchArgs, resolveEngine, engineStatus, openSession } from "./engines.mjs";
|
|
11
|
-
import { TOOLS, resolveTool, toolStatus, openTool } from "./tools.mjs";
|
|
11
|
+
import { TOOLS, resolveTool, toolStatus, openTool, readToolAliases, toolsWithAliases } from "./tools.mjs";
|
|
12
12
|
import { tradeArgs, tradeUsage } from "./trade.mjs";
|
|
13
13
|
import { postSocial, socialRoster } from "./socials.mjs";
|
|
14
14
|
import { runUpgrade } from "./upgrade.mjs";
|
|
@@ -31,7 +31,7 @@ import { banner, hr, acid, ash, bone, dim, ok, err, warn, info, moshcodeVersion
|
|
|
31
31
|
import { CORE_CLI_COMMAND_NAMES } from "./cli-schema.mjs";
|
|
32
32
|
import { RENAMED_COMMANDS, findPitCommand, pitHelpModel, renderPitCommand, suggest, wantsHelp } from "./help.mjs";
|
|
33
33
|
import { openNewTab } from "./tabs.mjs";
|
|
34
|
-
import { MAX_EXPANSIONS, expandAlias, getAlias, loadAliases, removeAlias, setAlias } from "./aliases.mjs";
|
|
34
|
+
import { MAX_EXPANSIONS, expandAlias, getAlias, loadAliases, mergeAliases, removeAlias, setAlias } from "./aliases.mjs";
|
|
35
35
|
import { herdCommand, herdStart, renderRoster, roster, splitDetachArgs } from "./herd-cli.mjs";
|
|
36
36
|
import { detectSubstrate, substrateNote } from "./herd.mjs";
|
|
37
37
|
|
|
@@ -269,6 +269,115 @@ function printAliases({ json = false } = {}) {
|
|
|
269
269
|
}
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
+
/**
|
|
273
|
+
* `/alias install <tool>` — adopt the pit aliases a workflow tool offers.
|
|
274
|
+
*
|
|
275
|
+
* The tools in src/tools.mjs are separate products with their own release
|
|
276
|
+
* cycles, and several of them ship a set of commands rather than one binary.
|
|
277
|
+
* Which short words those deserve at this prompt is a question only the tool
|
|
278
|
+
* can answer, and only moshcode can act on: the file is ours, so a tool that
|
|
279
|
+
* wrote it directly would be reaching into a config it does not own — the same
|
|
280
|
+
* objection that keeps `railway setup agent` out of /install.
|
|
281
|
+
*
|
|
282
|
+
* So the tool proposes and the pit disposes. Deliberately its own verb rather
|
|
283
|
+
* than a step inside /install: writing the operator's aliases is a side effect
|
|
284
|
+
* an install command has no business having, and the roster is worth adopting
|
|
285
|
+
* long after the day a tool was installed.
|
|
286
|
+
*/
|
|
287
|
+
function aliasInstallCommand(args) {
|
|
288
|
+
const all = args.includes("--all");
|
|
289
|
+
const names = args.filter((a) => !a.startsWith("-"));
|
|
290
|
+
if (!all && !names.length) {
|
|
291
|
+
const offered = toolsWithAliases().map(([key]) => key);
|
|
292
|
+
console.log(err("usage: /alias install <tool> | --all"));
|
|
293
|
+
console.log(ash(offered.length
|
|
294
|
+
? ` tools that offer aliases: ${offered.join(", ")}`
|
|
295
|
+
: " no tool offers aliases yet"));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const wanted = all
|
|
300
|
+
? toolsWithAliases()
|
|
301
|
+
: names.map((name) => [name, resolveTool(name)?.[1] ?? null]);
|
|
302
|
+
|
|
303
|
+
// A run over --all reports per tool, because "3 added, 2 kept" for a roster
|
|
304
|
+
// is the useful shape; a single named tool reports per alias, because those
|
|
305
|
+
// are the words you are about to type.
|
|
306
|
+
let touched = 0;
|
|
307
|
+
for (const [key, tool] of wanted) {
|
|
308
|
+
if (!tool) { console.log(err(`no tool named "${key}" — /tools for the roster`)); continue; }
|
|
309
|
+
touched += adoptToolAliases(key, tool, { compact: all, quiet: false });
|
|
310
|
+
}
|
|
311
|
+
if (touched) console.log(ash(" run one with /<name> · /alias list for all of them"));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Read one tool's proposed aliases, merge them, and say what happened.
|
|
316
|
+
*
|
|
317
|
+
* The one place that does this, because three surfaces need it: `/install` and
|
|
318
|
+
* `/tools install` at the end of an install, `/upgrade` after a tool has gained
|
|
319
|
+
* commands, and `/alias install` on its own. Returns how many names were added
|
|
320
|
+
* so a caller can decide whether the run is worth a closing line.
|
|
321
|
+
*
|
|
322
|
+
* `quiet` is what makes it safe to hang off an install: a tool that offers
|
|
323
|
+
* nothing, or that cannot be asked, must not print a failure after a install
|
|
324
|
+
* that actually succeeded. Only real adoptions and genuine surprises speak up.
|
|
325
|
+
*/
|
|
326
|
+
function adoptToolAliases(key, tool, { compact = false, quiet = false } = {}) {
|
|
327
|
+
const label = acid(`/${key}`);
|
|
328
|
+
if (!tool?.aliases) {
|
|
329
|
+
if (!quiet) console.log(info(`${label} offers no aliases`));
|
|
330
|
+
return 0;
|
|
331
|
+
}
|
|
332
|
+
const read = readToolAliases(tool);
|
|
333
|
+
if (!read.ok) {
|
|
334
|
+
if (quiet) return 0;
|
|
335
|
+
console.log(err(`${label} — ${read.error}`));
|
|
336
|
+
if (!isInstalledTool(key)) console.log(ash(` not installed here — /install ${key}`));
|
|
337
|
+
return 0;
|
|
338
|
+
}
|
|
339
|
+
const result = mergeAliases(read.aliases, { isReserved: isReservedName });
|
|
340
|
+
if (!result.ok) {
|
|
341
|
+
if (!quiet) console.log(err(`${label} — ${result.error}`));
|
|
342
|
+
return 0;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (compact) {
|
|
346
|
+
const parts = [`${result.added.length} added`];
|
|
347
|
+
if (result.kept.length) parts.push(`${result.kept.length} kept`);
|
|
348
|
+
if (result.refused.length) parts.push(`${result.refused.length} refused`);
|
|
349
|
+
console.log(` ${label.padEnd(20)} ${ash(parts.join(", "))}`);
|
|
350
|
+
return result.added.length;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
for (const { name, value } of result.added) {
|
|
354
|
+
console.log(` ${ok(`${acid(`/${name}`)} ${ash("→")} ${bone(value)}`)}`);
|
|
355
|
+
}
|
|
356
|
+
// Named rather than counted: an alias the operator already owns is the one
|
|
357
|
+
// case where nothing changed *and* they need to know which word it was,
|
|
358
|
+
// because theirs and the tool's suggestion are both plausible. Suppressed
|
|
359
|
+
// after an install, where a list of names that did not change is noise
|
|
360
|
+
// between the installer's output and the prompt.
|
|
361
|
+
if (!quiet) {
|
|
362
|
+
for (const { name, value } of result.kept) {
|
|
363
|
+
console.log(` ${info(`kept your own ${acid(`/${name}`)} ${ash(`(${value})`)}`)}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
for (const { name, reason } of result.refused) {
|
|
367
|
+
console.log(` ${warn(`skipped ${acid(`/${name}`)} ${ash(`— ${reason}`)}`)}`);
|
|
368
|
+
}
|
|
369
|
+
if (result.dropped) console.log(ash(` ${result.dropped} more offered than one run writes`));
|
|
370
|
+
if (!quiet && !result.added.length && !result.kept.length && !result.refused.length) {
|
|
371
|
+
console.log(info(`${label} offers no aliases`));
|
|
372
|
+
}
|
|
373
|
+
return result.added.length;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Is this tool's native executable present? Used only to explain a failure. */
|
|
377
|
+
function isInstalledTool(key) {
|
|
378
|
+
return Boolean(toolStatus().find((entry) => entry.key === key)?.installed);
|
|
379
|
+
}
|
|
380
|
+
|
|
272
381
|
/**
|
|
273
382
|
* `/alias` — define, list, and forget the shortcuts (src/aliases.mjs).
|
|
274
383
|
*
|
|
@@ -289,6 +398,13 @@ function aliasCommand(rest, line) {
|
|
|
289
398
|
printAliases({ json });
|
|
290
399
|
return;
|
|
291
400
|
}
|
|
401
|
+
// Before `set`, because `install` is a verb and not a name: falling through
|
|
402
|
+
// to the bare-`/alias <name> <value>` shorthand would define an alias called
|
|
403
|
+
// "install" pointing at whatever came next.
|
|
404
|
+
if (sub === "install" || sub === "adopt") {
|
|
405
|
+
aliasInstallCommand(args);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
292
408
|
if (sub === "set" || sub === "add") {
|
|
293
409
|
const name = args[0];
|
|
294
410
|
const value = aliasValue(line);
|
|
@@ -322,7 +438,7 @@ function aliasCommand(rest, line) {
|
|
|
322
438
|
// when there is a value after it, or `/alias gs` would silently define
|
|
323
439
|
// nothing.
|
|
324
440
|
if (args.length) { aliasCommand(["set", ...rest], `/alias set ${commandRemainder(line)}`); return; }
|
|
325
|
-
console.log(err(`unknown /alias verb "${verb}" — set, list, get, rm`));
|
|
441
|
+
console.log(err(`unknown /alias verb "${verb}" — set, list, get, rm, install`));
|
|
326
442
|
}
|
|
327
443
|
|
|
328
444
|
/**
|
|
@@ -446,6 +562,16 @@ function printPwd() {
|
|
|
446
562
|
async function upgradeAll(targets) {
|
|
447
563
|
console.log(info(`upgrading ${bone("moshcode")} + installed engines/tools — hand-off to each updater…`));
|
|
448
564
|
await runUpgrade(targets, { log: (s) => console.log(s), rule: () => console.log(hr()) });
|
|
565
|
+
// An upgrade is where a tool *gains* commands, so it is the moment its new
|
|
566
|
+
// shortcuts should appear — a roster adopted once at install time otherwise
|
|
567
|
+
// goes stale the first time the tool ships something new. Quiet and
|
|
568
|
+
// never-overwrite, exactly as at install.
|
|
569
|
+
const wanted = targets?.length
|
|
570
|
+
? toolsWithAliases().filter(([key]) => targets.includes(key))
|
|
571
|
+
: toolsWithAliases();
|
|
572
|
+
let added = 0;
|
|
573
|
+
for (const [key, tool] of wanted) added += adoptToolAliases(key, tool, { quiet: true });
|
|
574
|
+
if (added) console.log(ash(` ${added} new alias${added === 1 ? "" : "es"} · /alias list for all of them`));
|
|
449
575
|
}
|
|
450
576
|
|
|
451
577
|
// The live mirror for this pit, once /sessions is watching. Module-level so the
|
|
@@ -599,7 +725,18 @@ function installTarget(key) {
|
|
|
599
725
|
if (e.code === "ENOENT" && target.installHelp) console.log(info(target.installHelp));
|
|
600
726
|
resolve();
|
|
601
727
|
});
|
|
602
|
-
child.on("exit", (code) => {
|
|
728
|
+
child.on("exit", (code) => {
|
|
729
|
+
console.log(hr());
|
|
730
|
+
if (code !== 0) { console.log(err(`install exited ${code}`)); return resolve(); }
|
|
731
|
+
console.log(ok(`${key} installed. 🤘`));
|
|
732
|
+
// Installing a tool is also configuring it: a set of commands is not
|
|
733
|
+
// usable from the pit until the words that reach them exist. Quiet, so a
|
|
734
|
+
// tool with nothing to offer — every engine, and most tools — finishes
|
|
735
|
+
// exactly as it did before. Names you bound yourself are never touched.
|
|
736
|
+
const added = Object.hasOwn(TOOLS, key) ? adoptToolAliases(key, TOOLS[key], { quiet: true }) : 0;
|
|
737
|
+
if (added) console.log(ash(` ${added} alias${added === 1 ? "" : "es"} from ${key} · /alias list for all of them`));
|
|
738
|
+
resolve();
|
|
739
|
+
});
|
|
603
740
|
});
|
|
604
741
|
}
|
|
605
742
|
|
|
@@ -892,6 +1029,18 @@ export async function tui() {
|
|
|
892
1029
|
}
|
|
893
1030
|
if (cmd === "tools") {
|
|
894
1031
|
if (!rest[0]) { printTools(); continue; }
|
|
1032
|
+
// `/tools install <name>` reads as the obvious spelling to anyone who has
|
|
1033
|
+
// just been shown the roster by `/tools`, and resolveTool would otherwise
|
|
1034
|
+
// answer it with `unknown tool "install"` — a dead end pointing at the
|
|
1035
|
+
// wrong word. Same for the verbs that pair with it.
|
|
1036
|
+
if (["install", "upgrade", "update"].includes(rest[0].toLowerCase()) && rest[1]) {
|
|
1037
|
+
const verb = rest[0].toLowerCase();
|
|
1038
|
+
rl.close();
|
|
1039
|
+
if (verb === "install") await installTarget(rest[1].toLowerCase());
|
|
1040
|
+
else await upgradeAll(rest.slice(1).map((r) => r.toLowerCase()));
|
|
1041
|
+
rl = mkrl();
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
895
1044
|
const resolved = resolveTool(rest[0]);
|
|
896
1045
|
if (!resolved) { console.log(err(`unknown tool "${rest[0]}". try: ${Object.keys(TOOLS).join(", ")}`)); continue; }
|
|
897
1046
|
const [key, tool] = resolved;
|