moshcode 0.64.0 → 0.66.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 CHANGED
@@ -665,6 +665,32 @@ engines, `mcpjam` tells you whether that server is healthy first — health
665
665
  checks, OAuth conformance, tool-surface diffing, and structured triage from the
666
666
  terminal or CI. Re-running `moshcode install mcpjam` is also its upgrade path.
667
667
 
668
+ ### Alchemy — onchain data, wallets, and x402
669
+
670
+ ```sh
671
+ moshcode install alchemy # npm i -g @alchemy/cli
672
+
673
+ moshcode alchemy auth # browser login, then pick an app
674
+ moshcode alchemy evm balance --address 0x…
675
+ moshcode alchemy --json --no-interactive wallet send …
676
+ ```
677
+
678
+ [Alchemy](https://www.alchemy.com/)'s CLI covers four things from one binary:
679
+ querying onchain data across EVM and Solana (balances, NFTs, transfers, prices,
680
+ blocks, logs, traces, simulations, raw RPC), managing Alchemy apps, networks,
681
+ allowlists and webhooks, driving an agent-ready wallet (sends, swaps, contract
682
+ calls, approvals, cross-chain bridges), and paying third-party x402 APIs in
683
+ USDC under a spend cap.
684
+
685
+ `alchemy auth` opens a browser to link your account and saves the selected app's
686
+ API key; API keys and x402 wallet auth work too, depending on the command. Pass
687
+ `--json --no-interactive` when a script or agent is driving, which is also what
688
+ makes it read like the rest of the roster in a pipeline. It needs Node 22 or
689
+ newer, and re-running `moshcode install alchemy` is its upgrade path.
690
+
691
+ Where CoinPay is the payments product MoshCode ships alongside, Alchemy is the
692
+ read side of the same world — the chain itself rather than one wallet's ledger.
693
+
668
694
  `gh`, `supabase`, and `doctl` publish no cross-platform install script, so
669
695
  MoshCode resolves the latest GitHub release and drops the binary in
670
696
  `$MOSHCODE_BIN` (default `~/.local/bin`) — no sudo, no package manager. Set
@@ -805,6 +831,29 @@ survive between sessions. A name that is already a pit command, an engine, or a
805
831
  tool is refused rather than shadowed — built-ins are dispatched first, so such
806
832
  an alias would never run.
807
833
 
834
+ Some workflow tools ship a *set* of commands rather than one binary, and propose
835
+ short words for them. Installing such a tool configures those words too — a
836
+ dispatcher fronting seven commands is not reachable from the pit until the names
837
+ that reach them exist:
838
+
839
+ ```text
840
+ /install cli-tools # or /tools install cli-tools
841
+ ✓ cli-tools installed. 🤘
842
+ ✓ /blog → blog-post
843
+ ✓ /free → domainfree
844
+ ```
845
+
846
+ `/upgrade` does the same, because an upgrade is where a tool *gains* commands —
847
+ a roster adopted once at install time otherwise goes stale the first time the
848
+ tool ships something new. `/alias install <tool>` (or `--all`) re-runs it on
849
+ demand, for tools you installed before this existed.
850
+
851
+ The tool proposes and the pit disposes: moshcode reads the suggestions and
852
+ writes the file, so nothing else reaches into a config it does not own. A name
853
+ you bound yourself always wins — your `/prs` may carry `--orgs` flags a generic
854
+ suggestion knows nothing about — and a tool that offers nothing, or cannot be
855
+ asked, is silent rather than turning a successful install into an error.
856
+
808
857
  ### Social posting from the pit
809
858
 
810
859
  The pit can hand a prepared post to Bluesky or Nostr without storing either
@@ -1334,6 +1383,7 @@ chmod +x deploy.mosh
1334
1383
  | `alpaca(args…)` | drive the native Alpaca trading CLI |
1335
1384
  | `mcpjam(args…)` | drive the MCPJam CLI (test, debug, and validate MCP servers) |
1336
1385
  | `spinifex(args…)` | drive the Spinifex CLI (`spx` — AWS-compatible cloud on your own hardware) |
1386
+ | `alchemy(args…)` | drive the Alchemy CLI (onchain data, apps, wallets, x402) |
1337
1387
  | `trade(args…)` | look up tickers, inspect markets, preview/place Alpaca orders |
1338
1388
  | `stocks(args…)` | research tickers via advis0r (`stocksRead` returns the data) |
1339
1389
  | `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";
@@ -456,7 +456,14 @@ async function main() {
456
456
  process.exitCode = 1;
457
457
  return;
458
458
  }
459
- if (result.code === 0) console.log(`\n✓ ${target} installed. run it with \`${bin}\`. 🤘`);
459
+ if (result.code === 0) {
460
+ console.log(`\n✓ ${target} installed. run it with \`${bin}\`. 🤘`);
461
+ // Installing is also configuring: a tool that ships a set of commands is
462
+ // not usable from the pit until the words that reach them exist. Quiet
463
+ // for everything that offers none, and a name already in the file is
464
+ // reported by the adopter rather than replaced.
465
+ for (const line of adoptAliasLines(target, entry, { isReserved })) console.log(line);
466
+ }
460
467
  return backToPit(`install ${target}`, result.code);
