moshcode 0.58.0 → 0.59.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 +179 -0
- package/bin/moshcode.mjs +2 -2
- package/examples/account.mosh +48 -0
- package/examples/aliases.mosh +56 -0
- package/examples/research-desk.mosh +49 -0
- package/package.json +1 -1
- package/src/auth.mjs +59 -64
- package/src/cli-schema.mjs +35 -0
- package/src/commands.mjs +305 -29
- package/src/cost-cli.mjs +232 -0
- package/src/cost-pricing.mjs +159 -0
- package/src/cost.mjs +634 -0
- package/src/games-breakout.mjs +64 -10
- package/src/games-paddle.mjs +128 -0
- package/src/games-pong.mjs +53 -4
- package/src/games.mjs +164 -12
- package/src/herd-cli.mjs +4 -0
- package/src/tui.mjs +1 -0
package/src/commands.mjs
CHANGED
|
@@ -16,11 +16,18 @@
|
|
|
16
16
|
import { spawn, spawnSync } from "node:child_process";
|
|
17
17
|
|
|
18
18
|
import { createRegistry } from "./registry.mjs";
|
|
19
|
-
import { cliVerb, aiVerb } from "./cli.mjs";
|
|
19
|
+
import { cliVerb, aiVerb, runMoshcode } from "./cli.mjs";
|
|
20
20
|
import { ingestApproval, pollApproval } from "./notify.mjs";
|
|
21
21
|
import { capture, killSession, sendPrompt } from "./herd.mjs";
|
|
22
22
|
import { herdStart, roster, waitFor } from "./herd-cli.mjs";
|
|
23
23
|
import { shellInvocation } from "./shell.mjs";
|
|
24
|
+
import { identity, loginAuto, logout as forgetCreds } from "./auth.mjs";
|
|
25
|
+
import { expandAlias, getAlias, loadAliases, removeAlias, setAlias } from "./aliases.mjs";
|
|
26
|
+
import { CORE_CLI_COMMAND_NAMES, PIT_COMMANDS } from "./cli-schema.mjs";
|
|
27
|
+
import { fetchAdvisor, stocksArgs } from "./advisor.mjs";
|
|
28
|
+
import { cryptoArgs, fetchCrypto } from "./crypto.mjs";
|
|
29
|
+
import { collectNews, loadListFeeds, readingList } from "./news.mjs";
|
|
30
|
+
import { resolveList } from "./news-sources.mjs";
|
|
24
31
|
|
|
25
32
|
// The moshcoding pit-anthem playlist. mosh() blasts this URL and, on a desktop
|
|
26
33
|
// with a GUI, tries to open it in the default browser.
|
|
@@ -64,6 +71,61 @@ function expectNoArgs(name, args) {
|
|
|
64
71
|
}
|
|
65
72
|
}
|
|
66
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Whether a name already belongs to moshcode, for alias().
|
|
76
|
+
*
|
|
77
|
+
* The pit asks the dispatcher this by resolving; a script has no dispatcher, so
|
|
78
|
+
* it asks the schema instead — the CLI commands and the pit's own verbs, which
|
|
79
|
+
* is what an alias could collide with. Kept as a predicate (the shape
|
|
80
|
+
* setAlias() takes) rather than an exported list, so this stays the caller's
|
|
81
|
+
* answer and not a second roster to drift from the first.
|
|
82
|
+
*/
|
|
83
|
+
function isReserved(name) {
|
|
84
|
+
const key = String(name).toLowerCase();
|
|
85
|
+
return CORE_CLI_COMMAND_NAMES.includes(key)
|
|
86
|
+
|| PIT_COMMANDS.some((c) => (typeof c === "string" ? c : c.name) === key);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The shell verb, named so runAlias() can execute an expanded alias line
|
|
90
|
+
// through exactly the same path a script's own shell() call takes — one
|
|
91
|
+
// invocation, one dry-run story, one { ok, code } contract. It is registered in
|
|
92
|
+
// COMMANDS below like every other verb.
|
|
93
|
+
const SHELL = {
|
|
94
|
+
name: "shell",
|
|
95
|
+
summary: "run a shell command (blocking, cmd.exe on Windows or $SHELL elsewhere)",
|
|
96
|
+
usage: "shell(cmd)",
|
|
97
|
+
detail: "runs cmd in $SHELL, loading your rc file where it can; returns { ok, code, signal }",
|
|
98
|
+
// The moshscript system verb for arbitrary shell commands. Blocking
|
|
99
|
+
// (spawnSync + inherited stdio) so it runs inline in the no-`await` style,
|
|
100
|
+
// and the child owns the terminal for interactive commands. Returns
|
|
101
|
+
// { ok, code } so scripts can branch on the exit status:
|
|
102
|
+
// const r = shell("npm test"); if (!r.ok) say("tests failed");
|
|
103
|
+
run(ctx, ...args) {
|
|
104
|
+
const cmd = args.join(" ");
|
|
105
|
+
if (!cmd) throw new Error("moshscript: shell() requires a command string");
|
|
106
|
+
if (ctx.dryRun) {
|
|
107
|
+
ctx.out(` ▶ shell(${JSON.stringify(cmd)}) → would run: $SHELL ${shellInvocation(cmd).flags} ${JSON.stringify(cmd)}`);
|
|
108
|
+
// Same R8 contract as the comment above: `code` is always present, so a
|
|
109
|
+
// script branching on the exit status behaves the same under --dry-run.
|
|
110
|
+
return { ok: true, code: 0, dryRun: true };
|
|
111
|
+
}
|
|
112
|
+
// Same invocation the pit's own `!cmd` uses, so a command that works when
|
|
113
|
+
// typed works when scripted: interactive where a terminal is attached, so
|
|
114
|
+
// the user's rc file — and the aliases in it — are loaded. src/shell.mjs
|
|
115
|
+
// has the reasoning, including why a headless run stays non-interactive.
|
|
116
|
+
const { shell: sh, args: shArgs } = shellInvocation(cmd);
|
|
117
|
+
ctx.out(` ▶ shell: ${cmd}`);
|
|
118
|
+
const res = spawnSync(sh, shArgs, { stdio: "inherit" });
|
|
119
|
+
if (res.error) throw res.error;
|
|
120
|
+
const code = res.status ?? 1;
|
|
121
|
+
if (code !== 0) {
|
|
122
|
+
ctx.out(` ✗ shell() exited ${res.signal || code}`);
|
|
123
|
+
return { ok: false, code, signal: res.signal || null };
|
|
124
|
+
}
|
|
125
|
+
return { ok: true, code: 0 };
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
67
129
|
// The vocabulary, in registration order. mosh() is the worked example of the
|
|
68
130
|
// command shape; the rest follow the same pattern.
|
|
69
131
|
const COMMANDS = [
|
|
@@ -189,39 +251,227 @@ const COMMANDS = [
|
|
|
189
251
|
},
|
|
190
252
|
},
|
|
191
253
|
|
|
254
|
+
SHELL,
|
|
255
|
+
|
|
256
|
+
// The account. Local rather than cliVerbs for the same reason the herd is:
|
|
257
|
+
// `moshcode whoami` prints, and a script needs to *branch* on the answer —
|
|
258
|
+
// "am I logged in?", "whose account is this?", "are there credits left?".
|
|
259
|
+
// Shelling out returns { ok, code }, so the only way to read the account
|
|
260
|
+
// would be to re-parse stdout. src/auth.mjs owns the flows either way; these
|
|
261
|
+
// are a second caller of the same identity(), not a second implementation.
|
|
192
262
|
{
|
|
193
|
-
name: "
|
|
194
|
-
summary: "
|
|
195
|
-
usage: "
|
|
196
|
-
detail: "
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
263
|
+
name: "whoami",
|
|
264
|
+
summary: "the logged-in account, as a value (verified against app.moshcode.sh)",
|
|
265
|
+
usage: "whoami()",
|
|
266
|
+
detail: "returns { status, verified, api, user: { id, email, name, credits } }. needs await",
|
|
267
|
+
// Never throws — an unreachable app is a `status`, not an exception,
|
|
268
|
+
// because the caller is usually deciding whether to start work.
|
|
269
|
+
async run(ctx) {
|
|
270
|
+
if (ctx.dryRun) {
|
|
271
|
+
ctx.out(" 👤 whoami() → would check app.moshcode.sh");
|
|
272
|
+
return { status: "dry_run", verified: false, api: null, user: null, dryRun: true };
|
|
273
|
+
}
|
|
274
|
+
const me = await identity();
|
|
275
|
+
const who = me.user?.email || me.user?.name;
|
|
276
|
+
ctx.out(me.verified
|
|
277
|
+
? ` 👤 whoami() → ${who || "moshcoder"} (${me.user.credits ?? "?"} credits)`
|
|
278
|
+
: ` 👤 whoami() → ${me.status}${who ? ` (${who}, unverified)` : ""}`);
|
|
279
|
+
return me;
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
name: "login",
|
|
284
|
+
summary: "authenticate this machine against app.moshcode.sh",
|
|
285
|
+
usage: "login({ device, browser, force })",
|
|
286
|
+
detail: "no-op when already authenticated unless force; returns { ok, email, already }. needs await",
|
|
287
|
+
// Idempotent by default. A script that opens with login() should be safe to
|
|
288
|
+
// re-run all day without throwing a browser tab at an operator who is
|
|
289
|
+
// already signed in — so the verified case returns early. `force` re-runs
|
|
290
|
+
// the flow anyway (switching accounts), and device/browser pin the flow
|
|
291
|
+
// rather than letting loginAuto sniff for SSH.
|
|
292
|
+
async run(ctx, opts = {}) {
|
|
293
|
+
if (ctx.dryRun) {
|
|
294
|
+
ctx.out(" 🔑 login() → would authenticate against app.moshcode.sh");
|
|
295
|
+
return { ok: true, email: null, already: false, dryRun: true };
|
|
296
|
+
}
|
|
297
|
+
if (!opts.force) {
|
|
298
|
+
const me = await identity();
|
|
299
|
+
if (me.verified) {
|
|
300
|
+
ctx.out(` 🔑 login() → already signed in as ${me.user.email || me.user.name || "moshcoder"}`);
|
|
301
|
+
return { ok: true, email: me.user.email ?? null, already: true };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
const r = await loginAuto({ device: Boolean(opts.device), browser: Boolean(opts.browser) });
|
|
306
|
+
ctx.out(` 🔑 login() → signed in as ${r.email || "moshcoder"} 🤘`);
|
|
307
|
+
return { ok: true, email: r.email ?? null, already: false };
|
|
308
|
+
} catch (e) {
|
|
309
|
+
// R8: hand back the failure instead of throwing, so a script can fall
|
|
310
|
+
// back (skip the notify, run read-only) rather than die at line 1.
|
|
311
|
+
ctx.out(` ✗ login() failed — ${e.message}`);
|
|
312
|
+
return { ok: false, email: null, already: false, error: e.message };
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
name: "requireLogin",
|
|
318
|
+
summary: "BLOCK until this machine is authenticated — the gate for scripts that need an account",
|
|
319
|
+
usage: "requireLogin({ device, browser })",
|
|
320
|
+
detail: "returns the verified { id, email, name, credits }; THROWS if it can't authenticate. needs await",
|
|
321
|
+
// The one verb here that throws, and on purpose: "require" means the script
|
|
322
|
+
// must not continue unauthenticated. Everything downstream (notify, ask,
|
|
323
|
+
// credits) would fail one call at a time and much less legibly, so a script
|
|
324
|
+
// that needs an account says so once, at the top.
|
|
325
|
+
async run(ctx, opts = {}) {
|
|
326
|
+
if (ctx.dryRun) {
|
|
327
|
+
ctx.out(" 🔒 requireLogin() → would require an authenticated account");
|
|
328
|
+
return { id: null, email: null, name: null, credits: null, dryRun: true };
|
|
329
|
+
}
|
|
330
|
+
let me = await identity();
|
|
331
|
+
if (!me.verified) {
|
|
332
|
+
ctx.out(` 🔒 requireLogin() → ${me.status} — starting the login flow…`);
|
|
333
|
+
try { await loginAuto({ device: Boolean(opts.device), browser: Boolean(opts.browser) }); }
|
|
334
|
+
catch (e) { throw new Error(`moshscript: requireLogin() could not authenticate — ${e.message}`); }
|
|
335
|
+
me = await identity();
|
|
336
|
+
}
|
|
337
|
+
if (!me.verified) {
|
|
338
|
+
throw new Error(`moshscript: requireLogin() could not authenticate (${me.status}) — run \`moshcode login\``);
|
|
339
|
+
}
|
|
340
|
+
ctx.out(` 🔒 requireLogin() → ${me.user.email || me.user.name || "moshcoder"} 🤘`);
|
|
341
|
+
return me.user;
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
name: "logout",
|
|
346
|
+
summary: "forget this machine's credentials",
|
|
347
|
+
usage: "logout()",
|
|
348
|
+
detail: "returns { ok }",
|
|
202
349
|
run(ctx, ...args) {
|
|
203
|
-
|
|
204
|
-
if (
|
|
350
|
+
expectNoArgs("logout", args);
|
|
351
|
+
if (ctx.dryRun) { ctx.out(" 🚪 logout() → would forget the local credentials"); return { ok: true, dryRun: true }; }
|
|
352
|
+
forgetCreds();
|
|
353
|
+
return { ok: true };
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
// Aliases. The pit already keeps named shortcuts (src/aliases.mjs) and they
|
|
358
|
+
// are the operator's own vocabulary — the things *they* retype. A script that
|
|
359
|
+
// cannot reach them has to re-spell every one of those lines, so the same
|
|
360
|
+
// store is readable and writable here, and runAlias() executes one.
|
|
361
|
+
{
|
|
362
|
+
name: "alias",
|
|
363
|
+
summary: "read, list, or define pit aliases",
|
|
364
|
+
usage: 'alias() | alias(name) | alias(name, line)',
|
|
365
|
+
detail: "no args → the whole map; one arg → that line or null; two → defines it, returns { ok, name, value, previous }",
|
|
366
|
+
run(ctx, name, value) {
|
|
367
|
+
if (name === undefined) return loadAliases();
|
|
368
|
+
if (value === undefined) return getAlias(String(name));
|
|
205
369
|
if (ctx.dryRun) {
|
|
206
|
-
ctx.out(`
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
370
|
+
ctx.out(` 🔖 alias(${name}) → would define: ${value}`);
|
|
371
|
+
return { ok: true, name: String(name), value: String(value), previous: null, dryRun: true };
|
|
372
|
+
}
|
|
373
|
+
// Same reservation rule the pit enforces: a shortcut that collides with a
|
|
374
|
+
// built-in would be silently dead, so it is refused rather than shadowed.
|
|
375
|
+
const r = setAlias(name, value, { isReserved });
|
|
376
|
+
ctx.out(r.ok ? ` 🔖 alias(${r.name}) → ${r.value}` : ` ✗ alias() — ${r.error}`);
|
|
377
|
+
return r;
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
name: "unalias",
|
|
382
|
+
summary: "forget a pit alias",
|
|
383
|
+
usage: "unalias(name)",
|
|
384
|
+
detail: "returns { ok, name, value } — { ok: false } when there was no such alias",
|
|
385
|
+
run(ctx, name) {
|
|
386
|
+
if (!name) throw new Error("moshscript: unalias(name) requires an alias name");
|
|
387
|
+
if (ctx.dryRun) { ctx.out(` 🔖 unalias(${name}) → would forget it`); return { ok: true, name: String(name), dryRun: true }; }
|
|
388
|
+
const r = removeAlias(name);
|
|
389
|
+
ctx.out(r.ok ? ` 🔖 unalias(${r.name})` : ` ✗ unalias() — ${r.error}`);
|
|
390
|
+
return r;
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
name: "runAlias",
|
|
395
|
+
summary: "run a pit alias by name, with extra arguments appended",
|
|
396
|
+
usage: "runAlias(name, ...args)",
|
|
397
|
+
detail: "returns { ok, code } like shell()/CLI verbs; { ok: false, code: 127 } when undefined",
|
|
398
|
+
// The expansion rule is the pit's (src/aliases.mjs): a leading `/` is a pit
|
|
399
|
+
// command, anything else is a shell line, and typed arguments are appended
|
|
400
|
+
// rather than substituted. A pit command routes to its CLI twin here —
|
|
401
|
+
// moshscript is not the pit, but `/agents claude` and `moshcode agents
|
|
402
|
+
// claude` are the same capability, which is the whole cliVerb premise.
|
|
403
|
+
run(ctx, name, ...args) {
|
|
404
|
+
if (!name) throw new Error("moshscript: runAlias(name) requires an alias name");
|
|
405
|
+
const value = getAlias(String(name));
|
|
406
|
+
if (value == null) {
|
|
407
|
+
ctx.out(` ✗ runAlias(${name}) — no alias named "${name}"`);
|
|
408
|
+
return { ok: false, code: 127 };
|
|
210
409
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
ctx.
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
410
|
+
const line = expandAlias(value, args.map(String).join(" "));
|
|
411
|
+
if (line.startsWith("/")) {
|
|
412
|
+
const [verb, ...rest] = line.slice(1).split(/\s+/).filter(Boolean);
|
|
413
|
+
return runMoshcode(verb, rest, ctx);
|
|
414
|
+
}
|
|
415
|
+
return SHELL.run(ctx, line.replace(/^!/, ""));
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
|
|
419
|
+
// Reading the tools, not just running them. `stocks report NVDA` prints a
|
|
420
|
+
// table; a script wants the score. These call the same advis0r/feed layer the
|
|
421
|
+
// CLI renders from, so a verb here can never drift from its printed twin.
|
|
422
|
+
{
|
|
423
|
+
name: "stocksRead",
|
|
424
|
+
summary: "run a stocks query and RETURN its JSON (advis0r)",
|
|
425
|
+
usage: 'stocksRead("report", "NVDA")',
|
|
426
|
+
detail: "same arguments as stocks(); returns the parsed data, or null on error. needs await",
|
|
427
|
+
async run(ctx, ...args) {
|
|
428
|
+
const request = stocksArgs(args.map(String));
|
|
429
|
+
if (request.error) throw new Error(`moshscript: stocksRead() — ${request.error}`);
|
|
430
|
+
if (ctx.dryRun) { ctx.out(` 📈 stocksRead(${args.join(" ")}) → would query advis0r`); return null; }
|
|
431
|
+
ctx.out(` 📈 stocksRead(${args.join(" ")})`);
|
|
432
|
+
const res = await fetchAdvisor(request);
|
|
433
|
+
if (!res.ok) { ctx.out(` ! ${res.error || `advis0r returned ${res.status}`}`); return null; }
|
|
434
|
+
return res.data;
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
name: "cryptoRead",
|
|
439
|
+
summary: "run a crypto query and RETURN its JSON (advis0r)",
|
|
440
|
+
usage: 'cryptoRead("report", "BTC/USD")',
|
|
441
|
+
detail: "same arguments as crypto(); returns the parsed data, or null on error. needs await",
|
|
442
|
+
async run(ctx, ...args) {
|
|
443
|
+
const request = cryptoArgs(args.map(String));
|
|
444
|
+
if (request.error) throw new Error(`moshscript: cryptoRead() — ${request.error}`);
|
|
445
|
+
if (ctx.dryRun) { ctx.out(` 🪙 cryptoRead(${args.join(" ")}) → would query advis0r`); return null; }
|
|
446
|
+
ctx.out(` 🪙 cryptoRead(${args.join(" ")})`);
|
|
447
|
+
const res = await fetchCrypto(request);
|
|
448
|
+
if (!res.ok) { ctx.out(` ! ${res.error || `advis0r returned ${res.status}`}`); return null; }
|
|
449
|
+
return res.data;
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
name: "newsRead",
|
|
454
|
+
summary: "fetch the news feeds and RETURN the headlines",
|
|
455
|
+
usage: 'newsRead({ list, limit })',
|
|
456
|
+
detail: "returns [{ title, link, source, date }, …] — your subscriptions, or a named list. needs await",
|
|
457
|
+
// `list` names one of the built-in feed lists; omit it for the operator's
|
|
458
|
+
// own subscriptions (the same reading list `/news` shows).
|
|
459
|
+
async run(ctx, opts = {}) {
|
|
460
|
+
const limit = Number(opts.limit) || 20;
|
|
461
|
+
if (ctx.dryRun) { ctx.out(` 📰 newsRead() → would fetch ${opts.list || "your"} feeds`); return []; }
|
|
462
|
+
let feeds;
|
|
463
|
+
if (opts.list) {
|
|
464
|
+
const list = resolveList(String(opts.list));
|
|
465
|
+
if (!list) throw new Error(`moshscript: newsRead() — no feed list named "${opts.list}"`);
|
|
466
|
+
const loaded = await loadListFeeds(list);
|
|
467
|
+
if (!loaded.ok) { ctx.out(` ! couldn't load ${opts.list}`); return []; }
|
|
468
|
+
feeds = loaded.feeds;
|
|
469
|
+
} else {
|
|
470
|
+
feeds = readingList().feeds;
|
|
223
471
|
}
|
|
224
|
-
|
|
472
|
+
ctx.out(` 📰 newsRead() → reading ${feeds.length} feed(s)…`);
|
|
473
|
+
const { items } = await collectNews(feeds);
|
|
474
|
+
return items.slice(0, limit).map(({ title, link, source, date }) => ({ title, link, source, date }));
|
|
225
475
|
},
|
|
226
476
|
},
|
|
227
477
|
|
|
@@ -334,6 +584,7 @@ const COMMANDS = [
|
|
|
334
584
|
cliVerb("agents", "launch an autonomous agent session (moshcode agents <engine>)"),
|
|
335
585
|
cliVerb("herd", "drive the herd (moshcode herd <verb>) — see herdStart/herdWait for values"),
|
|
336
586
|
cliVerb("ps", "print the herd roster"),
|
|
587
|
+
cliVerb("cost", "print what the herd is spending (moshcode cost [name] [--all])"),
|
|
337
588
|
cliVerb("start", "raw-launch an engine (moshcode start <engine>)"),
|
|
338
589
|
cliVerb("install", "install an engine or workflow tool"),
|
|
339
590
|
cliVerb("upgrade", "upgrade moshcode, engines, and tools"),
|
|
@@ -357,6 +608,31 @@ const COMMANDS = [
|
|
|
357
608
|
cliVerb("mcpjam", "drive the MCPJam CLI (test, debug, and validate MCP servers)"),
|
|
358
609
|
cliVerb("trade", "look up tickers, inspect markets, and preview/place Alpaca orders"),
|
|
359
610
|
cliVerb("pwd", "print the current repo/location"),
|
|
611
|
+
|
|
612
|
+
// Research and feeds. The *Read() verbs above return the data; these are the
|
|
613
|
+
// rendered CLI, for when a script wants the table on the operator's screen.
|
|
614
|
+
cliVerb("stocks", "research tickers via advis0r (report, discover, signals, research)"),
|
|
615
|
+
cliVerb("crypto", "research crypto pairs via advis0r (quote, report, bars, book)"),
|
|
616
|
+
cliVerb("advisor", "query advis0r directly"),
|
|
617
|
+
cliVerb("news", "read, search, and subscribe to news feeds"),
|
|
618
|
+
cliVerb("rss", "manage RSS subscriptions and reading lists"),
|
|
619
|
+
|
|
620
|
+
// Extending moshcode from a script — the same fan-out `mcp`/`skill` do.
|
|
621
|
+
cliVerb("plugin", "install/manage moshcode plugins from the marketplace"),
|
|
622
|
+
cliVerb("engines", "list coding engines and whether they're installed"),
|
|
623
|
+
cliVerb("tools", "list the adjacent workflow CLIs and whether they're installed"),
|
|
624
|
+
|
|
625
|
+
// Hosting: the Moshpit side of the CLI, so a deploy script can claim a name,
|
|
626
|
+
// serve a site, and bring the resolver up without dropping to $SHELL.
|
|
627
|
+
cliVerb("dns", "drive the Moshpit DNS bridge (enable, status, resolve)"),
|
|
628
|
+
cliVerb("doh", "run/inspect the DNS-over-HTTPS endpoint"),
|
|
629
|
+
cliVerb("site", "scaffold and publish a site"),
|
|
630
|
+
cliVerb("serve", "serve a directory over HTTP"),
|
|
631
|
+
cliVerb("template", "scaffold from a moshcode template"),
|
|
632
|
+
|
|
633
|
+
// Settings sync (PRD 0010) — needs an account, so pair with requireLogin().
|
|
634
|
+
cliVerb("save", "push local settings to your moshcode account"),
|
|
635
|
+
cliVerb("load", "pull settings from your moshcode account"),
|
|
360
636
|
];
|
|
361
637
|
|
|
362
638
|
/** A fresh registry preloaded with the built-in vocabulary. */
|
package/src/cost-cli.mjs
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// `moshcode cost` — what the herd is spending, right now.
|
|
2
|
+
//
|
|
3
|
+
// The roster answers "which agent is blocked". This answers the other question
|
|
4
|
+
// you have at 2am with six agents running: "what is this costing me". Same
|
|
5
|
+
// shape as `moshcode ps` on purpose — one row per session, one line of totals —
|
|
6
|
+
// because it is the same list of sessions seen through a different column.
|
|
7
|
+
//
|
|
8
|
+
// Everything it prints comes from the engines' own session logs (src/cost.mjs).
|
|
9
|
+
// Nothing is sampled, nothing is proxied, and a number the engine itself
|
|
10
|
+
// computed is never overwritten by our arithmetic.
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_WINDOW_MS, UNCOSTED_ENGINES, attributeRuns, engineRuns,
|
|
13
|
+
formatTokens, formatUsd, totals,
|
|
14
|
+
} from "./cost.mjs";
|
|
15
|
+
import { pricingFile } from "./cost-pricing.mjs";
|
|
16
|
+
import { EXIT, humanAge, roster } from "./herd-cli.mjs";
|
|
17
|
+
import { acid, ash, bone, dim, err, info, table, warn } from "./ui.mjs";
|
|
18
|
+
|
|
19
|
+
const tilde = (p) => {
|
|
20
|
+
const home = process.env.HOME || "";
|
|
21
|
+
return home && p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** "30m", "6h", "3d", "90s" — the same vocabulary `moshcode wait --timeout` takes. */
|
|
25
|
+
export function parseWindow(raw, fallback = DEFAULT_WINDOW_MS) {
|
|
26
|
+
const m = /^(\d+(?:\.\d+)?)\s*([smhd])?$/.exec(String(raw ?? "").trim());
|
|
27
|
+
if (!m) return fallback;
|
|
28
|
+
const n = Number(m[1]);
|
|
29
|
+
if (!Number.isFinite(n) || n <= 0) return fallback;
|
|
30
|
+
return n * { s: 1e3, m: 60e3, h: 3600e3, d: 86400e3 }[m[2] || "h"];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function flagValue(argv, name) {
|
|
34
|
+
const i = argv.indexOf(name);
|
|
35
|
+
if (i === -1) return null;
|
|
36
|
+
const next = argv[i + 1];
|
|
37
|
+
return next && !next.startsWith("-") ? next : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* How confident the dollar figure is, in one character.
|
|
42
|
+
*
|
|
43
|
+
* `~` means we multiplied tokens by a published rate card; no marker means the
|
|
44
|
+
* engine handed us the price. On a subscription the estimate is what the same
|
|
45
|
+
* work would cost on the API — worth watching, not worth invoicing.
|
|
46
|
+
*/
|
|
47
|
+
function costCell(cost, source) {
|
|
48
|
+
if (cost == null) return ash("—");
|
|
49
|
+
const text = formatUsd(cost);
|
|
50
|
+
if (source === "engine") return bone(text);
|
|
51
|
+
return `${bone(text)}${dim("~")}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Cache tokens get their own column rather than folding into `in`.
|
|
56
|
+
*
|
|
57
|
+
* On a long agent session they are most of the traffic and a tenth of the price
|
|
58
|
+
* — a single "in" number that mixes them makes a $3 session look like a $60
|
|
59
|
+
* one, and hides the thing you would actually act on.
|
|
60
|
+
*/
|
|
61
|
+
const cacheTokens = (u) => u.cacheRead + u.cacheWrite5m + u.cacheWrite1h;
|
|
62
|
+
|
|
63
|
+
/** The per-session table, shared by the one-shot report and `--watch`. */
|
|
64
|
+
export function renderCost(rows, { indent = " " } = {}) {
|
|
65
|
+
if (!rows.length) return "";
|
|
66
|
+
return table(
|
|
67
|
+
rows.map((r) => [
|
|
68
|
+
bone(r.name),
|
|
69
|
+
ash(String(r.engine)),
|
|
70
|
+
ash(r.models?.length ? r.models.join(",") : "—"),
|
|
71
|
+
dim(formatTokens(r.usage.input)),
|
|
72
|
+
dim(formatTokens(r.usage.output)),
|
|
73
|
+
dim(formatTokens(cacheTokens(r.usage))),
|
|
74
|
+
costCell(r.cost, r.costSource),
|
|
75
|
+
dim(humanAge(r.age)),
|
|
76
|
+
]),
|
|
77
|
+
{ columns: ["session", "engine", "model", "in", "out", "cache", "cost", "age"], header: true, indent: indent.length },
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The `--all` table: engine sessions as the engines recorded them. */
|
|
82
|
+
function renderRuns(runs, { indent = " " } = {}) {
|
|
83
|
+
if (!runs.length) return "";
|
|
84
|
+
return table(
|
|
85
|
+
runs.map((r) => [
|
|
86
|
+
bone(String(r.id).slice(0, 8)),
|
|
87
|
+
ash(r.engine),
|
|
88
|
+
ash(r.models?.length ? r.models.join(",") : "—"),
|
|
89
|
+
ash(tilde(r.cwd || "")),
|
|
90
|
+
dim(formatTokens(r.usage.input)),
|
|
91
|
+
dim(formatTokens(r.usage.output)),
|
|
92
|
+
dim(formatTokens(cacheTokens(r.usage))),
|
|
93
|
+
costCell(r.cost, r.costSource),
|
|
94
|
+
]),
|
|
95
|
+
{ columns: ["run", "engine", "model", "cwd", "in", "out", "cache", "cost"], header: true, indent: indent.length },
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Gather everything once: the roster, the engine runs in the window, and the
|
|
101
|
+
* attribution between them. Returned whole so `--json`, the table, and the
|
|
102
|
+
* herd bar all read the same numbers.
|
|
103
|
+
*/
|
|
104
|
+
export async function costReport({ since, cwd = null, engines = null } = {}) {
|
|
105
|
+
const sessions = roster();
|
|
106
|
+
const runs = await engineRuns({ since, cwd, engines });
|
|
107
|
+
const { rows, unattributed } = attributeRuns(sessions, runs);
|
|
108
|
+
return { sessions, runs, rows, unattributed, since };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The total is the total of the table above it, and anything the table left out
|
|
113
|
+
* is its own line. A single grand total over rows that are not all shown reads
|
|
114
|
+
* as "your herd cost $850" when the herd cost nothing and another terminal did.
|
|
115
|
+
*/
|
|
116
|
+
function footer(report, write, { attribution = true } = {}) {
|
|
117
|
+
const shown = totals(report.rows);
|
|
118
|
+
const loose = totals(report.unattributed);
|
|
119
|
+
const all = totals([...report.rows, ...report.unattributed]);
|
|
120
|
+
|
|
121
|
+
write("");
|
|
122
|
+
write(` ${bone("total")} ${costCell(shown.cost, "rates")} ${dim(`${formatTokens(shown.usage.input)} in · ${formatTokens(shown.usage.output)} out · ${formatTokens(cacheTokens(shown.usage))} cached`)}`);
|
|
123
|
+
if (attribution && report.unattributed.length) {
|
|
124
|
+
write(info(`plus ${formatUsd(loose.cost)} in ${report.unattributed.length} engine session(s) outside the herd — ${acid("moshcode cost --all")} shows them.`));
|
|
125
|
+
}
|
|
126
|
+
if (shown.cost != null || loose.cost != null) {
|
|
127
|
+
write(dim(` ~ estimated from published rates; unmarked figures are the engine's own.`));
|
|
128
|
+
}
|
|
129
|
+
if (all.unpriced.length) {
|
|
130
|
+
write(warn(`no rate for ${all.unpriced.join(", ")} — tokens counted, cost omitted.`));
|
|
131
|
+
write(info(`price them in ${tilde(pricingFile())}: { "${all.unpriced[0]}": { "input": 1.25, "output": 10 } }`));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `moshcode cost [name] [--all] [--since 6h] [--engine <name>] [--json] [--watch [secs]]`
|
|
137
|
+
*/
|
|
138
|
+
export async function costCommand(argv = [], { write = console.log } = {}) {
|
|
139
|
+
const asJson = argv.includes("--json");
|
|
140
|
+
const all = argv.includes("--all");
|
|
141
|
+
const since = Date.now() - parseWindow(flagValue(argv, "--since"));
|
|
142
|
+
const engineFlag = flagValue(argv, "--engine");
|
|
143
|
+
const engines = engineFlag ? engineFlag.split(",").map((s) => s.trim()).filter(Boolean) : null;
|
|
144
|
+
const watch = argv.includes("--watch");
|
|
145
|
+
const flagWords = new Set([flagValue(argv, "--since"), flagValue(argv, "--engine"), flagValue(argv, "--watch")]);
|
|
146
|
+
const name = argv.find((a) => !a.startsWith("-") && !flagWords.has(a)) || null;
|
|
147
|
+
|
|
148
|
+
if (watch && asJson) {
|
|
149
|
+
write(err("--watch and --json do not go together — pipe repeated `moshcode cost --json` instead."));
|
|
150
|
+
return EXIT.usage;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const once = async () => {
|
|
154
|
+
const report = await costReport({ since, engines });
|
|
155
|
+
let rows = report.rows;
|
|
156
|
+
if (name) {
|
|
157
|
+
rows = rows.filter((r) => r.name === name);
|
|
158
|
+
if (!rows.length) {
|
|
159
|
+
write(err(`no session named ${JSON.stringify(name)} — ${acid("moshcode ps")}`));
|
|
160
|
+
return EXIT.gone;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (asJson) {
|
|
165
|
+
write(JSON.stringify({
|
|
166
|
+
since,
|
|
167
|
+
sessions: rows.map(({ name: n, engine, cwd, state, models, usage, cost, costSource, unpriced, runs }) => ({
|
|
168
|
+
name: n, engine, cwd, state, models, usage, cost, costSource, unpriced,
|
|
169
|
+
runs: runs.map((r) => ({ id: r.id, model: r.model, usage: r.usage, cost: r.cost, costSource: r.costSource, start: r.start, end: r.end })),
|
|
170
|
+
})),
|
|
171
|
+
unattributed: report.unattributed.map((r) => ({
|
|
172
|
+
id: r.id, engine: r.engine, cwd: r.cwd, model: r.model, usage: r.usage, cost: r.cost, costSource: r.costSource, start: r.start, end: r.end,
|
|
173
|
+
})),
|
|
174
|
+
totals: totals([...rows, ...report.unattributed]),
|
|
175
|
+
}, null, 2));
|
|
176
|
+
return EXIT.matched;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (all) {
|
|
180
|
+
const runs = name ? rows.flatMap((r) => r.runs) : report.runs;
|
|
181
|
+
if (!runs.length) {
|
|
182
|
+
write(info("no engine sessions on disk in this window — widen it with `--since 7d`."));
|
|
183
|
+
return EXIT.matched;
|
|
184
|
+
}
|
|
185
|
+
write(renderRuns(runs));
|
|
186
|
+
// The runs ARE the rows here, so they are what the total totals. And the
|
|
187
|
+
// "not tied to a herd session" note would be describing the whole table
|
|
188
|
+
// back at itself, so it stays off.
|
|
189
|
+
footer({ ...report, rows: runs, unattributed: [] }, write, { attribution: false });
|
|
190
|
+
return EXIT.matched;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!rows.length) {
|
|
194
|
+
write(info("the herd is empty — `moshcode herd start claude` puts something in it."));
|
|
195
|
+
write(info(`already ran an agent outside the herd? ${acid("moshcode cost --all")}`));
|
|
196
|
+
return EXIT.matched;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
write(renderCost(rows));
|
|
200
|
+
footer({ ...report, rows }, write);
|
|
201
|
+
if (UNCOSTED_ENGINES.some((e) => rows.some((r) => r.engine === e))) {
|
|
202
|
+
write(info(`${UNCOSTED_ENGINES.join(", ")} keep no usage log moshcode can read — those rows show no cost, not zero cost.`));
|
|
203
|
+
}
|
|
204
|
+
return EXIT.matched;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
if (!watch) return once();
|
|
208
|
+
|
|
209
|
+
// --watch is the "ongoing" part: the same report, re-read on an interval,
|
|
210
|
+
// because the interesting thing about a running agent's cost is the slope.
|
|
211
|
+
const every = Math.max(2, Number(flagValue(argv, "--watch") || 10)) * 1000;
|
|
212
|
+
let stop = false;
|
|
213
|
+
const onSigint = () => { stop = true; };
|
|
214
|
+
process.on("SIGINT", onSigint);
|
|
215
|
+
try {
|
|
216
|
+
while (!stop) {
|
|
217
|
+
if (process.stdout.isTTY) process.stdout.write("\x1b[2J\x1b[H");
|
|
218
|
+
await once();
|
|
219
|
+
write(dim(` refreshing every ${Math.round(every / 1000)}s · ctrl-c to stop`));
|
|
220
|
+
await new Promise((resolve) => {
|
|
221
|
+
const timer = setTimeout(resolve, every);
|
|
222
|
+
// A timer must not hold the process open past a ctrl-c.
|
|
223
|
+
timer.unref?.();
|
|
224
|
+
const poll = setInterval(() => { if (stop) { clearTimeout(timer); clearInterval(poll); resolve(); } }, 100);
|
|
225
|
+
poll.unref?.();
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
} finally {
|
|
229
|
+
process.off("SIGINT", onSigint);
|
|
230
|
+
}
|
|
231
|
+
return EXIT.matched;
|
|
232
|
+
}
|