Skip to content

Commit 4abcdec

Browse files
ralyodioclaude
andauthored
feat(help): moshscript verbs, terminal manners, and completable topics (#263)
The tail of PRD 0006 — R14, R15, R16. R14. `moshcode help ask` answered "no help for ask". `ask()` is as much part of the interface as `moshcode prd`, but the vocabulary lives in a registry help had never been introduced to. Verbs now carry an optional `usage` — `ai(prompt, { engine })`, `ask(...prompt)` — and help renders it with the summary and a note that this one is called from a .mosh file, not a shell. `usage` stays optional, so a verb registered by a host that has not declared one still renders as `name(…)`. cliVerb() derives its own signature rather than repeating it twenty times: every CLI verb forwards its arguments to `moshcode <name>`, so adding a line to the vocabulary documents it. R15 turned out to be already true and is now nailed down. src/ui.mjs gates colour on `NO_COLOR == null && process.stdout.isTTY`, so both the CLI and the pit already emit plain text to a pipe. Three tests keep it that way, including one for the pit — which needs spawnSync rather than the execFile helper the rest of the file uses, because the pit reads stdin and execFile has no way to supply it. Nothing paginates. R16. `moshcode help <tab>` now completes topics, in all four shells. The list is what help itself accepts — commands, engines, tools and moshscript verbs — so tab-completion and help cannot disagree about what is answerable. A test asserts exactly that: every offered topic resolves. 1099 tests, 887 pass, 0 fail. That closes PRD 0006. R1–R16 are implemented across #257, #258, #260 and this. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 39bca06 commit 4abcdec

6 files changed

Lines changed: 192 additions & 5 deletions

File tree

‎bin/moshcode.mjs‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { completionScript } from "../src/completion.mjs";
3232
import { CORE_CLI_COMMAND_NAMES } from "../src/cli-schema.mjs";
3333
import {
3434
findCommand, findVerb, helpModel, renderAll, renderCommand, renderOverview,
35-
renderMarkdown, suggest, wantsHelp, withoutHelp,
35+
renderMarkdown, renderScriptVerb, suggest, wantsHelp, withoutHelp,
3636
} from "../src/help.mjs";
3737
import { moshcodeVersion } from "../src/ui.mjs";
3838

@@ -216,7 +216,16 @@ function handledHelp(cmd, rest) {
216216
console.log(`see also: moshcode help tools · moshcode help install`);
217217
return true;
218218
}
219-
const near = suggest(args[0], [...Object.keys(ENGINES), ...Object.keys(TOOLS)]);
219+
// A moshscript verb is a fair thing to ask about — `ask()` is as much
220+
// part of the interface as `moshcode prd` (R14).
221+
const verbHelp = renderScriptVerb(moshVocabulary().get(args[0]));
222+
if (verbHelp) {
223+
console.log(verbHelp);
224+
return true;
225+
}
226+
const near = suggest(args[0], [
227+
...Object.keys(ENGINES), ...Object.keys(TOOLS), ...moshVocabulary().names(),
228+
]);
220229
console.error(`✗ no help for ${JSON.stringify(args[0])}${near ? ` — did you mean ${near}?` : ""}`);
221230
console.error(" moshcode help list commands");
222231
process.exitCode = 1;

‎src/cli.mjs‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,17 @@ export function runMoshcode(cmd, args, ctx) {
6060

6161
/** A vocabulary command mapping `name(...args)` → `moshcode name ...args`. */
6262
export function cliVerb(name, summary) {
63-
return { name, summary, run: (ctx, ...args) => runMoshcode(name, args, ctx) };
63+
// `usage` is the call signature `moshcode help <verb>` renders (PRD 0006
64+
// R14). Every CLI verb has the same one — it forwards its arguments to
65+
// `moshcode <name>` — so deriving it here means a verb added below is
66+
// documented by the act of adding it.
67+
return {
68+
name,
69+
summary,
70+
usage: `${name}(...args)`,
71+
detail: `runs \`moshcode ${name} ...args\` and returns { ok, code }`,
72+
run: (ctx, ...args) => runMoshcode(name, args, ctx),
73+
};
6474
}
6575

6676
/**
@@ -102,5 +112,7 @@ export function runAi(ctx, prompt, opts = {}) {
102112
export const aiVerb = {
103113
name: "ai",
104114
summary: "run a coding engine on a prompt and return its output (shortcut)",
115+
usage: "ai(prompt, { engine })",
116+
detail: "blocking; returns the engine's stdout as a string. engine defaults to the first installed one. needs await",
105117
run: (ctx, prompt, opts) => runAi(ctx, prompt, opts),
106118
};

‎src/commands.mjs‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ const COMMANDS = [
6767
{
6868
name: "code",
6969
summary: "compile features (no bugs)",
70+
usage: "code()",
71+
detail: "narrates a build step; takes no arguments",
7072
run(ctx, ...args) {
7173
expectNoArgs("code", args);
7274
ctx.out(" ⌨ code() → compiling features (no bugs)…");
@@ -75,6 +77,8 @@ const COMMANDS = [
7577
{
7678
name: "mosh",
7779
summary: "open the pit + blast the moshcoding playlist",
80+
usage: "mosh()",
81+
detail: "opens the pit and starts the playlist",
7882
run(ctx, ...args) {
7983
expectNoArgs("mosh", args);
8084
ctx.out(" 🤘 mosh() → opening the pit");
@@ -91,6 +95,8 @@ const COMMANDS = [
9195
{
9296
name: "notify",
9397
summary: "ping the operator via app.moshcode.sh (email/SMS/Slack/Telegram/push)",
98+
usage: "notify(...message)",
99+
detail: "returns { id, url } — fire and forget, no reply awaited",
94100
// Fire-and-forget. Posts the approval to the app, which fans it out to the
95101
// operator's channels. Returns { id, url } so a script can hand the link off.
96102
async run(ctx, ...args) {
@@ -107,6 +113,8 @@ const COMMANDS = [
107113
{
108114
name: "ask",
109115
summary: "notify + BLOCK until the human approves/instructs at app.moshcode.sh",
116+
usage: "ask(...prompt)",
117+
detail: "BLOCKS until a human answers at app.moshcode.sh; returns their reply or null. needs await",
110118
// The human-in-the-loop gate. Posts the approval to the app, then waits for
111119
// the operator to open app.moshcode.sh/approve/:id, read the context, and
112120
// submit. Resolves with their instructions (or null). Requires `await`.
@@ -130,6 +138,8 @@ const COMMANDS = [
130138
{
131139
name: "repeat",
132140
summary: "back to the top of the loop",
141+
usage: "repeat()",
142+
detail: "jumps back to the top of the loop",
133143
run(ctx, ...args) {
134144
expectNoArgs("repeat", args);
135145
ctx.out(" ↻ repeat() → back to the top");
@@ -138,13 +148,17 @@ const COMMANDS = [
138148
{
139149
name: "say",
140150
summary: "print a line",
151+
usage: "say(...parts)",
152+
detail: "prints one line",
141153
run(ctx, ...args) {
142154
ctx.out(` 💬 ${args.join(" ")}`);
143155
},
144156
},
145157
{
146158
name: "sleep",
147159
summary: "pause for N milliseconds (blocking)",
160+
usage: "sleep(ms)",
161+
detail: "blocks for ms milliseconds",
148162
// Synchronous/blocking so it pauses inline in the simple no-`await` style:
149163
// `while (alive) { work(); sleep(1000); }` actually waits each iteration.
150164
run(ctx, ...args) {
@@ -163,6 +177,8 @@ const COMMANDS = [
163177
{
164178
name: "stop",
165179
summary: "end the loop (alive = false)",
180+
usage: "stop()",
181+
detail: "ends the loop (alive = false)",
166182
run(ctx, ...args) {
167183
expectNoArgs("stop", args);
168184
ctx.stop();
@@ -173,6 +189,8 @@ const COMMANDS = [
173189
{
174190
name: "shell",
175191
summary: "run a shell command (blocking, cmd.exe on Windows or $SHELL -c elsewhere)",
192+
usage: "shell(cmd)",
193+
detail: "runs cmd in $SHELL; returns { ok, code, signal }",
176194
// The moshscript system verb for arbitrary shell commands. Blocking
177195
// (spawnSync + inherited stdio) so it runs inline in the no-`await` style,
178196
// and the child owns the terminal for interactive commands. Returns

‎src/completion.mjs‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from "./cli-schema.mjs";
77
import { ENGINES, ENGINE_ALIASES } from "./engines.mjs";
88
import { TOOLS } from "./tools.mjs";
9+
import { moshVocabulary } from "./commands.mjs";
910

1011
export const COMPLETION_SHELLS = ["bash", "zsh", "fish", "powershell"];
1112

@@ -54,6 +55,16 @@ export function completionModel() {
5455
skills: uniqueEntries(SKILL_VERBS),
5556
skillSources: uniqueEntries(SKILL_VERBS.filter(({ acceptsSource }) => acceptsSource)),
5657
shells: COMPLETION_SHELLS.map((name) => entry(name, `generate ${name} completion`)),
58+
// `moshcode help <topic>` accepts anything help can answer for: a command,
59+
// an engine, a tool, or a moshscript verb (PRD 0006 R16). Offering the same
60+
// set here is what keeps tab-completion and help one discoverability
61+
// surface rather than two that disagree.
62+
helpTopics: uniqueEntries([
63+
...CORE_CLI_COMMANDS.filter(({ name }) => !name.startsWith("-")),
64+
...engines,
65+
...tools,
66+
...moshVocabulary().all().map((c) => entry(c.name, c.summary || "moshscript verb")),
67+
]),
5768
};
5869
}
5970

@@ -110,6 +121,7 @@ ${powershellEntries("MoshcodeCompletionMcpServerSpecs", model.mcpServerSpecs)}
110121
${powershellEntries("MoshcodeCompletionSkills", model.skills)}
111122
${powershellEntries("MoshcodeCompletionSkillSources", model.skillSources)}
112123
${powershellEntries("MoshcodeCompletionShells", model.shells)}
124+
${powershellEntries("MoshcodeCompletionHelpTopics", model.helpTopics)}
113125
${powershellEntries("MoshcodeCompletionJson", optionEntries("--json", "print JSON"))}
114126
${powershellEntries("MoshcodeCompletionLogin", optionEntries("--browser -b --device -d", "authentication mode"))}
115127
${powershellEntries("MoshcodeCompletionRun", optionEntries("--dry-run --max -n", "run option"))}
@@ -154,6 +166,9 @@ Register-ArgumentCompleter -Native -CommandName moshcode -ScriptBlock {
154166
{ $_ -in @('upgrade', 'update') } {
155167
$choices = $script:MoshcodeCompletionUpgrade
156168
}
169+
'help' {
170+
if ($argumentIndex -eq 2) { $choices = $script:MoshcodeCompletionHelpTopics }
171+
}
157172
'completion' {
158173
if ($argumentIndex -eq 2) { $choices = $script:MoshcodeCompletionShells }
159174
}
@@ -244,6 +259,9 @@ _moshcode_completion() {
244259
completion)
245260
(( COMP_CWORD == 2 )) && choices="${names(model.shells)}"
246261
;;
262+
help)
263+
(( COMP_CWORD == 2 )) && choices="${names(model.helpTopics)}"
264+
;;
247265
mcp)
248266
if (( COMP_CWORD == 2 )); then
249267
choices="${names(model.mcp)}"
@@ -350,6 +368,12 @@ _moshcode() {
350368
_describe "shell" choices
351369
fi
352370
;;
371+
help)
372+
if (( CURRENT == 3 )); then
373+
choices=(${zshValues(model.helpTopics)})
374+
_describe "help topic" choices
375+
fi
376+
;;
353377
mcp)
354378
if (( CURRENT == 3 )); then
355379
choices=(${zshValues(model.mcp)})
@@ -458,6 +482,7 @@ ${fishEntries(atSecondToken("install"), model.install)}
458482
${fishEntries(atSecondToken("uninstall remove"), model.uninstall)}
459483
${fishEntries("__moshcode_command_is upgrade update", model.upgrade)}
460484
${fishEntries(atSecondToken("completion"), model.shells)}
485+
${fishEntries(atSecondToken("help"), model.helpTopics)}
461486
${fishEntries(atSecondToken("mcp"), model.mcp)}
462487
${fishEntries(atSecondToken("skill skills"), model.skills)}
463488
complete -c moshcode -n '__moshcode_nested_is mcp list' -l json -d 'print JSON'

‎src/help.mjs‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,29 @@ export function helpModel({ engines = [], tools = [], version = "" } = {}) {
308308
* to stderr and exit 1; `renderCommand` on stdout is the same text for the
309309
* person who asked politely.
310310
*/
311+
/* ------------------------------------------------------- moshscript verbs */
312+
313+
/**
314+
* A moshscript verb, rendered from the registry (PRD 0006 R14).
315+
*
316+
* `moshcode help ask` is a fair question — `ask()` is as much part of the
317+
* interface as `moshcode prd` — and it used to answer "no help for ask",
318+
* because the vocabulary lives in a registry that help had never been
319+
* introduced to.
320+
*
321+
* `usage` is optional on a command object, so a verb registered by a host that
322+
* has not declared one still renders: the signature falls back to `name(…)` and
323+
* the summary carries the meaning.
324+
*/
325+
export function renderScriptVerb(command) {
326+
if (!command) return null;
327+
const out = [`${command.usage || `${command.name}(…)`} — ${command.summary || "a moshscript verb"}`];
328+
if (command.detail) out.push("", wrap(command.detail, 0));
329+
out.push("", "a moshscript verb — call it from a .mosh file, not from the shell.");
330+
out.push("", "see also: moshcode help run · moshcode help commands");
331+
return out.join("\n");
332+
}
333+
311334
/* ---------------------------------------------------------------- markdown */
312335

313336
export const README_START = "<!-- COMMANDS:START -->";

‎test/help.test.mjs‎

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
// code, one shape, at every depth.
1212
import assert from "node:assert/strict";
1313
import test from "node:test";
14-
import { execFile } from "node:child_process";
14+
import { execFile, spawnSync } from "node:child_process";
1515
import { promisify } from "node:util";
1616
import fs from "node:fs";
1717
import path from "node:path";
@@ -24,10 +24,13 @@ import {
2424
} from "../src/cli-schema.mjs";
2525
import {
2626
WIDTH, findCommand, findPitCommand, helpModel, pitHelpModel, renderCommand, renderOverview,
27-
renderPitCommand, README_START, README_END, suggest, wantsHelp, withCommandTable, wrap,
27+
renderPitCommand, renderScriptVerb, README_START, README_END, suggest, wantsHelp,
28+
withCommandTable, wrap,
2829
} from "../src/help.mjs";
2930
import { ENGINES } from "../src/engines.mjs";
3031
import { TOOLS } from "../src/tools.mjs";
32+
import { moshVocabulary } from "../src/commands.mjs";
33+
import { completionModel } from "../src/completion.mjs";
3134

3235
const run = promisify(execFile);
3336
const BIN = fileURLToPath(new URL("../bin/moshcode.mjs", import.meta.url));
@@ -395,3 +398,100 @@ test("help --markdown emits the same table the README carries", async () => {
395398
const readme = fs.readFileSync(path.join(ROOT, "README.md"), "utf8");
396399
assert.ok(readme.includes(stdout.trim()), "the generator and the README disagree");
397400
});
401+
402+
/* ------------------------------------------------- R14: moshscript verbs */
403+
404+
const ESC = "[";
405+
406+
test("moshcode help <verb> answers for a moshscript verb", async () => {
407+
// `ask()` is as much part of the interface as `moshcode prd`, and used to
408+
// answer "no help for ask" because the vocabulary lives in a registry help
409+
// had never been introduced to.
410+
const { code, stdout, stderr } = await cli(["help", "ask"]);
411+
assert.equal(code, 0);
412+
assert.equal(stderr, "");
413+
assert.match(stdout, /^ask\(\.\.\.prompt\) —/, "the call signature leads");
414+
assert.match(stdout, /moshscript verb/);
415+
});
416+
417+
test("the signature comes from the registry, including its options", async () => {
418+
const { stdout } = await cli(["help", "ai"]);
419+
assert.match(stdout, /ai\(prompt, \{ engine \}\)/, "R14's worked example");
420+
});
421+
422+
test("every moshscript verb renders, with or without a declared usage", () => {
423+
for (const command of moshVocabulary().all()) {
424+
const text = renderScriptVerb(command);
425+
assert.ok(text, `${command.name} rendered nothing`);
426+
assert.match(text, /moshscript verb/, `${command.name} lost its footer`);
427+
}
428+
// A host may register a verb with no usage at all; it must still render.
429+
assert.match(renderScriptVerb({ name: "custom", summary: "" }), /^custom\(…\)/);
430+
assert.equal(renderScriptVerb(null), null);
431+
});
432+
433+
test("a CLI verb's usage is derived, so adding one documents it", () => {
434+
const gh = moshVocabulary().get("gh");
435+
assert.equal(gh.usage, "gh(...args)");
436+
assert.match(gh.detail, /moshcode gh/);
437+
});
438+
439+
test("a command and a verb of the same name resolve to the command", async () => {
440+
// `run` and `pwd` are both. The CLI command is what `moshcode help run`
441+
// means — the verb is reachable through `moshcode commands`.
442+
const { stdout } = await cli(["help", "run"]);
443+
assert.match(stdout, /^moshcode run —/);
444+
});
445+
446+
/* --------------------------------------------------------- R15: terminal */
447+
448+
test("help is plain text when the terminal is not one", async () => {
449+
// execFile gives the child a pipe, not a TTY — the non-TTY half of R15.
450+
for (const args of [["--help"], ["help", "prd"], ["help", "ask"], ["help", "--all"]]) {
451+
const { stdout } = await cli(args);
452+
assert.ok(!stdout.includes(ESC), `${args.join(" ")} emitted ANSI to a pipe`);
453+
}
454+
});
455+
456+
test("NO_COLOR is honoured", async () => {
457+
const env = { ...process.env, NO_COLOR: "1" };
458+
delete env.FORCE_COLOR;
459+
const { stdout } = await cli(["--help"], { env });
460+
assert.ok(!stdout.includes(ESC), "NO_COLOR still produced escapes");
461+
});
462+
463+
test("the pit's help is plain when piped", () => {
464+
// spawnSync, not the execFile helper: the pit reads stdin, and execFile has
465+
// no way to supply it — the child sits waiting for a prompt that never comes.
466+
const { stdout } = spawnSync(process.execPath, [BIN], {
467+
cwd: ROOT, input: "/help\n/quit\n", encoding: "utf8", timeout: 30_000,
468+
});
469+
assert.ok(!stdout.includes(ESC), "the pit coloured a pipe");
470+
assert.match(stdout, /not in the pit/, "and still rendered");
471+
});
472+
473+
/* ------------------------------------------------------ R16: completion */
474+
475+
test("completion offers help topics in every shell", async () => {
476+
for (const shell of ["bash", "zsh", "fish", "powershell"]) {
477+
const { code, stdout } = await cli(["completion", shell]);
478+
assert.equal(code, 0, `${shell} completion failed`);
479+
// The topics are the surface help itself accepts: a command, an engine, a
480+
// tool, and a moshscript verb.
481+
for (const topic of ["install", "claude", "gh", "ask"]) {
482+
assert.ok(stdout.includes(topic), `${shell} completion offers no "${topic}" help topic`);
483+
}
484+
}
485+
});
486+
487+
test("every completion help topic is one help can actually answer", () => {
488+
const model = completionModel();
489+
assert.ok(model.helpTopics.length >= 30, "the topic list looks empty");
490+
const engines = new Set(Object.keys(ENGINES));
491+
const tools = new Set(Object.keys(TOOLS));
492+
const verbs = new Set(moshVocabulary().names());
493+
for (const { name } of model.helpTopics) {
494+
const answerable = findCommand(name) || engines.has(name) || tools.has(name) || verbs.has(name);
495+
assert.ok(answerable, `completion offers "${name}" but help cannot answer it`);
496+
}
497+
});

0 commit comments

Comments
 (0)