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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,21 @@ 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:

```text
/alias install cli-tools # /blog, /free, /whois — from the tool itself
/alias install --all # every tool that offers them
```

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.

### Social posting from the pit

The pit can hand a prepared post to Bluesky or Nostr without storing either
Expand Down
65 changes: 65 additions & 0 deletions src/aliases.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,68 @@ export function expandAlias(value, args = "") {
const line = `${String(value).trim()}${args ? ` ${args}` : ""}`;
return /^[/!]/.test(line) ? line : `!${line}`;
}

/**
* How many aliases one tool may offer in a single install.
*
* A bound, not a judgement about any tool: the proposal is a subprocess's
* stdout being written into the operator's config, and an unbounded loop over
* whatever it printed is how a bug in a tool becomes a thousand-line
* aliases.json. No CLI here offers more than a handful.
*/
export const MAX_PROPOSED = 64;

/**
* Merge a tool's proposed aliases into the operator's file, in one write.
*
* Existing names always win. The file is the operator's: silently repointing a
* word they bound themselves is the kind of change nothing surfaces until the
* wrong command runs — and the pit's own aliases carry flags (`--orgs`,
* `--apply`) that a tool's generic suggestion does not know about.
*
* Names that collide with a pit command, engine, or tool are refused rather
* than written, for the reason setAlias refuses them: built-ins are resolved
* first, so such an alias would be silently dead.
*
* Returns { ok, error, added, kept, refused } — each a list of
* { name, value, previous? , reason? } so the caller can say what happened
* without re-deriving it.
*/
export function mergeAliases(proposed, { isReserved = () => false } = {}) {
const added = [];
const kept = [];
const refused = [];
if (!proposed || typeof proposed !== "object" || Array.isArray(proposed)) {
return { ok: false, error: "that isn't a set of aliases", added, kept, refused };
}

const existing = loadAliases();
const merged = { ...existing };
// Sorted so the report reads the same way twice regardless of the order the
// tool happened to print, and truncated rather than refused: a tool that
// offers too many still gets its first MAX_PROPOSED written, and the caller
// is told what was dropped.
const entries = Object.entries(proposed).sort(([a], [b]) => a.localeCompare(b));
const dropped = Math.max(0, entries.length - MAX_PROPOSED);

for (const [rawName, rawValue] of entries.slice(0, MAX_PROPOSED)) {
const name = normalizeName(rawName);
const value = typeof rawValue === "string" ? rawValue.trim() : "";
if (!name) { refused.push({ name: String(rawName), reason: "not a usable alias name" }); continue; }
if (!value) { refused.push({ name, reason: "nothing to run" }); continue; }
if (value.includes("\n")) { refused.push({ name, reason: "an alias is a single line" }); continue; }
if (value.length > MAX_VALUE) { refused.push({ name, reason: `${value.length} characters — the cap is ${MAX_VALUE}` }); continue; }
if (isReserved(name)) { refused.push({ name, reason: "already a pit command, engine, or tool" }); continue; }
if (Object.hasOwn(existing, name)) { kept.push({ name, value: existing[name], proposed: value }); continue; }
merged[name] = value;
added.push({ name, value });
}

// Nothing new is still a success — "already up to date" is the common case on
// a second run, and writing the file again to say so would only churn mtime.
if (added.length) {
try { saveAliases(merged); }
catch (e) { return { ok: false, error: `can't write ${aliasFile()}: ${e.message}`, added: [], kept, refused }; }
}
return { ok: true, added, kept, refused, dropped };
}
7 changes: 5 additions & 2 deletions src/cli-schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1176,23 +1176,26 @@ export const PIT_COMMANDS = [
description: "show the current dir + git repo/branch/origin" },
{ name: "shell", aliases: ["sh"], args: "[cmd]", pitOnly: true,
description: "drop into $SHELL (exit → back to the pit); also !cmd" },
{ name: "alias", aliases: ["aliases"], args: 'set <name> "<cmd>" | list | get | rm', pitOnly: true,
{ name: "alias", aliases: ["aliases"], args: 'set <name> "<cmd>" | list | get | rm | install <tool>', pitOnly: true,
description: "name a line you keep retyping; /<name> runs it",
synopsis: [
['/alias set <name> "<command>"', "define one (also: /alias <name> \"<command>\")"],
["/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"],
],
examples: [
['/alias set gs "git status"', "then /gs — and /gs -sb appends"],
// Deliberately not `cc`: that one is already how the pit spells claude,
// so the example would print a refusal for anyone who typed it.
['/alias set cx "/agents codex"', "a pit command, not a shell one"],
["/alias install cli-tools", "/blog, /free, /whois — from the tool itself"],
["/alias rm gs", ""],
],
note: "the command runs in $SHELL unless it starts with / — then it is a pit command. "
+ "Aliases live in ~/.moshcode/aliases.json and cannot shadow a pit command, engine, or tool.",
+ "Aliases live in ~/.moshcode/aliases.json and cannot shadow a pit command, engine, or tool. "
+ "/alias install never overwrites a name you bound yourself.",
},
{ name: "help", aliases: ["?", "h"], args: "[command]", pitOnly: true,
description: "this, or one command in detail" },
Expand Down
66 changes: 66 additions & 0 deletions src/tools.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// c0upons owns community coupons and bounties, the cloud CLIs below own
// deploys/secrets/infra, Coral owns read-only data access across those systems,
// and moshcode only conducts their native command lines.
import { spawnSync } from "node:child_process";
import { homedir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
Expand Down Expand Up @@ -69,6 +70,12 @@ 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`.
// 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
// where those words mean something else entirely.
aliases: { cmd: "cli-tools", args: ["aliases", "--json"] },
},
secrets: {
desc: "LogicSRC — end-to-end-encrypted team credential sharing (login, teams, credentials)",
Expand Down Expand Up @@ -306,6 +313,65 @@ export function openTool(tool, args = [], opts = {}) {
return openPassthrough(tool, args, opts);
}

/** The command that prints a tool's proposed pit aliases, or null. */
export function toolAliasSpec(tool) {
return tool?.aliases || null;
}

/** Every tool that offers pit aliases, as `[key, tool]`. */
export function toolsWithAliases() {
return Object.entries(TOOLS).filter(([, tool]) => toolAliasSpec(tool));
}

/**
* How long a tool gets to print its aliases before we stop waiting.
*
* This runs on an interactive verb, so the failure we care about is a CLI that
* blocks on something — a login prompt, a network read — rather than one that
* is merely slow. Printing a constant should take milliseconds.
*/
const ALIAS_TIMEOUT_MS = 10_000;

/**
* Ask a tool for the pit aliases it proposes: `{ ok, aliases, error }`.
*
* Captured rather than passed through, because the output is data we are about
* to merge into the operator's config and not something to put on their
* screen. Every failure is a returned reason instead of a throw — a tool that
* is not installed, prints nothing, or prints something that is not JSON is an
* ordinary outcome of this verb, not an error the pit should fall over on.
*
* `run` is injectable so the tests do not need seven CLIs on PATH.
*/
export function readToolAliases(tool, { run = spawnSync } = {}) {
const spec = toolAliasSpec(tool);
if (!spec) return { ok: false, error: "offers no aliases" };
let result;
try {
result = run(spec.cmd, spec.args, {
encoding: "utf8",
timeout: ALIAS_TIMEOUT_MS,
// No inherited stdin: a tool that decides to ask a question here would
// otherwise hang the pit on a prompt nobody can see.
stdio: ["ignore", "pipe", "pipe"],
});
} catch (e) {
return { ok: false, error: e.message };
}
if (result.error) return { ok: false, error: result.error.message };
if (result.status !== 0) {
const said = String(result.stderr || "").trim().split("\n")[0];
return { ok: false, error: said || `${spec.cmd} exited ${result.status}` };
}
let parsed;
try { parsed = JSON.parse(String(result.stdout || "")); }
catch { return { ok: false, error: `${spec.cmd} didn't print JSON` }; }
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return { ok: false, error: `${spec.cmd} printed JSON, but not a set of aliases` };
}
return { ok: true, aliases: parsed };
}

