From e009a9ee7d39fc9d761e37a57307c167a93ea68c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 22 Sep 2026 14:38:32 +0000 Subject: [PATCH] fix(install): resolve tools by the name people actually say `/install profullstack` answered `unknown engine or tool "profullstack"`. The set is published under the Profullstack name and installs from github.com/profullstack/cli-tools, so that is the first thing anyone types -- but the registry key is `cli-tools`, and it is the one key whose name mentions neither the brand nor a binary it provides. Engines have had ENGINE_ALIASES for exactly this since `/agents cc`. Tools never got the equivalent, so resolveTool was exact-match only. This adds TOOL_ALIASES in the same shape and teaches resolveTool to consult it, which fixes every surface at once -- the pit's /install and /uninstall and the CLI's `moshcode install` all already route through resolveInstallable. Aliased: profullstack (+ the binaries the set symlinks, since someone who has only ever run blog-post has no reason to know the dispatcher installs it), bufferoverride -> bo, compute -> c0mpute, and the handful of other spellings that differ from their key. A test asserts every alias points at a real tool and shadows no existing key, and own-property lookup is kept so `__proto__` still resolves to nothing. Also: the pit's unknown-target line was a dead end. `moshcode install ` prints the full engine and tool roster on a miss, but the pit printed one line and stopped. It now suggests the nearest names, or points at /engines and /tools when nothing is close. suggestTargets stays quiet for an unrelated word rather than offering a confusing near miss. Co-Authored-By: Claude Opus 5 (1M context) --- src/tools.mjs | 79 ++++++++++++++++++++++++++++++++++++++++++--- src/tui.mjs | 14 ++++++-- test/tools.test.mjs | 48 ++++++++++++++++++++++++++- 3 files changed, 133 insertions(+), 8 deletions(-) diff --git a/src/tools.mjs b/src/tools.mjs index fcaf4abe..282fe1ea 100644 --- a/src/tools.mjs +++ b/src/tools.mjs @@ -454,14 +454,83 @@ export const TOOLS = { }, }; -/** Resolve a name to `[key, tool]`, or null. */ +/** + * Aliases so a tool resolves under the name people actually say, the way + * ENGINE_ALIASES does for engines. + * + * `cli-tools` is the one that catches everyone: the set is published under the + * Profullstack name and installs from github.com/profullstack/cli-tools, so + * `/install profullstack` is the first thing anyone types — and it is the only + * key here whose registry name mentions neither the brand nor the binary. + */ +export const TOOL_ALIASES = { + profullstack: "cli-tools", "profullstack/cli-tools": "cli-tools", + clitools: "cli-tools", "cli_tools": "cli-tools", tools: "cli-tools", + // The binaries the set symlinks. Someone who has only ever run `blog-post` + // has no reason to know the dispatcher is what installs it. + "blog-post": "cli-tools", domainfree: "cli-tools", "gh-prs": "cli-tools", + // `bo` is the registry key; BufferOverride is how the product is spelled. + bufferoverride: "bo", "buffer-override": "bo", + // Spellings of the rest that differ from their key. + compute: "c0mpute", coupons: "c0upons", + "crawl-proof": "crawlproof", eleven: "elevenlabs", "eleven-labs": "elevenlabs", + im: "imagemagick", "image-magick": "imagemagick", convert: "imagemagick", + "digitalocean": "doctl", do: "doctl", +}; + +/** Resolve a name or alias to `[key, tool]`, or null. */ export function resolveTool(token) { if (!token) return null; - const key = String(token).trim().toLowerCase(); - // Own properties only: TOOLS is a plain object literal, so a name like - // `constructor` or `__proto__` would otherwise resolve to something off + const token_ = String(token).trim().toLowerCase(); + // Own properties only: TOOLS/TOOL_ALIASES are plain object literals, so a name + // like `constructor` or `__proto__` would otherwise resolve to something off // Object.prototype and be handed on as a tool with no bin/install. - return Object.hasOwn(TOOLS, key) ? [key, TOOLS[key]] : null; + const key = Object.hasOwn(TOOLS, token_) + ? token_ + : Object.hasOwn(TOOL_ALIASES, token_) + ? TOOL_ALIASES[token_] + : null; + return key ? [key, TOOLS[key]] : null; +} + +/** + * The closest install targets to a name that resolved to nothing, so the error + * can point somewhere instead of only saying no. Substring both ways first + * (`profullstack` is a substring of no key, but `mosh` is of several), then a + * cheap edit-distance pass for a typo like `crawlprof`. + */ +export function suggestTargets(token, names, limit = 3) { + const t = String(token || "").trim().toLowerCase(); + if (!t) return []; + const scored = []; + for (const name of names) { + if (name === t) continue; + if (name.includes(t) || t.includes(name)) scored.push([0, name]); + else { + const d = editDistance(t, name); + // Only a near miss is worth printing; an unrelated word should get + // nothing rather than a confusing "did you mean". + if (d <= Math.max(2, Math.floor(name.length / 3))) scored.push([d, name]); + } + } + return scored.sort((a, b) => a[0] - b[0] || a[1].localeCompare(b[1])).slice(0, limit).map(([, n]) => n); +} + +/** Levenshtein, two rows — these are command names, not documents. */ +function editDistance(a, b) { + let prev = Array.from({ length: b.length + 1 }, (_, i) => i); + for (let i = 1; i <= a.length; i += 1) { + const row = [i]; + for (let j = 1; j <= b.length; j += 1) { + row[j] = Math.min( + prev[j] + 1, + row[j - 1] + 1, + prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1), + ); + } + prev = row; + } + return prev[b.length]; } /** diff --git a/src/tui.mjs b/src/tui.mjs index eee34208..b1d8ffa6 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -8,7 +8,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { ENGINES, agentLaunchArgs, resolveEngine, engineStatus, openSession } from "./engines.mjs"; -import { TOOLS, resolveTool, resolveInstallable, toolStatus, openTool, readToolAliases, toolsWithAliases } from "./tools.mjs"; +import { TOOLS, resolveTool, resolveInstallable, suggestTargets, toolStatus, openTool, readToolAliases, toolsWithAliases } from "./tools.mjs"; import { tradeArgs, tradeUsage } from "./trade.mjs"; import { postSocial, socialRoster } from "./socials.mjs"; import { shortenCommand } from "./shorten.mjs"; @@ -796,7 +796,17 @@ function installTarget(token) { // the resolvers check own properties only — `/install constructor` prints // the unknown-target line, not a TypeError that kills the pit. const resolved = resolveInstallable(token); - if (!resolved) { console.log(err(`unknown engine or tool "${token}"`)); return resolve(); } + if (!resolved) { + // A bare "unknown" is a dead end in the pit, where there is no usage text + // to fall back to the way `moshcode install` has one. Point at the + // nearest names when there are any, and at the rosters when there are not. + const near = suggestTargets(token, [...Object.keys(ENGINES), ...Object.keys(TOOLS)]); + console.log(err(`unknown engine or tool "${token}"`)); + console.log(info(near.length + ? `did you mean ${near.map((n) => `\`${n}\``).join(" or ")}?` + : "try `/engines` or `/tools` for what can be installed")); + return resolve(); + } const [key, target] = resolved; console.log(info(`installing ${key}: ${target.install.cmd} ${target.install.args.join(" ")}`)); // Before the rule, so the prompt reads as the pit asking rather than as diff --git a/test/tools.test.mjs b/test/tools.test.mjs index b8356ba7..c9bf34e0 100644 --- a/test/tools.test.mjs +++ b/test/tools.test.mjs @@ -18,7 +18,7 @@ import test from "node:test"; import { isInstalled, primaryBin, resolveEngine, ENGINES } from "../src/engines.mjs"; import { needsRootHere } from "../src/escalate.mjs"; -import { TOOLS, resolveInstallable, resolveTool, retry, toolList, toolUpgradeSpec } from "../src/tools.mjs"; +import { TOOLS, TOOL_ALIASES, resolveInstallable, resolveTool, retry, suggestTargets, toolList, toolUpgradeSpec } from "../src/tools.mjs"; const BIN = fileURLToPath(new URL("../bin/moshcode.mjs", import.meta.url)); @@ -684,3 +684,49 @@ test("install targets resolve engine aliases the way every other engine surface assert.equal(resolveInstallable("constructor"), null); assert.equal(resolveInstallable(""), null); }); + +/* --------------------------------------------- tool aliases + suggestions */ + +test("a tool resolves under the name people actually say", () => { + // The one that started this: the set is published under the Profullstack + // name and installs from profullstack/cli-tools, so `/install profullstack` + // is what gets typed — but the registry key is `cli-tools`. + assert.equal(resolveTool("profullstack")?.[0], "cli-tools"); + assert.equal(resolveTool("PROFULLSTACK")?.[0], "cli-tools"); + assert.equal(resolveTool(" profullstack ")?.[0], "cli-tools"); + assert.equal(resolveTool("profullstack/cli-tools")?.[0], "cli-tools"); + assert.equal(resolveTool("bufferoverride")?.[0], "bo"); + assert.equal(resolveTool("compute")?.[0], "c0mpute"); +}); + +test("a binary the set symlinks resolves to the dispatcher that installs it", () => { + for (const name of ["blog-post", "domainfree", "gh-prs"]) + assert.equal(resolveTool(name)?.[0], "cli-tools", name); +}); + +test("every tool alias points at a real tool and shadows no key", () => { + for (const [alias, key] of Object.entries(TOOL_ALIASES)) { + assert.ok(Object.hasOwn(TOOLS, key), `${alias} -> ${key} is not a tool`); + assert.ok(!Object.hasOwn(TOOLS, alias), `${alias} is already a tool key`); + } +}); + +test("tool aliases do not resolve off Object.prototype", () => { + for (const name of ["constructor", "__proto__", "toString", "hasOwnProperty"]) + assert.equal(resolveTool(name), null, name); +}); + +test("install targets resolve tool aliases too", () => { + assert.deepEqual(resolveInstallable("profullstack"), ["cli-tools", TOOLS["cli-tools"]]); + assert.deepEqual(resolveInstallable("cli-tools"), ["cli-tools", TOOLS["cli-tools"]]); +}); + +test("suggestTargets points at the nearest names, and stays quiet otherwise", () => { + const names = Object.keys(TOOLS); + assert.deepEqual(suggestTargets("crawlprof", names), ["crawlproof"]); + assert.ok(suggestTargets("elevenlab", names).includes("elevenlabs")); + // An unrelated word gets nothing rather than a confusing near miss. + assert.deepEqual(suggestTargets("zzzzzzzzzzqqq", names), []); + assert.deepEqual(suggestTargets("", names), []); + assert.ok(suggestTargets("tool", names).length <= 3); +});