Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -805,20 +805,28 @@ survive between sessions. A name that is already a pit command, an engine, or a
tool is refused rather than shadowed — built-ins are dispatched first, so such
an alias would never run.

Some workflow tools ship a set of commands rather than one binary, and propose
short words for them. `/alias install` adopts those:
Some workflow tools ship a *set* of commands rather than one binary, and propose
short words for them. Installing such a tool configures those words too — a
dispatcher fronting seven commands is not reachable from the pit until the names
that reach them exist:

```text
/alias install cli-tools # /blog, /free, /whois — from the tool itself
/alias install --all # every tool that offers them
/install cli-tools # or /tools install cli-tools
✓ cli-tools installed. 🤘
✓ /blog → blog-post
✓ /free → domainfree
```

`/upgrade` does the same, because an upgrade is where a tool *gains* commands —
a roster adopted once at install time otherwise goes stale the first time the
tool ships something new. `/alias install <tool>` (or `--all`) re-runs it on
demand, for tools you installed before this existed.

The tool proposes and the pit disposes: moshcode reads the suggestions and
writes the file, so nothing else reaches into a config it does not own. A name
you bound yourself always wins and is reported rather than replaced — your
`/prs` may carry `--orgs` flags a generic suggestion knows nothing about.
Deliberately its own verb rather than a step inside `/install`, which has no
business rewriting your aliases as a side effect.
you bound yourself always wins — your `/prs` may carry `--orgs` flags a generic
suggestion knows nothing about — and a tool that offers nothing, or cannot be
asked, is silent rather than turning a successful install into an error.

### Social posting from the pit