461
468
  }
462
469
  if (cmd === "uninstall" || cmd === "remove") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moshcode",
3
- "version": "0.64.0",
3
+ "version": "0.66.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": {
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
+ }
@@ -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",
@@ -1176,23 +1179,26 @@ export const PIT_COMMANDS = [
1176
1179
  description: "show the current dir + git repo/branch/origin" },
1177
1180
  { name: "shell", aliases: ["sh"], args: "[cmd]", pitOnly: true,
1178
1181
  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,
1182
+ { name: "alias", aliases: ["aliases"], args: 'set <name> "<cmd>" | list | get | rm | install <tool>', pitOnly: true,
1180
1183
  description: "name a line you keep retyping; /<name> runs it",
1181
1184
  synopsis: [
1182
1185
  ['/alias set <name> "<command>"', "define one (also: /alias <name> \"<command>\")"],
1183
1186
  ["/alias [list] [--json]", "every alias"],
1184
1187
  ["/alias get <name>", "what one expands to"],
1185
1188
  ["/alias rm <name>", "forget one"],
1189
+ ["/alias install <tool> | --all", "re-adopt a tool's aliases (/install does it too)"],
1186
1190
  ],
1187
1191
  examples: [
1188
1192
  ['/alias set gs "git status"', "then /gs — and /gs -sb appends"],
1189
1193
  // Deliberately not `cc`: that one is already how the pit spells claude,
1190
1194
  // so the example would print a refusal for anyone who typed it.
1191
1195
  ['/alias set cx "/agents codex"', "a pit command, not a shell one"],
1196
+ ["/alias install cli-tools", "/blog, /free, /whois — from the tool itself"],
1192
1197
  ["/alias rm gs", ""],
1193
1198
  ],
1194
1199
  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.",
1200
+ + "Aliases live in ~/.moshcode/aliases.json and cannot shadow a pit command, engine, or tool. "
1201
+ + "/alias install never overwrites a name you bound yourself.",
1196
1202
  },
1197
1203
  { name: "help", aliases: ["?", "h"], args: "[command]", pitOnly: true,
1198
1204
  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);
@@ -681,6 +681,7 @@ const COMMANDS = [
681
681
  cliVerb("alpaca", "drive the native Alpaca trading CLI"),
682
682
  cliVerb("mcpjam", "drive the MCPJam CLI (test, debug, and validate MCP servers)"),
683
683
  cliVerb("spinifex", "drive the Spinifex CLI (spx — AWS-compatible cloud on your own hardware)"),
684
+ cliVerb("alchemy", "drive the Alchemy CLI (onchain data, apps, wallets, x402)"),
684
685
  cliVerb("trade", "look up tickers, inspect markets, and preview/place Alpaca orders"),
685
686
  cliVerb("pwd", "print the current repo/location"),
686
687
 
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
@@ -44,6 +46,42 @@ export const TOOLS = {
44
46
  // The CLI updates itself in place from the same origin the installer uses.
45
47
  upgrade: { cmd: "c0upons", args: ["upgrade"] },
46
48
  },
49
+ "cli-tools": {
50
+ desc: "Profullstack cli-tools — blog publishing, domain availability, and GitHub PR sweeps",
51
+ // The odd shape here: this is a *set* of commands, not one binary. The
52
+ // installer symlinks blog-post, domainfree, domainjson, gh-prs,
53
+ // gh-prs-merge, gh-prs-fix-all and tcfeed into ~/.local/bin, and `cli-tools`
54
+ // is the dispatcher that fronts them. Probing the dispatcher is what makes
55
+ // "installed" mean "the whole set is installed" rather than "one of seven
56
+ // names happens to exist".
57
+ bin: "cli-tools",
58
+ install: {
59
+ cmd: "sh",
60
+ args: [
61
+ "-c",
62
+ "curl -fsSL https://raw.githubusercontent.com/profullstack/cli-tools/master/install.sh | sh",
63
+ ],
64
+ },
65
+ // The installer symlinks into ~/.local/bin and appends nothing to PATH, so
66
+ // the shell that ran the install cannot see the commands — the same gap
67
+ // turso, gradient and kimi have.
68
+ binDirs: [path.join(homedir(), ".local", "bin")],
69
+ // Its own updater, which pulls and relinks. Deliberately not the installer:
70
+ // re-running that would re-clone for someone whose checkout lives
71
+ // elsewhere, and `cli-tools update` refuses to move a dirty or diverged
72
+ // tree rather than discarding work.
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
+ },
47
85
  secrets: {
48
86
  desc: "LogicSRC — end-to-end-encrypted team credential sharing (login, teams, credentials)",
49
87
  // The passthrough target is the `logicsrc` binary; the moshcode command is
@@ -243,6 +281,22 @@ export const TOOLS = {
243
281
  // migrations, and restarts the services. toolUpgradeSpec falls back to
244
282
  // install, so there is deliberately no upgrade key.
245
283
  },
284
+ alchemy: {
285
+ desc: "Alchemy — onchain data, apps and webhooks, agent wallets, and x402 payments (EVM + Solana)",
286
+ bin: "alchemy",
287
+ // An ordinary global npm package, and `npm install -g` is idempotent, so
288
+ // re-running the install IS the upgrade — same shape as mcpjam, hence no
289
+ // upgrade key. Its postinstall only prints a banner on a global install:
290
+ // the skill-sync branch above it needs a skills-lock.json that the
291
+ // published tarball does not ship, so nothing reaches out or is written.
292
+ //
293
+ // No installHelp: that line is for a MISSING installer (alpaca's go,
294
+ // gradient's python3), and npm is already here — moshcode runs on it. The
295
+ // package does declare node >=22, which npm warns about rather than
296
+ // refuses; the CLI then says so itself on first run, which is a better
297
+ // place to hear it than an install that succeeded.
298
+ install: { cmd: "npm", args: ["install", "-g", "@alchemy/cli"] },
299
+ },
246
300
  };
247
301
 
248
302
  /** Resolve a name to `[key, tool]`, or null. */
@@ -280,6 +334,90 @@ export function openTool(tool, args = [], opts = {}) {
280
334
  return openPassthrough(tool, args, opts);
281
335
  }
282
336
 
337
+ /** The command that prints a tool's proposed pit aliases, or null. */
338
+ export function toolAliasSpec(tool) {
339
+ return tool?.aliases || null;
340
+ }
341
+
342
+ /**
343
+ * Adopt a tool's aliases and describe the result in plain lines.
344
+ *
345
+ * The unpainted counterpart to the pit's renderer, for `moshcode install` —
346
+ * which is a plain-stdout surface, and may be a script's stdout. Returns [] for
347
+ * everything with nothing to say, so hanging this off an install costs a tool
348
+ * that offers no aliases exactly one function call and no output.
349
+ *
350
+ * Silent on failure by design: an installer that succeeded must not be followed
351
+ * by an error about a nicety, and `/alias install <tool>` says the same thing
352
+ * loudly for anyone who goes looking.
353
+ */
354
+ export function adoptAliasLines(key, tool, { isReserved = () => false, read = readToolAliases } = {}) {
355
+ if (!toolAliasSpec(tool)) return [];
356
+ const answer = read(tool);
357
+ if (!answer.ok) return [];
358
+ const result = mergeAliases(answer.aliases, { isReserved });
359
+ if (!result.ok || !result.added.length) return [];
360
+ return [
361
+ `\n${key} also offers ${result.added.length} pit alias${result.added.length === 1 ? "" : "es"}:`,
362
+ ...result.added.map(({ name, value }) => ` /${name} → ${value}`),
363
+ ...(result.kept.length ? [` (kept ${result.kept.length} you had already bound)`] : []),
364
+ ];
365
+ }
366
+
367
+ /** Every tool that offers pit aliases, as `[key, tool]`. */
368
+ export function toolsWithAliases() {
369
+ return Object.entries(TOOLS).filter(([, tool]) => toolAliasSpec(tool));
370
+ }
371
+
372
+ /**
373
+ * How long a tool gets to print its aliases before we stop waiting.
374
+ *
375
+ * This runs on an interactive verb, so the failure we care about is a CLI that
376
+ * blocks on something — a login prompt, a network read — rather than one that
377
+ * is merely slow. Printing a constant should take milliseconds.
378
+ */
379
+ const ALIAS_TIMEOUT_MS = 10_000;
380
+
381
+ /**
382
+ * Ask a tool for the pit aliases it proposes: `{ ok, aliases, error }`.
383
+ *
384
+ * Captured rather than passed through, because the output is data we are about
385
+ * to merge into the operator's config and not something to put on their
386
+ * screen. Every failure is a returned reason instead of a throw — a tool that
387
+ * is not installed, prints nothing, or prints something that is not JSON is an
388
+ * ordinary outcome of this verb, not an error the pit should fall over on.
389
+ *
390
+ * `run` is injectable so the tests do not need seven CLIs on PATH.
391
+ */
392
+ export function readToolAliases(tool, { run = spawnSync } = {}) {
393
+ const spec = toolAliasSpec(tool);
394
+ if (!spec) return { ok: false, error: "offers no aliases" };
395
+ let result;
396
+ try {
397
+ result = run(spec.cmd, spec.args, {
398
+ encoding: "utf8",
399
+ timeout: ALIAS_TIMEOUT_MS,
400
+ // No inherited stdin: a tool that decides to ask a question here would
401
+ // otherwise hang the pit on a prompt nobody can see.
402
+ stdio: ["ignore", "pipe", "pipe"],
403
+ });
404
+ } catch (e) {
405
+ return { ok: false, error: e.message };
406
+ }
407
+ if (result.error) return { ok: false, error: result.error.message };
408
+ if (result.status !== 0) {
409
+ const said = String(result.stderr || "").trim().split("\n")[0];
410
+ return { ok: false, error: said || `${spec.cmd} exited ${result.status}` };
411
+ }
412
+ let parsed;
413
+ try { parsed = JSON.parse(String(result.stdout || "")); }
414
+ catch { return { ok: false, error: `${spec.cmd} didn't print JSON` }; }
415
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
416
+ return { ok: false, error: `${spec.cmd} printed JSON, but not a set of aliases` };
417
+ }
418
+ return { ok: true, aliases: parsed };
419
+ }
420
+
283
421
  // Generic utilities used by the app/package surface.
284
422
 
285
423
  /**
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) => { console.log(hr()); console.log(code === 0 ? ok(`${key} installed. 🤘`) : err(`install exited ${code}`)); resolve(); });
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;