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
79 changes: 74 additions & 5 deletions src/tools.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}

/**
Expand Down
14 changes: 12 additions & 2 deletions src/tui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
48 changes: 47 additions & 1 deletion test/tools.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -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);
});
Loading