diff --git a/README.md b/README.md index 0f17328..dcae28e 100644 --- a/README.md +++ b/README.md @@ -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 ` (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 diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs index d40d396..8cc1e6d 100755 --- a/bin/moshcode.mjs +++ b/bin/moshcode.mjs @@ -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, @@ -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"; @@ -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") { diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 5ad8052..ee6b30e 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -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", @@ -1183,7 +1186,7 @@ export const PIT_COMMANDS = [ ["/alias [list] [--json]", "every alias"], ["/alias get ", "what one expands to"], ["/alias rm ", "forget one"], - ["/alias install | --all", "adopt the aliases a workflow tool offers"], + ["/alias install | --all", "re-adopt a tool's aliases (/install does it too)"], ], examples: [ ['/alias set gs "git status"', "then /gs β€” and /gs -sb appends"], diff --git a/src/commands.mjs b/src/commands.mjs index 9e24dca..4525f4a 100644 --- a/src/commands.mjs +++ b/src/commands.mjs @@ -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); diff --git a/src/tools.mjs b/src/tools.mjs index 046a731..3f7f657 100644 --- a/src/tools.mjs +++ b/src/tools.mjs @@ -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 @@ -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 @@ -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 ` 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)); diff --git a/src/tui.mjs b/src/tui.mjs index 0375d9f..3f6098e 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -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 / Β· /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 / Β· /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. */ @@ -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 @@ -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(); + }); }); } @@ -985,6 +1029,18 @@ export async function tui() { } if (cmd === "tools") { if (!rest[0]) { printTools(); continue; } + // `/tools install ` 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; diff --git a/test/alias-install.test.mjs b/test/alias-install.test.mjs index 17a5c03..1d7df48 100644 --- a/test/alias-install.test.mjs +++ b/test/alias-install.test.mjs @@ -9,7 +9,7 @@ import test from "node:test"; import { fileURLToPath } from "node:url"; import { mergeAliases } from "../src/aliases.mjs"; -import { TOOLS, readToolAliases, toolsWithAliases } from "../src/tools.mjs"; +import { TOOLS, adoptAliasLines, readToolAliases, toolsWithAliases } from "../src/tools.mjs"; const BIN = fileURLToPath(new URL("../bin/moshcode.mjs", import.meta.url)); @@ -126,6 +126,61 @@ test("mergeAliases treats anything that isn't an object as no aliases at all", ( }); }); +/* -------------------------------------------------- at the end of install */ + +test("adoptAliasLines says what an install added, and nothing when there is nothing", () => { + inFreshHome(() => { + const lines = adoptAliasLines("cli-tools", TOOLS["cli-tools"], { + read: () => ({ ok: true, aliases: { blog: "blog-post", free: "domainfree" } }), + }); + assert.match(lines.join("\n"), /2 pit aliases/); + assert.match(lines.join("\n"), /\/blog β†’ blog-post/); + + // Second run: everything is already there, so an install prints nothing. + const again = adoptAliasLines("cli-tools", TOOLS["cli-tools"], { + read: () => ({ ok: true, aliases: { blog: "blog-post", free: "domainfree" } }), + }); + assert.deepEqual(again, []); + }); +}); + +test("adoptAliasLines stays silent for a tool that offers none, and never runs it", () => { + inFreshHome(() => { + let ran = false; + assert.deepEqual(adoptAliasLines("railway", TOOLS.railway, { read: () => { ran = true; return {}; } }), []); + assert.equal(ran, false); + }); +}); + +test("a failing tool never turns a successful install into an error", () => { + inFreshHome(() => { + for (const answer of [{ ok: false, error: "not installed" }, { ok: true, aliases: {} }]) { + assert.deepEqual(adoptAliasLines("cli-tools", TOOLS["cli-tools"], { read: () => answer }), []); + } + }); +}); + +test("adoptAliasLines reaches a real tool on PATH, and keeps your own line", () => { + inFreshHome((home) => { + const previousPath = process.env.PATH; + const binDir = join(home, "fakebin"); + mkdirSync(binDir, { recursive: true }); + const fake = join(binDir, "cli-tools"); + writeFileSync(fake, '#!/bin/sh\necho \'{"blog":"blog-post","prs":"gh-prs"}\'\n'); + chmodSync(fake, 0o755); + mkdirSync(join(home, ".moshcode"), { recursive: true }); + writeFileSync(join(home, ".moshcode", "aliases.json"), '{"prs":"gh-prs-all"}\n'); + process.env.PATH = `${binDir}:${previousPath}`; + try { + const lines = adoptAliasLines("cli-tools", TOOLS["cli-tools"]).join("\n"); + assert.match(lines, /1 pit alias:/); + assert.match(lines, /\/blog β†’ blog-post/); + assert.match(lines, /kept 1 you had already bound/); + assert.equal(aliasesOf(home).prs, "gh-prs-all"); + } finally { process.env.PATH = previousPath; } + }); +}); + /* ------------------------------------------------------------ in the pit */ /** @@ -217,6 +272,16 @@ test("/alias install names an unknown tool rather than defining an alias for it" assert.match(result.stdout, /no tool named/); }); +test("/tools install routes to the installer, not to a tool called install", async () => { + // An unknown target, so nothing is ever downloaded: the assertion is only + // that the words reached installTarget, whose refusal names engines *and* + // tools β€” where resolveTool's would have been `unknown tool "install"`, + // pointing at the wrong word entirely. + const result = await runTui("/tools install nosuchtool"); + assert.match(result.stdout, /unknown engine or tool/); + assert.doesNotMatch(result.stdout, /unknown tool "install"/); +}); + test('"install" is a verb, so it never becomes an alias called install', async () => { const result = await runTui(["/alias install cli-tools", "/alias list"]); assert.ok(!Object.hasOwn(aliasesOf(result.home), "install")); diff --git a/test/tools.test.mjs b/test/tools.test.mjs index 8c2d85b..8a4c7da 100644 --- a/test/tools.test.mjs +++ b/test/tools.test.mjs @@ -47,6 +47,12 @@ function run(args, { binDir, cwd, input = "", env = {} } = {}) { stdio: [stdin, stdout, stderr], env: { ...process.env, + // After the spread, or the real one wins. A throwaway home, because a + // successful install is allowed to write one: a tool that offers pit + // aliases has them adopted into ~/.moshcode/aliases.json at the end of + // it. Without this the suite edits the aliases of whoever runs it. + HOME: tempDir("moshcode-home-"), + USERPROFILE: undefined, ...env, PATH: binDir ? `${binDir}${path.delimiter}${env.PATH ?? process.env.PATH ?? ""}` @@ -407,9 +413,16 @@ for (const [name, shell, script] of [ const nativeBin = path.join(root, "bin"); const capture = path.join(root, "shell-args.json"); mkdirSync(nativeBin); + // First invocation only. The spy stands in for the shell itself, so + // anything else the run reaches for a shell would overwrite it β€” a tool + // that offers pit aliases is asked for them once the install succeeds, and + // its own shebang lands right back here. The install is what this asserts, + // and the install is what runs first. writeExecutable(nativeBin, shell, ` import fs from "node:fs"; -fs.writeFileSync(process.env.SHELL_CAPTURE, JSON.stringify(process.argv.slice(2))); +if (!fs.existsSync(process.env.SHELL_CAPTURE)) { + fs.writeFileSync(process.env.SHELL_CAPTURE, JSON.stringify(process.argv.slice(2))); +} `); const result = await run(["install", name], {