Expand Down
13 changes: 10 additions & 3 deletions bin/moshcode.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import fs from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { runScript } from "../src/runtime.mjs";
import { moshVocabulary } from "../src/commands.mjs";
import { isReserved, moshVocabulary } from "../src/commands.mjs";
import {
agentLaunchArgs,
engineList,
Expand All @@ -14,7 +14,7 @@ import {
resolveExecutable,
runCmd,
} from "../src/engines.mjs";
import { TOOLS, toolList, toolStatus, resolveTool, openTool } from "../src/tools.mjs";
import { TOOLS, toolList, toolStatus, resolveTool, openTool, adoptAliasLines } from "../src/tools.mjs";
import { tradeArgs, tradeUsage } from "../src/trade.mjs";
import { runUpgrade } from "../src/upgrade.mjs";
import { selfUpdateCommand } from "../src/selfupdate.mjs";
Expand Down Expand Up @@ -456,7 +456,14 @@ async function main() {
process.exitCode = 1;
return;
}
if (result.code === 0) console.log(`\n✓ ${target} installed. run it with \`${bin}\`. 🤘`);
if (result.code === 0) {
console.log(`\n✓ ${target} installed. run it with \`${bin}\`. 🤘`);
// Installing is also configuring: a tool that ships a set of commands is
// not usable from the pit until the words that reach them exist. Quiet
// for everything that offers none, and a name already in the file is
// reported by the adopter rather than replaced.
for (const line of adoptAliasLines(target, entry, { isReserved })) console.log(line);
}
return backToPit(`install ${target}`, result.code);
}
if (cmd === "uninstall" || cmd === "remove") {
Expand Down
5 changes: 4 additions & 1 deletion src/cli-schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,11 @@ export const CORE_CLI_COMMANDS = [
examples: [
["moshcode install claude", "an engine"],
["moshcode install gh", "a workflow tool"],
["moshcode install cli-tools", "a set of commands — its pit aliases come too"],
],
seeAlso: ["uninstall", "upgrade", "engines", "tools"],
note: "a tool that ships a set of commands can propose pit aliases for them; "
+ "installing it adopts the ones you have not already bound yourself.",
},
{
name: "uninstall",
Expand Down Expand Up @@ -1183,7 +1186,7 @@ export const PIT_COMMANDS = [
["/alias [list] [--json]", "every alias"],
["/alias get <name>", "what one expands to"],
["/alias rm <name>", "forget one"],
["/alias install <tool> | --all", "adopt the aliases a workflow tool offers"],
["/alias install <tool> | --all", "re-adopt a tool's aliases (/install does it too)"],
],
examples: [
['/alias set gs "git status"', "then /gs — and /gs -sb appends"],
Expand Down
2 changes: 1 addition & 1 deletion src/commands.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ function expectNoArgs(name, args) {
* setAlias() takes) rather than an exported list, so this stays the caller's
* answer and not a second roster to drift from the first.
*/
function isReserved(name) {
export function isReserved(name) {
const key = String(name).toLowerCase();
return CORE_CLI_COMMAND_NAMES.includes(key)
|| PIT_COMMANDS.some((c) => (typeof c === "string" ? c : c.name) === key);
Expand Down
32 changes: 31 additions & 1 deletion src/tools.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { homedir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { mergeAliases } from "./aliases.mjs";
import { isInstalled, openPassthrough } from "./engines.mjs";

// gh, supabase, and doctl publish only GitHub release binaries — no official
Expand Down Expand Up @@ -70,7 +71,11 @@ export const TOOLS = {
// elsewhere, and `cli-tools update` refuses to move a dirty or diverged
// tree rather than discarding work.
upgrade: { cmd: "cli-tools", args: ["update"] },
// The pit aliases this set offers, read by `/alias install cli-tools`.
// The pit aliases this set offers. Read at the end of /install and
// /upgrade, so installing the set is also configuring it — seven commands
// behind one dispatcher are not reachable from the pit until the words that
// reach them exist. `/alias install cli-tools` re-runs it on demand.
//
// Declared rather than probed: a command that prints a set of aliases is
// only safe to run against a tool we already know answers it, and an
// `aliases --json` guessed at every installed CLI would eventually hit one
Expand Down Expand Up @@ -318,6 +323,31 @@ export function toolAliasSpec(tool) {
return tool?.aliases || null;
}

/**
* Adopt a tool's aliases and describe the result in plain lines.
*
* The unpainted counterpart to the pit's renderer, for `moshcode install` —
* which is a plain-stdout surface, and may be a script's stdout. Returns [] for
* everything with nothing to say, so hanging this off an install costs a tool
* that offers no aliases exactly one function call and no output.
*
* Silent on failure by design: an installer that succeeded must not be followed
* by an error about a nicety, and `/alias install <tool>` says the same thing
* loudly for anyone who goes looking.
*/
export function adoptAliasLines(key, tool, { isReserved = () => false, read = readToolAliases } = {}) {
if (!toolAliasSpec(tool)) return [];
const answer = read(tool);
if (!answer.ok) return [];
const result = mergeAliases(answer.aliases, { isReserved });
if (!result.ok || !result.added.length) return [];
return [
`\n${key} also offers ${result.added.length} pit alias${result.added.length === 1 ? "" : "es"}:`,
...result.added.map(({ name, value }) => ` /${name} → ${value}`),
...(result.kept.length ? [` (kept ${result.kept.length} you had already bound)`] : []),
];
}

/** Every tool that offers pit aliases, as `[key, tool]`. */
export function toolsWithAliases() {
return Object.entries(TOOLS).filter(([, tool]) => toolAliasSpec(tool));
Expand Down
132 changes: 94 additions & 38 deletions src/tui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -305,49 +305,72 @@ function aliasInstallCommand(args) {
// are the words you are about to type.
let touched = 0;
for (const [key, tool] of wanted) {
const label = acid(`/${key}`);
if (!tool) { console.log(err(`no tool named "${key}" — /tools for the roster`)); continue; }
if (!tool.aliases) {
// Unreachable under --all, whose roster is exactly the tools that offer
// aliases, so this can only be one the operator named by hand.
console.log(info(`${label} offers no aliases`));
continue;
}
const read = readToolAliases(tool);
if (!read.ok) {
console.log(err(`${label} — ${read.error}`));
if (!isInstalledTool(key)) console.log(ash(` not installed here — /install ${key}`));
continue;
}
const result = mergeAliases(read.aliases, { isReserved: isReservedName });
if (!result.ok) { console.log(err(`${label} — ${result.error}`)); continue; }
touched += result.added.length;
touched += adoptToolAliases(key, tool, { compact: all, quiet: false });
}
if (touched) console.log(ash(" run one with /<name> · /alias list for all of them"));
}

if (all) {
const parts = [`${result.added.length} added`];
if (result.kept.length) parts.push(`${result.kept.length} kept`);
if (result.refused.length) parts.push(`${result.refused.length} refused`);
console.log(` ${label.padEnd(20)} ${ash(parts.join(", "))}`);
continue;
}
for (const { name, value } of result.added) {
console.log(` ${ok(`${acid(`/${name}`)} ${ash("→")} ${bone(value)}`)}`);
}
// Named rather than counted: an alias the operator already owns is the one
// case where nothing changed *and* they need to know which word it was,
// because theirs and the tool's suggestion are both plausible.
/**
* Read one tool's proposed aliases, merge them, and say what happened.
*
* The one place that does this, because three surfaces need it: `/install` and
* `/tools install` at the end of an install, `/upgrade` after a tool has gained
* commands, and `/alias install` on its own. Returns how many names were added
* so a caller can decide whether the run is worth a closing line.
*
* `quiet` is what makes it safe to hang off an install: a tool that offers
* nothing, or that cannot be asked, must not print a failure after a install
* that actually succeeded. Only real adoptions and genuine surprises speak up.
*/
function adoptToolAliases(key, tool, { compact = false, quiet = false } = {}) {
const label = acid(`/${key}`);
if (!tool?.aliases) {
if (!quiet) console.log(info(`${label} offers no aliases`));
return 0;
}
const read = readToolAliases(tool);
if (!read.ok) {
if (quiet) return 0;
console.log(err(`${label} — ${read.error}`));
if (!isInstalledTool(key)) console.log(ash(` not installed here — /install ${key}`));
return 0;
}
const result = mergeAliases(read.aliases, { isReserved: isReservedName });
if (!result.ok) {
if (!quiet) console.log(err(`${label} — ${result.error}`));
return 0;
}

if (compact) {
const parts = [`${result.added.length} added`];
if (result.kept.length) parts.push(`${result.kept.length} kept`);
if (result.refused.length) parts.push(`${result.refused.length} refused`);
console.log(` ${label.padEnd(20)} ${ash(parts.join(", "))}`);
return result.added.length;
}

for (const { name, value } of result.added) {
console.log(` ${ok(`${acid(`/${name}`)} ${ash("→")} ${bone(value)}`)}`);
}
// Named rather than counted: an alias the operator already owns is the one
// case where nothing changed *and* they need to know which word it was,
// because theirs and the tool's suggestion are both plausible. Suppressed
// after an install, where a list of names that did not change is noise
// between the installer's output and the prompt.
if (!quiet) {
for (const { name, value } of result.kept) {
console.log(` ${info(`kept your own ${acid(`/${name}`)} ${ash(`(${value})`)}`)}`);
}
for (const { name, reason } of result.refused) {
console.log(` ${warn(`skipped ${acid(`/${name}`)} ${ash(`— ${reason}`)}`)}`);
}
if (result.dropped) console.log(ash(` ${result.dropped} more offered than /alias install writes at once`));
if (!result.added.length && !result.kept.length && !result.refused.length) {
console.log(info(`${label} offers no aliases`));
}
}
if (touched) console.log(ash(" run one with /<name> · /alias list for all of them"));
for (const { name, reason } of result.refused) {
console.log(` ${warn(`skipped ${acid(`/${name}`)} ${ash(`— ${reason}`)}`)}`);
}
if (result.dropped) console.log(ash(` ${result.dropped} more offered than one run writes`));
if (!quiet && !result.added.length && !result.kept.length && !result.refused.length) {
console.log(info(`${label} offers no aliases`));
}
return result.added.length;
}

/** Is this tool's native executable present? Used only to explain a failure. */
Expand Down Expand Up @@ -539,6 +562,16 @@ function printPwd() {
async function upgradeAll(targets) {
console.log(info(`upgrading ${bone("moshcode")} + installed engines/tools — hand-off to each updater…`));
await runUpgrade(targets, { log: (s) => console.log(s), rule: () => console.log(hr()) });
// An upgrade is where a tool *gains* commands, so it is the moment its new
// shortcuts should appear — a roster adopted once at install time otherwise
// goes stale the first time the tool ships something new. Quiet and
// never-overwrite, exactly as at install.
const wanted = targets?.length
? toolsWithAliases().filter(([key]) => targets.includes(key))
: toolsWithAliases();
let added = 0;
for (const [key, tool] of wanted) added += adoptToolAliases(key, tool, { quiet: true });
if (added) console.log(ash(` ${added} new alias${added === 1 ? "" : "es"} · /alias list for all of them`));
}

// The live mirror for this pit, once /sessions is watching. Module-level so the
Expand Down Expand Up @@ -692,7 +725,18 @@ function installTarget(key) {
if (e.code === "ENOENT" && target.installHelp) console.log(info(target.installHelp));
resolve();
});
child.on("exit", (code) => { console.log(hr()); console.log(code === 0 ? ok(`${key} installed. 🤘`) : err(`install exited ${code}`)); resolve(); });
child.on("exit", (code) => {
console.log(hr());
if (code !== 0) { console.log(err(`install exited ${code}`)); return resolve(); }
console.log(ok(`${key} installed. 🤘`));
// Installing a tool is also configuring it: a set of commands is not
// usable from the pit until the words that reach them exist. Quiet, so a
// tool with nothing to offer — every engine, and most tools — finishes
// exactly as it did before. Names you bound yourself are never touched.
const added = Object.hasOwn(TOOLS, key) ? adoptToolAliases(key, TOOLS[key], { quiet: true }) : 0;
if (added) console.log(ash(` ${added} alias${added === 1 ? "" : "es"} from ${key} · /alias list for all of them`));
resolve();
});
});
}

Expand Down Expand Up @@ -985,6 +1029,18 @@ export async function tui() {
}
if (cmd === "tools") {
if (!rest[0]) { printTools(); continue; }
// `/tools install <name>` reads as the obvious spelling to anyone who has
// just been shown the roster by `/tools`, and resolveTool would otherwise
// answer it with `unknown tool "install"` — a dead end pointing at the
// wrong word. Same for the verbs that pair with it.
if (["install", "upgrade", "update"].includes(rest[0].toLowerCase()) && rest[1]) {
const verb = rest[0].toLowerCase();
rl.close();
if (verb === "install") await installTarget(rest[1].toLowerCase());
else await upgradeAll(rest.slice(1).map((r) => r.toLowerCase()));
rl = mkrl();
continue;
}
const resolved = resolveTool(rest[0]);
if (!resolved) { console.log(err(`unknown tool "${rest[0]}". try: ${Object.keys(TOOLS).join(", ")}`)); continue; }
const [key, tool] = resolved;
Expand Down
Loading
Loading