// Generic utilities used by the app/package surface.

/**
Expand Down
99 changes: 96 additions & 3 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, toolStatus, openTool } from "./tools.mjs";
import { TOOLS, resolveTool, toolStatus, openTool, readToolAliases, toolsWithAliases } from "./tools.mjs";
import { tradeArgs, tradeUsage } from "./trade.mjs";
import { postSocial, socialRoster } from "./socials.mjs";
import { runUpgrade } from "./upgrade.mjs";
Expand All @@ -31,7 +31,7 @@ import { banner, hr, acid, ash, bone, dim, ok, err, warn, info, moshcodeVersion
import { CORE_CLI_COMMAND_NAMES } from "./cli-schema.mjs";
import { RENAMED_COMMANDS, findPitCommand, pitHelpModel, renderPitCommand, suggest, wantsHelp } from "./help.mjs";
import { openNewTab } from "./tabs.mjs";
import { MAX_EXPANSIONS, expandAlias, getAlias, loadAliases, removeAlias, setAlias } from "./aliases.mjs";
import { MAX_EXPANSIONS, expandAlias, getAlias, loadAliases, mergeAliases, removeAlias, setAlias } from "./aliases.mjs";
import { herdCommand, herdStart, renderRoster, roster, splitDetachArgs } from "./herd-cli.mjs";
import { detectSubstrate, substrateNote } from "./herd.mjs";

Expand Down Expand Up @@ -269,6 +269,92 @@ function printAliases({ json = false } = {}) {
}
}

/**
* `/alias install <tool>` — adopt the pit aliases a workflow tool offers.
*
* The tools in src/tools.mjs are separate products with their own release
* cycles, and several of them ship a set of commands rather than one binary.
* Which short words those deserve at this prompt is a question only the tool
* can answer, and only moshcode can act on: the file is ours, so a tool that
* wrote it directly would be reaching into a config it does not own — the same
* objection that keeps `railway setup agent` out of /install.
*
* So the tool proposes and the pit disposes. Deliberately its own verb rather
* than a step inside /install: writing the operator's aliases is a side effect
* an install command has no business having, and the roster is worth adopting
* long after the day a tool was installed.
*/
function aliasInstallCommand(args) {
const all = args.includes("--all");
const names = args.filter((a) => !a.startsWith("-"));
if (!all && !names.length) {
const offered = toolsWithAliases().map(([key]) => key);
console.log(err("usage: /alias install <tool> | --all"));
console.log(ash(offered.length
? ` tools that offer aliases: ${offered.join(", ")}`
: " no tool offers aliases yet"));
return;
}

const wanted = all
? toolsWithAliases()
: names.map((name) => [name, resolveTool(name)?.[1] ?? null]);

// A run over --all reports per tool, because "3 added, 2 kept" for a roster
// is the useful shape; a single named tool reports per alias, because those
// 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;

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.
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"));
}

/** Is this tool's native executable present? Used only to explain a failure. */
function isInstalledTool(key) {
return Boolean(toolStatus().find((entry) => entry.key === key)?.installed);
}

/**
* `/alias` — define, list, and forget the shortcuts (src/aliases.mjs).
*
Expand All @@ -289,6 +375,13 @@ function aliasCommand(rest, line) {
printAliases({ json });
return;
}
// Before `set`, because `install` is a verb and not a name: falling through
// to the bare-`/alias <name> <value>` shorthand would define an alias called
// "install" pointing at whatever came next.
if (sub === "install" || sub === "adopt") {
aliasInstallCommand(args);
return;
}
if (sub === "set" || sub === "add") {
const name = args[0];
const value = aliasValue(line);
Expand Down Expand Up @@ -322,7 +415,7 @@ function aliasCommand(rest, line) {
// when there is a value after it, or `/alias gs` would silently define
// nothing.
if (args.length) { aliasCommand(["set", ...rest], `/alias set ${commandRemainder(line)}`); return; }
console.log(err(`unknown /alias verb "${verb}" — set, list, get, rm`));
console.log(err(`unknown /alias verb "${verb}" — set, list, get, rm, install`));
}

/**
Expand Down
Loading
Loading