moshcode 0.37.0 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/package.json +1 -1
- package/src/aliases.mjs +160 -0
- package/src/cli-schema.mjs +23 -2
- package/src/help.mjs +14 -1
- package/src/herd-bar.mjs +129 -4
- package/src/herd-cli.mjs +25 -2
- package/src/herd-workspace.mjs +4 -27
- package/src/herd.mjs +12 -0
- package/src/tui.mjs +156 -14
package/README.md
CHANGED
|
@@ -219,6 +219,13 @@ content pane", and the real attach would be a tmux client inside a tmux client.
|
|
|
219
219
|
Output grows the bar over the content for as long as you are reading it, then it
|
|
220
220
|
collapses back to one row.
|
|
221
221
|
|
|
222
|
+
**`moshcode attach <name>` gets the bar too.** A session you attach to directly
|
|
223
|
+
grows the same one-line prompt along the bottom for as long as you are there,
|
|
224
|
+
and it is taken away again when you detach — so a member is a plain member when
|
|
225
|
+
nobody is looking at it. `show <name>` from that bar switches you to another
|
|
226
|
+
member (and gives that one a bar before you land in it). The bar is the bottom
|
|
227
|
+
row either way, which is why one key finds it in both places.
|
|
228
|
+
|
|
222
229
|
The right-hand pane is not a picture of a session — it *is* the session's pane,
|
|
223
230
|
moved in. tmux's model is session → window → pane, so moving between *windows*
|
|
224
231
|
cannot keep anything on screen; but `join-pane` moves a running pane into an
|
|
@@ -536,6 +543,24 @@ an equity's score, and each response ships the `caveats` that say so. Prices are
|
|
|
536
543
|
Alpaca's US venue alone and can differ materially from other exchanges. Research
|
|
537
544
|
aid, not advice — and like `stocks`, nothing under `crypto` can place an order.
|
|
538
545
|
|
|
546
|
+
### Aliases (`/alias`)
|
|
547
|
+
|
|
548
|
+
The pit is a prompt you sit at all day, so it lets you name the lines you keep
|
|
549
|
+
retyping. An alias runs in `$SHELL` unless it starts with `/`, in which case it
|
|
550
|
+
is a pit command:
|
|
551
|
+
|
|
552
|
+
```text
|
|
553
|
+
/alias set gs "git status" # then /gs — and /gs -sb appends to it
|
|
554
|
+
/alias set cx "/agents codex" # a pit command, not a shell one
|
|
555
|
+
/alias # what is defined
|
|
556
|
+
/alias rm gs
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
They live in `~/.moshcode/aliases.json` (owner-only, like the history file) and
|
|
560
|
+
survive between sessions. A name that is already a pit command, an engine, or a
|
|
561
|
+
tool is refused rather than shadowed — built-ins are dispatched first, so such
|
|
562
|
+
an alias would never run.
|
|
563
|
+
|
|
539
564
|
### Social posting from the pit
|
|
540
565
|
|
|
541
566
|
The pit can hand a prepared post to Bluesky or Nostr without storing either
|
package/package.json
CHANGED
package/src/aliases.mjs
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Named shortcuts for whatever you type at the mosh prompt.
|
|
2
|
+
//
|
|
3
|
+
// The pit is a prompt people sit at all day, and the things they retype are
|
|
4
|
+
// their own: `git status`, `pnpm -r test`, `/agents claude --resume`. Shell
|
|
5
|
+
// aliases can't help — the pit is not a shell, and `!git status` is exactly the
|
|
6
|
+
// keystrokes an alias is supposed to save. So the pit keeps its own.
|
|
7
|
+
//
|
|
8
|
+
// An alias is a name and a line. The line is a shell command unless it starts
|
|
9
|
+
// with `/`, in which case it is a pit command:
|
|
10
|
+
//
|
|
11
|
+
// /alias set gs "git status" → /gs runs `$SHELL -c "git status"`
|
|
12
|
+
// /alias set cc "/agents claude" → /cc opens claude autonomously
|
|
13
|
+
//
|
|
14
|
+
// Shell-by-default because that is what the prompt is mostly asked for, and the
|
|
15
|
+
// leading slash is already how the pit spells its own verbs — so the rule reads
|
|
16
|
+
// the same way the rest of the pit does rather than being a new convention.
|
|
17
|
+
//
|
|
18
|
+
// Anything the pit can dispatch is fair game as a value, which is what keeps
|
|
19
|
+
// this from needing to grow a type: a bookmarklet or a URL becomes an alias the
|
|
20
|
+
// day the pit gets a verb that opens one, with no change here.
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
|
|
25
|
+
/** Owner-only, and for the same reason ~/.moshcode_history is: values are
|
|
26
|
+
* whatever was typed, and people alias commands that carry tokens. */
|
|
27
|
+
const FILE_MODE = 0o600;
|
|
28
|
+
|
|
29
|
+
const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
30
|
+
|
|
31
|
+
/** A value long enough to be a pasted mistake rather than a command. */
|
|
32
|
+
const MAX_VALUE = 4096;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* How many times one line may expand before the pit gives up.
|
|
36
|
+
*
|
|
37
|
+
* Aliases can name aliases (`/alias set st "/gs --short"`), which is useful and
|
|
38
|
+
* also the one way to write a loop: two aliases naming each other would spin
|
|
39
|
+
* the dispatch loop forever. Ten is far past any chain a person builds on
|
|
40
|
+
* purpose.
|
|
41
|
+
*/
|
|
42
|
+
export const MAX_EXPANSIONS = 10;
|
|
43
|
+
|
|
44
|
+
/** Where the aliases live. Derived per call so tests can move $HOME. */
|
|
45
|
+
export function aliasFile() {
|
|
46
|
+
return path.join(os.homedir(), ".moshcode", "aliases.json");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every alias, as a plain name → line map.
|
|
51
|
+
*
|
|
52
|
+
* A file that is missing, unreadable, or not the shape we wrote reads as "no
|
|
53
|
+
* aliases" rather than throwing: this is called on the dispatch path for every
|
|
54
|
+
* unrecognised command, and a hand-edited file with a stray comma must not take
|
|
55
|
+
* the prompt down with it. Entries whose value is not a string are dropped for
|
|
56
|
+
* the same reason.
|
|
57
|
+
*/
|
|
58
|
+
export function loadAliases() {
|
|
59
|
+
let raw;
|
|
60
|
+
try { raw = fs.readFileSync(aliasFile(), "utf8"); }
|
|
61
|
+
catch { return {}; }
|
|
62
|
+
let parsed;
|
|
63
|
+
try { parsed = JSON.parse(raw); }
|
|
64
|
+
catch { return {}; }
|
|
65
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
66
|
+
const out = {};
|
|
67
|
+
for (const [name, value] of Object.entries(parsed)) {
|
|
68
|
+
if (typeof value === "string" && value.trim()) out[name.toLowerCase()] = value;
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Write the map back, creating ~/.moshcode if this is the first alias. */
|
|
74
|
+
function saveAliases(aliases) {
|
|
75
|
+
const file = aliasFile();
|
|
76
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
77
|
+
// Sorted so the file reads like a list rather than like insertion order, and
|
|
78
|
+
// so hand edits produce a small diff.
|
|
79
|
+
const ordered = Object.fromEntries(Object.keys(aliases).sort().map((k) => [k, aliases[k]]));
|
|
80
|
+
fs.writeFileSync(file, `${JSON.stringify(ordered, null, 2)}\n`, { mode: FILE_MODE });
|
|
81
|
+
// `mode` only applies at creation, so an existing file keeps whatever the
|
|
82
|
+
// umask gave it. Tighten every write, the way the history file does.
|
|
83
|
+
try { fs.chmodSync(file, FILE_MODE); } catch { /* best effort */ }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The name as it is stored, or "" for anything that cannot be one. */
|
|
87
|
+
export function normalizeName(name) {
|
|
88
|
+
const clean = String(name ?? "").trim().toLowerCase().replace(/^\//, "");
|
|
89
|
+
return NAME_RE.test(clean) ? clean : "";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One alias's line, or null. */
|
|
93
|
+
export function getAlias(name) {
|
|
94
|
+
const key = normalizeName(name);
|
|
95
|
+
if (!key) return null;
|
|
96
|
+
const aliases = loadAliases();
|
|
97
|
+
return Object.hasOwn(aliases, key) ? aliases[key] : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Define an alias. Returns { ok, error, name, value, previous }.
|
|
102
|
+
*
|
|
103
|
+
* `isReserved` asks the pit whether a name is already its own — a command, an
|
|
104
|
+
* engine, a tool. A predicate rather than a list because the dispatcher decides
|
|
105
|
+
* that by resolving, aliases included, and a list copied out of the rosters
|
|
106
|
+
* here would be a second answer that drifts from the first. A colliding name is
|
|
107
|
+
* refused rather than shadowed: built-ins are checked first, so an alias named
|
|
108
|
+
* `agents` would be silently dead, and a shortcut that does nothing is worse
|
|
109
|
+
* than one that was never accepted.
|
|
110
|
+
*/
|
|
111
|
+
export function setAlias(name, value, { isReserved = () => false } = {}) {
|
|
112
|
+
const key = normalizeName(name);
|
|
113
|
+
if (!key) {
|
|
114
|
+
return { ok: false, error: `"${name}" isn't a usable alias name — letters, digits, . _ - and it must start with a letter or digit` };
|
|
115
|
+
}
|
|
116
|
+
if (isReserved(key)) {
|
|
117
|
+
return { ok: false, error: `/${key} is already a pit command, engine, or tool — pick another name` };
|
|
118
|
+
}
|
|
119
|
+
const line = String(value ?? "").trim();
|
|
120
|
+
if (!line) return { ok: false, error: "an alias needs something to run" };
|
|
121
|
+
if (line.includes("\n")) return { ok: false, error: "an alias is a single line" };
|
|
122
|
+
if (line.length > MAX_VALUE) return { ok: false, error: `that value is ${line.length} characters — the cap is ${MAX_VALUE}` };
|
|
123
|
+
|
|
124
|
+
const aliases = loadAliases();
|
|
125
|
+
const previous = Object.hasOwn(aliases, key) ? aliases[key] : null;
|
|
126
|
+
aliases[key] = line;
|
|
127
|
+
try { saveAliases(aliases); }
|
|
128
|
+
catch (e) { return { ok: false, error: `can't write ${aliasFile()}: ${e.message}` }; }
|
|
129
|
+
return { ok: true, name: key, value: line, previous };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Forget one. Returns { ok, error, name, value }. */
|
|
133
|
+
export function removeAlias(name) {
|
|
134
|
+
const key = normalizeName(name);
|
|
135
|
+
const aliases = loadAliases();
|
|
136
|
+
if (!key || !Object.hasOwn(aliases, key)) {
|
|
137
|
+
return { ok: false, error: `no alias named "${String(name ?? "").replace(/^\//, "")}"` };
|
|
138
|
+
}
|
|
139
|
+
const value = aliases[key];
|
|
140
|
+
delete aliases[key];
|
|
141
|
+
try { saveAliases(aliases); }
|
|
142
|
+
catch (e) { return { ok: false, error: `can't write ${aliasFile()}: ${e.message}` }; }
|
|
143
|
+
return { ok: true, name: key, value };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The line an alias becomes, with anything else the user typed appended.
|
|
148
|
+
*
|
|
149
|
+
* Appended rather than substituted, the way a shell alias behaves: `/gs -sb` is
|
|
150
|
+
* `git status -sb`. `args` is the raw remainder of the typed line, not the
|
|
151
|
+
* tokenized parts, so the user's own quoting survives into `$SHELL -c`.
|
|
152
|
+
*
|
|
153
|
+
* The `!` is what routes a bare value to the shell — the pit already reads a
|
|
154
|
+
* leading `!` as "run this in $SHELL", so an alias does not need a second path
|
|
155
|
+
* through it.
|
|
156
|
+
*/
|
|
157
|
+
export function expandAlias(value, args = "") {
|
|
158
|
+
const line = `${String(value).trim()}${args ? ` ${args}` : ""}`;
|
|
159
|
+
return /^[/!]/.test(line) ? line : `!${line}`;
|
|
160
|
+
}
|
package/src/cli-schema.mjs
CHANGED
|
@@ -115,10 +115,13 @@ export const CORE_CLI_COMMANDS = [
|
|
|
115
115
|
name: "attach",
|
|
116
116
|
group: "runtime",
|
|
117
117
|
description: "attach this terminal to a herd session",
|
|
118
|
-
synopsis: [["moshcode attach <name>", "
|
|
118
|
+
synopsis: [["moshcode attach <name>", "F12 for the mosh bar · Ctrl-b d detaches (Ctrl-] without tmux)"]],
|
|
119
119
|
examples: [["moshcode attach api", ""]],
|
|
120
120
|
seeAlso: ["ps", "herd", "kill"],
|
|
121
|
-
note: "
|
|
121
|
+
note: "under tmux the session gets a one-line mosh bar along the bottom for as long as you are "
|
|
122
|
+
+ "attached, so the way out is on screen even when the agent has the keyboard: F12 reaches it, "
|
|
123
|
+
+ "Esc goes back, `detach` leaves. it is taken away again when you detach. "
|
|
124
|
+
+ "detaching leaves the session running; ending it is `moshcode kill`. "
|
|
122
125
|
+ "the whole herd shares one tmux server, so from inside any session Ctrl-b s picks another, "
|
|
123
126
|
+ "Ctrl-b ) and Ctrl-b ( step through them, and Ctrl-b L goes back to the last one — "
|
|
124
127
|
+ "no switcher under the no-tmux fallback, where Ctrl-] detaches instead.",
|
|
@@ -892,6 +895,24 @@ export const PIT_COMMANDS = [
|
|
|
892
895
|
description: "show the current dir + git repo/branch/origin" },
|
|
893
896
|
{ name: "shell", aliases: ["sh"], args: "[cmd]", pitOnly: true,
|
|
894
897
|
description: "drop into $SHELL (exit → back to the pit); also !cmd" },
|
|
898
|
+
{ name: "alias", aliases: ["aliases"], args: 'set <name> "<cmd>" | list | get | rm', pitOnly: true,
|
|
899
|
+
description: "name a line you keep retyping; /<name> runs it",
|
|
900
|
+
synopsis: [
|
|
901
|
+
['/alias set <name> "<command>"', "define one (also: /alias <name> \"<command>\")"],
|
|
902
|
+
["/alias [list] [--json]", "every alias"],
|
|
903
|
+
["/alias get <name>", "what one expands to"],
|
|
904
|
+
["/alias rm <name>", "forget one"],
|
|
905
|
+
],
|
|
906
|
+
examples: [
|
|
907
|
+
['/alias set gs "git status"', "then /gs — and /gs -sb appends"],
|
|
908
|
+
// Deliberately not `cc`: that one is already how the pit spells claude,
|
|
909
|
+
// so the example would print a refusal for anyone who typed it.
|
|
910
|
+
['/alias set cx "/agents codex"', "a pit command, not a shell one"],
|
|
911
|
+
["/alias rm gs", ""],
|
|
912
|
+
],
|
|
913
|
+
note: "the command runs in $SHELL unless it starts with / — then it is a pit command. "
|
|
914
|
+
+ "Aliases live in ~/.moshcode/aliases.json and cannot shadow a pit command, engine, or tool.",
|
|
915
|
+
},
|
|
895
916
|
{ name: "help", aliases: ["?", "h"], args: "[command]", pitOnly: true,
|
|
896
917
|
description: "this, or one command in detail" },
|
|
897
918
|
{ name: "quit", aliases: ["exit", "q"], pitOnly: true,
|
package/src/help.mjs
CHANGED
|
@@ -449,8 +449,21 @@ export function renderPitCommand(name) {
|
|
|
449
449
|
}
|
|
450
450
|
}
|
|
451
451
|
const out = [`/${entry.name} — ${entry.description}`];
|
|
452
|
-
|
|
452
|
+
// A pit-only verb may write its own synopsis/examples/note, the same shapes
|
|
453
|
+
// renderCommand reads. Without them the args string is the whole usage, which
|
|
454
|
+
// is enough for `/quit` and not enough for anything with sub-verbs.
|
|
455
|
+
const synopsis = entry.synopsis || (entry.args ? [[`/${entry.name} ${entry.args}`, ""]] : []);
|
|
456
|
+
if (synopsis.length) {
|
|
457
|
+
out.push("", "usage:");
|
|
458
|
+
for (const [line, note] of synopsis) out.push(row(line, note, 44));
|
|
459
|
+
}
|
|
453
460
|
if (entry.aliases?.length) out.push("", `aliases: ${entry.aliases.map((a) => `/${a}`).join(", ")}`);
|
|
461
|
+
const examples = entry.examples || [];
|
|
462
|
+
if (examples.length) {
|
|
463
|
+
out.push("", "examples:");
|
|
464
|
+
for (const [line, note] of examples) out.push(row(line, note ? `# ${note}` : "", 44));
|
|
465
|
+
}
|
|
466
|
+
if (entry.note) out.push("", wrap(entry.note, 0));
|
|
454
467
|
return out.join("\n");
|
|
455
468
|
}
|
|
456
469
|
|
package/src/herd-bar.mjs
CHANGED
|
@@ -54,6 +54,90 @@ export function paneRoles(target, { runner = spawnSync } = {}) {
|
|
|
54
54
|
return roles;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** How a bar pane starts itself. Separated so tests can run a stand-in. */
|
|
58
|
+
export function barCommand(self = process.argv[1]) {
|
|
59
|
+
return `${process.execPath} ${self} herd bar`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The window a pane lives in, as a target string. */
|
|
63
|
+
export function ownTarget({ runner = spawnSync, me = process.env.TMUX_PANE } = {}) {
|
|
64
|
+
if (!me) return null;
|
|
65
|
+
const r = tmux(["display-message", "-p", "-t", me, "#{session_name}:#{window_index}"], { runner });
|
|
66
|
+
return r.ok ? r.stdout.trim() || null : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Put a bar at the bottom of `target`, or find the one already there.
|
|
71
|
+
*
|
|
72
|
+
* Idempotent, because both the workspace and every attach want one and neither
|
|
73
|
+
* should care which of them got there first.
|
|
74
|
+
*/
|
|
75
|
+
export function ensureBar(target, { runner = spawnSync, command = null } = {}) {
|
|
76
|
+
const existing = paneRoles(target, { runner }).bar;
|
|
77
|
+
if (existing) return { paneId: existing.paneId, created: false };
|
|
78
|
+
const made = tmux(
|
|
79
|
+
["split-window", "-t", target, "-f", "-v", "-l", String(BAR_HEIGHT), "-P", "-F", "#{pane_id}", command],
|
|
80
|
+
{ runner },
|
|
81
|
+
);
|
|
82
|
+
if (!made.ok) return { paneId: null, created: false };
|
|
83
|
+
const paneId = made.stdout.trim().split("\n")[0];
|
|
84
|
+
if (!paneId) return { paneId: null, created: false };
|
|
85
|
+
tmux(["select-pane", "-t", paneId, "-T", BAR_TITLE], { runner });
|
|
86
|
+
return { paneId, created: true };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Bind the key that reaches the bar.
|
|
91
|
+
*
|
|
92
|
+
* `{bottom-right}` rather than a pane id, so one binding serves the workspace
|
|
93
|
+
* and every attached session — in both, the bar is the bottom row. A pane id
|
|
94
|
+
* would have pinned the key to whichever bar happened to be built last.
|
|
95
|
+
*
|
|
96
|
+
* The root table is what makes it work at all: tmux claims the key before the
|
|
97
|
+
* pane's application ever sees it, which is the whole point when the pane holds
|
|
98
|
+
* an agent that has taken the keyboard.
|
|
99
|
+
*/
|
|
100
|
+
export function bindJumpKey({ runner = spawnSync } = {}) {
|
|
101
|
+
return tmux(["bind-key", "-n", BAR_KEY, "select-pane", "-t", "{bottom-right}"], { runner }).ok;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Drop the bar from a window, leaving whatever else is in it alone. */
|
|
105
|
+
export function removeBar(target, { runner = spawnSync } = {}) {
|
|
106
|
+
const roles = paneRoles(target, { runner });
|
|
107
|
+
if (!roles.bar || !roles.content) return false;
|
|
108
|
+
return tmux(["kill-pane", "-t", roles.bar.paneId], { runner }).ok;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Take the bar back out of every session nobody is looking at.
|
|
113
|
+
*
|
|
114
|
+
* A bar left behind is not cosmetic: `kill` ends a member by killing its pane,
|
|
115
|
+
* so a session holding a leftover bar outlives the member it was named for and
|
|
116
|
+
* keeps showing up on the roster. Detaching cleans up after itself, but a
|
|
117
|
+
* crashed client cannot, so this also runs on the way in.
|
|
118
|
+
*
|
|
119
|
+
* Sessions with a client attached are skipped — someone else is using that bar.
|
|
120
|
+
*/
|
|
121
|
+
export function sweepBars({ runner = spawnSync, except = null } = {}) {
|
|
122
|
+
const r = tmux(["list-panes", "-a", "-F",
|
|
123
|
+
"#{session_name}\t#{window_index}\t#{pane_title}\t#{session_attached}"], { runner });
|
|
124
|
+
if (!r.ok) return 0;
|
|
125
|
+
const windows = new Map();
|
|
126
|
+
for (const line of r.stdout.split("\n")) {
|
|
127
|
+
const [session, window, title, attached] = line.split("\t");
|
|
128
|
+
if (!session || session === except || attached !== "0") continue;
|
|
129
|
+
const key = `${session}:${window}`;
|
|
130
|
+
const seen = windows.get(key) || { bars: 0, others: 0 };
|
|
131
|
+
if (title === BAR_TITLE) seen.bars += 1; else seen.others += 1;
|
|
132
|
+
windows.set(key, seen);
|
|
133
|
+
}
|
|
134
|
+
let removed = 0;
|
|
135
|
+
for (const [target, seen] of windows) {
|
|
136
|
+
if (seen.bars && seen.others && removeBar(target, { runner })) removed += 1;
|
|
137
|
+
}
|
|
138
|
+
return removed;
|
|
139
|
+
}
|
|
140
|
+
|
|
57
141
|
/* --------------------------------------------------------------- line editing */
|
|
58
142
|
|
|
59
143
|
/**
|
|
@@ -118,10 +202,14 @@ export async function herdBar({
|
|
|
118
202
|
stdin = process.stdin,
|
|
119
203
|
stdout = process.stdout,
|
|
120
204
|
runner = spawnSync,
|
|
121
|
-
target =
|
|
205
|
+
target = null,
|
|
122
206
|
run = null,
|
|
123
207
|
} = {}) {
|
|
124
208
|
const me = process.env.TMUX_PANE;
|
|
209
|
+
// The bar runs in the workspace AND under a plain attach, so it asks where it
|
|
210
|
+
// is rather than assuming. Everything below keys off that one answer.
|
|
211
|
+
const here = target || ownTarget({ runner, me }) || "herd:ui";
|
|
212
|
+
const inWorkspace = () => !!paneRoles(here, { runner }).sidebar;
|
|
125
213
|
const herdCommand = run || (async (argv, options) => (await import("./herd-cli.mjs")).herdCommand(argv, options));
|
|
126
214
|
|
|
127
215
|
let line = "";
|
|
@@ -146,10 +234,26 @@ export async function herdBar({
|
|
|
146
234
|
};
|
|
147
235
|
/** Give the keyboard back to whatever is on screen. */
|
|
148
236
|
const toContent = () => {
|
|
149
|
-
const roles = paneRoles(
|
|
237
|
+
const roles = paneRoles(here, { runner });
|
|
150
238
|
if (roles.content) tmux(["select-pane", "-t", roles.content.paneId], { runner });
|
|
151
239
|
};
|
|
152
240
|
|
|
241
|
+
/**
|
|
242
|
+
* `show` means two different things and both are right.
|
|
243
|
+
*
|
|
244
|
+
* In the workspace it swaps the content pane. Under a plain attach there is
|
|
245
|
+
* no content pane to swap, so it switches the client to that member — and
|
|
246
|
+
* gives that member a bar first, or you would arrive somewhere with no way
|
|
247
|
+
* back out, which is the bug this whole thing exists to fix.
|
|
248
|
+
*/
|
|
249
|
+
const showElsewhere = async (name) => {
|
|
250
|
+
const { paneIndex } = await import("./herd.mjs");
|
|
251
|
+
const found = paneIndex({ runner }).get(name);
|
|
252
|
+
if (!found) return false;
|
|
253
|
+
ensureBar(`${found.session}:${found.windowId}`, { runner, command: barCommand() });
|
|
254
|
+
return tmux(["switch-client", "-t", found.session], { runner }).ok;
|
|
255
|
+
};
|
|
256
|
+
|
|
153
257
|
const submit = async () => {
|
|
154
258
|
const typed = line;
|
|
155
259
|
line = "";
|
|
@@ -160,8 +264,13 @@ export async function herdBar({
|
|
|
160
264
|
if (command.kind === "detach") { tmux(["detach-client"], { runner }); return false; }
|
|
161
265
|
if (command.kind === "show") {
|
|
162
266
|
const [name] = command.argv;
|
|
163
|
-
|
|
164
|
-
|
|
267
|
+
let okShown = false;
|
|
268
|
+
if (name && inWorkspace()) {
|
|
269
|
+
const { showMember } = await import("./herd-workspace.mjs");
|
|
270
|
+
okShown = showMember(name, { runner, me });
|
|
271
|
+
} else if (name) {
|
|
272
|
+
okShown = await showElsewhere(name);
|
|
273
|
+
}
|
|
165
274
|
if (!okShown) { show([ash(`no session named ${JSON.stringify(name || "")} — try ps`)]); return true; }
|
|
166
275
|
collapse(); draw(); toContent();
|
|
167
276
|
return true;
|
|
@@ -173,8 +282,24 @@ export async function herdBar({
|
|
|
173
282
|
return true;
|
|
174
283
|
};
|
|
175
284
|
|
|
285
|
+
/**
|
|
286
|
+
* Keep the bar one row.
|
|
287
|
+
*
|
|
288
|
+
* tmux scales panes proportionally when the window resizes, so a bar built
|
|
289
|
+
* before a client attached came back three rows tall once one did — the pane
|
|
290
|
+
* was created against an 80x24 window and stretched to fit 100x30. Nothing
|
|
291
|
+
* outside can predict when that happens, but the bar gets a resize event for
|
|
292
|
+
* it, so the bar is the thing that fixes it.
|
|
293
|
+
*/
|
|
294
|
+
const keepThin = () => {
|
|
295
|
+
if (open) return;
|
|
296
|
+
tmux(["resize-pane", "-t", me, "-y", String(BAR_HEIGHT)], { runner });
|
|
297
|
+
};
|
|
298
|
+
|
|
176
299
|
try { stdin.setRawMode?.(true); } catch { /* not a tty */ }
|
|
177
300
|
stdin.resume();
|
|
301
|
+
stdout.on?.("resize", () => { keepThin(); draw(); });
|
|
302
|
+
keepThin();
|
|
178
303
|
draw();
|
|
179
304
|
|
|
180
305
|
await new Promise((resolve) => {
|
package/src/herd-cli.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import path from "node:path";
|
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
12
|
attachSession, capture, defaultName, detectSubstrate, forgetSession, HERD_SOCKET,
|
|
13
|
-
herdDir, killSession, listSessions, readManifest, rememberSession, sendKeys, sendPrompt,
|
|
13
|
+
herdDir, killSession, listSessions, paneIndex, readManifest, rememberSession, sendKeys, sendPrompt,
|
|
14
14
|
slugifyName, startSession, stopRuntime, substrateNote, validName, NAME_RE,
|
|
15
15
|
} from "./herd.mjs";
|
|
16
16
|
import { clearReport, reportState, STATES, withState } from "./herd-state.mjs";
|
|
@@ -328,9 +328,32 @@ export async function herdAttach(argv, { write = console.log } = {}) {
|
|
|
328
328
|
// this whole feature is someone quitting a session they meant to leave
|
|
329
329
|
// running, and the only defence is telling them the key first.
|
|
330
330
|
const substrate = detectSubstrate();
|
|
331
|
-
|
|
331
|
+
|
|
332
|
+
// Give the session a mosh bar, so the way out is on screen the whole time
|
|
333
|
+
// rather than in a line that the agent's first repaint scrolls away. Only a
|
|
334
|
+
// member sitting in its own session: a tiled one shares a window with its
|
|
335
|
+
// neighbours and would be handing them a footer they did not ask for.
|
|
336
|
+
const bar = await import("./herd-bar.mjs");
|
|
337
|
+
let barTarget = null;
|
|
338
|
+
if (substrate === "tmux") {
|
|
339
|
+
bar.sweepBars({ runner: undefined, except: "herd" });
|
|
340
|
+
const found = paneIndex().get(name);
|
|
341
|
+
if (found && found.session === name) {
|
|
342
|
+
barTarget = `${found.session}:${found.windowId}`;
|
|
343
|
+
bar.ensureBar(barTarget, { command: bar.barCommand() });
|
|
344
|
+
bar.bindJumpKey({});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
write(info(substrate === "tmux"
|
|
349
|
+
? `detach with Ctrl-b d — the session keeps running.${barTarget ? ` ${bar.BAR_KEY} for the mosh bar.` : ""}`
|
|
350
|
+
: "detach with Ctrl-] — the session keeps running."));
|
|
332
351
|
|
|
333
352
|
const result = await attachSession(name, { substrate });
|
|
353
|
+
// Take it back out on the way through, so a member is a member again: `kill`
|
|
354
|
+
// ends one by killing its pane, and a session still holding a bar would
|
|
355
|
+
// outlive the member and keep its name on the roster.
|
|
356
|
+
if (barTarget) bar.removeBar(barTarget, {});
|
|
334
357
|
if (!result.ok) { write(err(String(result.error?.message || result.error))); return EXIT.usage; }
|
|
335
358
|
|
|
336
359
|
const after = findSession(name);
|
package/src/herd-workspace.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
21
21
|
import { HERD_SOCKET, detectSubstrate, paneIndex, readManifest, tmux } from "./herd.mjs";
|
|
22
22
|
import { roster } from "./herd-cli.mjs";
|
|
23
23
|
import { groupByHerd, parseInput } from "./herd-ui.mjs";
|
|
24
|
-
import {
|
|
24
|
+
import { BAR_KEY, BAR_TITLE, SIDEBAR_TITLE, barCommand, bindJumpKey, ensureBar, paneRoles } from "./herd-bar.mjs";
|
|
25
25
|
import { acid, amber, ash, bone, danger, dim, err, info, ok } from "./ui.mjs";
|
|
26
26
|
|
|
27
27
|
export const WORKSPACE = "herd";
|
|
@@ -82,34 +82,11 @@ export async function herdUi(argv = [], { write = console.log, spawner = spawn,
|
|
|
82
82
|
|
|
83
83
|
/* ------------------------------------------------------------------ the bar */
|
|
84
84
|
|
|
85
|
-
/**
|
|
86
|
-
export function barCommand(self = process.argv[1]) {
|
|
87
|
-
return `${process.execPath} ${self} herd bar`;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Add the one-line mosh prompt under the content, and the key that reaches it.
|
|
92
|
-
*
|
|
93
|
-
* The binding goes in tmux's root table, so it is claimed before the pane's
|
|
94
|
-
* application ever sees it — that is what makes it work from inside an agent
|
|
95
|
-
* that has taken the keyboard, which is the case the bar exists for. It also
|
|
96
|
-
* switches the client first, so it is a way out of a member you attached to
|
|
97
|
-
* directly and not only of the workspace.
|
|
98
|
-
*/
|
|
85
|
+
/** Add the one-line mosh prompt under the content, and the key that reaches it. */
|
|
99
86
|
export function buildBar({ runner = spawnSync, command = barCommand() } = {}) {
|
|
100
|
-
const
|
|
101
|
-
["split-window", "-t", TARGET, "-f", "-v", "-l", String(BAR_HEIGHT), "-P", "-F", "#{pane_id}", command],
|
|
102
|
-
{ runner },
|
|
103
|
-
);
|
|
104
|
-
if (!made.ok) return null;
|
|
105
|
-
const paneId = made.stdout.trim().split("\n")[0];
|
|
87
|
+
const { paneId } = ensureBar(TARGET, { runner, command });
|
|
106
88
|
if (!paneId) return null;
|
|
107
|
-
|
|
108
|
-
// One string, not separate arguments: a bare ";" argument ends the bind-key
|
|
109
|
-
// command itself, so tmux binds the first command and runs the second once,
|
|
110
|
-
// now. That silently produced a key that switched sessions and did nothing
|
|
111
|
-
// else — the binding has to arrive as a single command sequence.
|
|
112
|
-
tmux(["bind-key", "-n", BAR_KEY, `switch-client -t ${WORKSPACE} ; select-pane -t ${paneId}`], { runner });
|
|
89
|
+
bindJumpKey({ runner });
|
|
113
90
|
tmux(["select-pane", "-t", `${TARGET}.0`], { runner });
|
|
114
91
|
return paneId;
|
|
115
92
|
}
|
package/src/herd.mjs
CHANGED
|
@@ -629,7 +629,19 @@ export function killSession(name, { substrate = detectSubstrate(), runner = spaw
|
|
|
629
629
|
if (substrate === "tmux") {
|
|
630
630
|
// kill-pane, not kill-session: a tiled member shares its session with
|
|
631
631
|
// every other tiled member, and killing that would take the lot.
|
|
632
|
+
const found = paneIndex({ runner }).get(name);
|
|
632
633
|
const r = tmux(["kill-pane", "-t", target(name, { runner })], { runner });
|
|
634
|
+
// A member being attached to has a mosh bar under it, and the bar would
|
|
635
|
+
// hold the session open after its member is gone — an empty room still
|
|
636
|
+
// answering to the dead member's name on the roster. If that is all that is
|
|
637
|
+
// left, take the room too.
|
|
638
|
+
if (r.ok && found) {
|
|
639
|
+
const left = tmux(["list-panes", "-t", found.session, "-F", "#{pane_title}"], { runner });
|
|
640
|
+
const titles = left.ok ? left.stdout.split("\n").filter(Boolean) : [];
|
|
641
|
+
if (titles.length && titles.every((t) => t === "mosh-bar")) {
|
|
642
|
+
tmux(["kill-session", "-t", found.session], { runner });
|
|
643
|
+
}
|
|
644
|
+
}
|
|
633
645
|
forgetSession(name);
|
|
634
646
|
return r.ok ? { ok: true } : { ok: false, error: new Error(r.stderr.trim() || "no such session") };
|
|
635
647
|
}
|
package/src/tui.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import { banner, hr, acid, ash, bone, dim, ok, err, warn, info, moshcodeVersion
|
|
|
27
27
|
import { CORE_CLI_COMMAND_NAMES } from "./cli-schema.mjs";
|
|
28
28
|
import { RENAMED_COMMANDS, findPitCommand, pitHelpModel, renderPitCommand, suggest, wantsHelp } from "./help.mjs";
|
|
29
29
|
import { openNewTab } from "./tabs.mjs";
|
|
30
|
+
import { MAX_EXPANSIONS, expandAlias, getAlias, loadAliases, removeAlias, setAlias } from "./aliases.mjs";
|
|
30
31
|
import { herdCommand, herdStart, renderRoster, roster, splitDetachArgs } from "./herd-cli.mjs";
|
|
31
32
|
import { detectSubstrate, substrateNote } from "./herd.mjs";
|
|
32
33
|
|
|
@@ -128,9 +129,32 @@ export function splitCommandLine(line) {
|
|
|
128
129
|
// hands this straight to `$SHELL -c`, the same way `!cmd` does: the shell does
|
|
129
130
|
// its own parsing, so re-joining the tokenized parts would strip the user's
|
|
130
131
|
// quotes and escapes and silently split `-m "two words"` into two arguments.
|
|
131
|
-
function commandRemainder(line) {
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
function commandRemainder(line, words = 1) {
|
|
133
|
+
let out = String(line);
|
|
134
|
+
for (let i = 0; i < words; i++) {
|
|
135
|
+
const firstWord = /^\s*\S+\s*/.exec(out);
|
|
136
|
+
out = firstWord ? out.slice(firstWord[0].length) : "";
|
|
137
|
+
}
|
|
138
|
+
return out.trim();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The value half of `/alias set <name> <value…>`, as the user meant it.
|
|
143
|
+
*
|
|
144
|
+
* `/alias set gs "git status"` quotes the value because that is the obvious way
|
|
145
|
+
* to write it, and `/alias set gc git commit -m "wip"` does not because the
|
|
146
|
+
* quotes there belong to the shell. Tokenizing tells the two apart: exactly one
|
|
147
|
+
* token means the whole value was quoted, so use it with the quotes stripped;
|
|
148
|
+
* anything else is a bare command line, and it goes through verbatim so the
|
|
149
|
+
* user's own quoting survives into `$SHELL -c`.
|
|
150
|
+
*/
|
|
151
|
+
export function aliasValue(line) {
|
|
152
|
+
const raw = commandRemainder(line, 3); // past "/alias", "set", "<name>"
|
|
153
|
+
if (!raw) return "";
|
|
154
|
+
let parts;
|
|
155
|
+
try { parts = splitCommandLine(raw); }
|
|
156
|
+
catch { return raw; }
|
|
157
|
+
return parts.length === 1 ? parts[0] : raw;
|
|
134
158
|
}
|
|
135
159
|
|
|
136
160
|
function printEngines(json = false) {
|
|
@@ -209,6 +233,94 @@ function printSocials() {
|
|
|
209
233
|
console.log(ash(" the browser always asks you to confirm before anything is published"));
|
|
210
234
|
}
|
|
211
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Is this name the pit's own?
|
|
238
|
+
*
|
|
239
|
+
* Asked by resolving it the way the dispatcher does, rather than by consulting
|
|
240
|
+
* a list: the dispatcher checks pit verbs, then engines, then tools, and only
|
|
241
|
+
* then aliases, so anything that resolves earlier would shadow an alias of the
|
|
242
|
+
* same name into silence.
|
|
243
|
+
*/
|
|
244
|
+
function isReservedName(name) {
|
|
245
|
+
const key = String(name).toLowerCase();
|
|
246
|
+
return Boolean(findPitCommand(key) || resolveEngine(key) || resolveTool(key) || RENAMED_COMMANDS[key]);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function printAliases({ json = false } = {}) {
|
|
250
|
+
const aliases = loadAliases();
|
|
251
|
+
const names = Object.keys(aliases).sort();
|
|
252
|
+
if (json) { console.log(JSON.stringify(aliases, null, 2)); return; }
|
|
253
|
+
if (!names.length) {
|
|
254
|
+
console.log(info(`no aliases yet — ${acid('/alias set gs "git status"')} then ${acid("/gs")}.`));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
console.log(bone(" aliases") + ash(" — run one with ") + acid("/<name>") + ash(" · ") + acid("/alias rm <name>") + ash(" to forget"));
|
|
258
|
+
const width = Math.max(...names.map((n) => n.length));
|
|
259
|
+
for (const name of names) {
|
|
260
|
+
// A leading slash marks the ones that are pit commands rather than shell,
|
|
261
|
+
// which is the only thing about a value that is not already visible.
|
|
262
|
+
const value = aliases[name];
|
|
263
|
+
const kind = value.startsWith("/") ? ash("pit ") : ash("shell");
|
|
264
|
+
console.log(` ${acid(`/${name}`.padEnd(width + 1))} ${kind} ${bone(value)}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* `/alias` — define, list, and forget the shortcuts (src/aliases.mjs).
|
|
270
|
+
*
|
|
271
|
+
* `line` comes in alongside the tokenized `rest` because the value is a command
|
|
272
|
+
* line, not an argument list: re-joining tokens would drop the quoting that the
|
|
273
|
+
* shell still has to read.
|
|
274
|
+
*/
|
|
275
|
+
function aliasCommand(rest, line) {
|
|
276
|
+
const json = rest.includes("--json");
|
|
277
|
+
// `--json` is the listing's flag wherever it appears, so `/alias --json` is a
|
|
278
|
+
// listing rather than a verb nobody recognises. The value in `set` is read
|
|
279
|
+
// from the raw line, not from here, so an aliased command that itself passes
|
|
280
|
+
// --json is untouched by this.
|
|
281
|
+
const [verb, ...args] = rest.filter((a) => a !== "--json");
|
|
282
|
+
const sub = String(verb ?? "").toLowerCase();
|
|
283
|
+
|
|
284
|
+
if (!verb || sub === "list" || sub === "ls") {
|
|
285
|
+
printAliases({ json });
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (sub === "set" || sub === "add") {
|
|
289
|
+
const name = args[0];
|
|
290
|
+
const value = aliasValue(line);
|
|
291
|
+
if (!name || !value) {
|
|
292
|
+
console.log(err('usage: /alias set <name> "<command>"'));
|
|
293
|
+
console.log(ash(" the command runs in $SHELL unless it starts with / — then it's a pit command"));
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const result = setAlias(name, value, { isReserved: isReservedName });
|
|
297
|
+
if (!result.ok) { console.log(err(result.error)); return; }
|
|
298
|
+
console.log(ok(`${acid(`/${result.name}`)} → ${bone(result.value)}`));
|
|
299
|
+
if (result.previous) console.log(ash(` replaced: ${result.previous}`));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (sub === "rm" || sub === "remove" || sub === "unset" || sub === "delete" || sub === "del") {
|
|
303
|
+
if (!args[0]) { console.log(err("usage: /alias rm <name>")); return; }
|
|
304
|
+
const result = removeAlias(args[0]);
|
|
305
|
+
console.log(result.ok ? ok(`forgot ${acid(`/${result.name}`)} ${ash(`(was: ${result.value})`)}`) : err(result.error));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (sub === "get" || sub === "show") {
|
|
309
|
+
if (!args[0]) { console.log(err("usage: /alias get <name>")); return; }
|
|
310
|
+
const value = getAlias(args[0]);
|
|
311
|
+
console.log(value == null
|
|
312
|
+
? err(`no alias named "${String(args[0]).replace(/^\//, "")}"`)
|
|
313
|
+
: ` ${acid(`/${String(args[0]).toLowerCase().replace(/^\//, "")}`)} ${ash("→")} ${bone(value)}`);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
// A bare `/alias gs "git status"` is what people type once they know the
|
|
317
|
+
// command exists, so treat an unknown verb as the name in `set` — but only
|
|
318
|
+
// when there is a value after it, or `/alias gs` would silently define
|
|
319
|
+
// nothing.
|
|
320
|
+
if (args.length) { aliasCommand(["set", ...rest], `/alias set ${commandRemainder(line)}`); return; }
|
|
321
|
+
console.log(err(`unknown /alias verb "${verb}" — set, list, get, rm`));
|
|
322
|
+
}
|
|
323
|
+
|
|
212
324
|
/**
|
|
213
325
|
* The moshscript vocabulary, split the way the CLI's help splits it.
|
|
214
326
|
*
|
|
@@ -526,19 +638,31 @@ export async function tui() {
|
|
|
526
638
|
const { restoreTee, drainRemote, atPrompt } = await startMirror();
|
|
527
639
|
|
|
528
640
|
let rl = mkrl();
|
|
641
|
+
// An alias expands into a line that is dispatched exactly as if it had been
|
|
642
|
+
// typed, so it goes back through the top of this loop instead of through a
|
|
643
|
+
// second copy of the dispatcher. `expansions` bounds a chain of aliases that
|
|
644
|
+
// name each other; it resets whenever a real line is read.
|
|
645
|
+
let pending = null;
|
|
646
|
+
let expansions = 0;
|
|
529
647
|
for (;;) {
|
|
530
648
|
let line;
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
649
|
+
if (pending != null) {
|
|
650
|
+
line = pending;
|
|
651
|
+
pending = null;
|
|
652
|
+
} else {
|
|
653
|
+
// Arm the prompt first, THEN release any command waiting from the web:
|
|
654
|
+
// rl.write() only lands as input once readline is actually asking.
|
|
655
|
+
const answer = ask(rl);
|
|
656
|
+
atPrompt(rl);
|
|
657
|
+
drainRemote();
|
|
658
|
+
try { line = await answer; } catch { break; }
|
|
659
|
+
finally { atPrompt(null); }
|
|
660
|
+
if (line == null) break; // Ctrl-D
|
|
661
|
+
expansions = 0;
|
|
662
|
+
line = line.trim();
|
|
663
|
+
if (!line) continue;
|
|
664
|
+
saveHistory(); // readline just recorded this line into the shared history
|
|
665
|
+
}
|
|
542
666
|
|
|
543
667
|
// vim-style shell escape: `!` drops into $SHELL, `!<cmd>` runs one-off. We
|
|
544
668
|
// take the raw remainder (not the tokenized parts) so quoting is preserved.
|
|
@@ -586,6 +710,7 @@ export async function tui() {
|
|
|
586
710
|
rl = mkrl();
|
|
587
711
|
continue;
|
|
588
712
|
}
|
|
713
|
+
if (cmd === "alias" || cmd === "aliases") { aliasCommand(rest, line); continue; }
|
|
589
714
|
if (cmd === "pwd" || cmd === "where") { printPwd(); continue; }
|
|
590
715
|
if (cmd === "login") {
|
|
591
716
|
const device = rest.includes("--device") || rest.includes("device") || rest.includes("-d");
|
|
@@ -785,6 +910,23 @@ export async function tui() {
|
|
|
785
910
|
rl = mkrl();
|
|
786
911
|
continue;
|
|
787
912
|
}
|
|
913
|
+
// A user-defined alias (src/aliases.mjs) — last, so it can never shadow a
|
|
914
|
+
// built-in, and so an alias that names one is dead rather than surprising.
|
|
915
|
+
// /alias set refuses those names for exactly this reason.
|
|
916
|
+
const aliased = getAlias(cmd);
|
|
917
|
+
if (aliased) {
|
|
918
|
+
if (expansions >= MAX_EXPANSIONS) {
|
|
919
|
+
console.log(err(`/${cmd} keeps expanding — ${MAX_EXPANSIONS} rounds and still not a command. check /alias list for a loop.`));
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
expansions += 1;
|
|
923
|
+
pending = expandAlias(aliased, commandRemainder(line));
|
|
924
|
+
// Echoed because the line that runs is not the line that was typed, and a
|
|
925
|
+
// shell command that fails is a lot easier to read when what actually ran
|
|
926
|
+
// is on the screen above it.
|
|
927
|
+
console.log(ash(` ▸ ${pending}`));
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
788
930
|
// A renamed verb gets pointed at its replacement; `/ticker` was a pit
|
|
789
931
|
// command for a release, so a bare "unknown command" is a dead end here.
|
|
790
932
|
const renamed = RENAMED_COMMANDS[cmd];
|