moshcode 0.25.1 → 0.27.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.
@@ -8,8 +8,12 @@ import {
8
8
  import {
9
9
  SKILL_ENGINES, planSkillInstall, runSkillInstall, skillName,
10
10
  } from "./skills.mjs";
11
+ import {
12
+ MARKETPLACE_NAME, PLUGINS, PLUGIN_ENGINES, marketplaceSource, planPluginCommand,
13
+ pluginId, resolvePlugin, runPluginCommand,
14
+ } from "./plugins.mjs";
11
15
  import { catalogList, resolveCatalog } from "./mcp-catalog.mjs";
12
- import { MCP_VERBS, SKILL_VERBS } from "./cli-schema.mjs";
16
+ import { MCP_VERBS, PLUGIN_VERBS, SKILL_VERBS } from "./cli-schema.mjs";
13
17
  import { acid, ash, bone, ok, err, info } from "./ui.mjs";
14
18
 
15
19
  function splitKV(pair) {
@@ -180,7 +184,7 @@ export function printSkillTargets(json = false) {
180
184
 
181
185
  function summarize(results) {
182
186
  for (const r of results) {
183
- if (r.status === "added" || r.status === "installed") console.log(line(r.key, ok(r.status)));
187
+ if (r.status === "added" || r.status === "installed" || r.status === "removed") console.log(line(r.key, ok(r.status)));
184
188
  else if (r.status === "failed") console.log(line(r.key, err(`failed${r.code != null ? ` (code ${r.code})` : r.signal ? ` (${r.signal})` : ""}`)));
185
189
  else if (r.status === "not-installed") console.log(line(r.key, ash("not installed — /install " + r.key)));
186
190
  else console.log(line(r.key, ash(`skipped — ${r.reason}`)));
@@ -263,3 +267,73 @@ export async function skillCommand(tokens, { run, installedSet } = {}) {
263
267
  summarize(results);
264
268
  return anyFailed(results) ? 1 : 0;
265
269
  }
270
+
271
+ /**
272
+ * `/plugin list` — what this marketplace ships, and which engines can take it.
273
+ *
274
+ * Two tables rather than one: the plugin list is a property of moshcode, the
275
+ * engine support is a property of this machine, and merging them into a single
276
+ * list is how "installed" and "installable" get confused.
277
+ */
278
+ export function printPluginTargets(json = false, { installedSet } = {}) {
279
+ const targets = integrationTargetStatus(PLUGIN_ENGINES, { installedSet }).map((t) => ({
280
+ ...t, supported: PLUGIN_ENGINES.includes(t.name),
281
+ }));
282
+ if (json) {
283
+ console.log(JSON.stringify({
284
+ marketplace: { name: MARKETPLACE_NAME, source: marketplaceSource() },
285
+ plugins: PLUGINS,
286
+ engines: targets,
287
+ }, null, 2));
288
+ return;
289
+ }
290
+ console.log(bone(" plugins") + ash(" — install moshcode's slash commands with ") + acid("/plugin install"));
291
+ for (const plugin of PLUGINS) {
292
+ console.log(` ${acid(pluginId(plugin.name).padEnd(18))}${ash(plugin.description)}`);
293
+ console.log(` ${" ".repeat(18)}${ash(plugin.commands.join(" "))}`);
294
+ }
295
+ console.log("");
296
+ for (const target of targets) {
297
+ const dot = target.supported && target.installed ? DOT.installed : DOT.missing;
298
+ console.log(` ${dot} ${bone(target.name.padEnd(9))} ${ash(target.supported ? "plugins supported" : "no plugin primitive")}`);
299
+ }
300
+ }
301
+
302
+ /** Run `/plugin …`. `tokens` are the words after `plugin`. */
303
+ export async function pluginCommand(tokens, { run, installedSet } = {}) {
304
+ const verb = tokens[0];
305
+ if (!verb || verb === "list") {
306
+ printPluginTargets(tokens.slice(1).includes("--json"), { installedSet });
307
+ return 0;
308
+ }
309
+ if (verb !== "install" && verb !== "remove") {
310
+ console.log(err(`unknown plugin verb "${verb}" — try ${PLUGIN_VERBS.map(({ name }) => name).join(", ")}`));
311
+ return 1;
312
+ }
313
+
314
+ const rest = tokens.slice(1).filter((t) => t !== "--json");
315
+ const stray = rest.find((t) => String(t).startsWith("-"));
316
+ if (stray) { console.log(err(`unknown plugin flag "${stray}"`)); return 1; }
317
+
318
+ const plugin = resolvePlugin(rest[0]);
319
+ if (!plugin) {
320
+ console.log(err(`unknown plugin "${rest[0]}" — this marketplace ships ${PLUGINS.map((p) => p.name).join(", ")}`));
321
+ return 1;
322
+ }
323
+
324
+ const source = marketplaceSource();
325
+ console.log(verb === "install"
326
+ ? info(`installing ${bone(pluginId(plugin.name))} ${ash(`from ${source}`)} across plugin engines…`)
327
+ : info(`removing ${bone(pluginId(plugin.name))} from plugin engines…`));
328
+
329
+ const plan = planPluginCommand({ plugin, source }, { installedSet, verb });
330
+ const results = await runPluginCommand(plan, { verb, ...(run ? { run } : {}) });
331
+ summarize(results);
332
+
333
+ // A newly installed plugin is not live in an already-running engine, and the
334
+ // first thing anyone does is type the slash command and conclude it failed.
335
+ if (!anyFailed(results) && verb === "install" && results.some((r) => r.status === "installed")) {
336
+ console.log(info(`restart the engine, then try ${acid(`${plugin.commands[0]} NVDA`)}`));
337
+ }
338
+ return anyFailed(results) ? 1 : 0;
339
+ }
@@ -0,0 +1,126 @@
1
+ // `moshcode plugin` — install moshcode's own slash commands into an engine.
2
+ //
3
+ // The same shape as src/skills.mjs, for the same reason: one source, a plan of
4
+ // per-engine actions, and a summary that names the engines it *skipped* as well
5
+ // as the ones it touched. An engine silently missing from the summary reads as
6
+ // "installed everywhere", which is exactly the confusion prd/0003 R8 exists to
7
+ // prevent.
8
+ //
9
+ // Claude Code is currently the only engine with a plugin primitive. That is a
10
+ // fact about the engines, not an assumption baked into the fan-out — adding a
11
+ // second one means adding a case to pluginInstallActions, nothing else.
12
+ import { ENGINES, isInstalled, ranOk, runCmd } from "./engines.mjs";
13
+
14
+ /** Engines with a plugin primitive. */
15
+ export const PLUGIN_ENGINES = ["claude"];
16
+
17
+ /** The marketplace this repo publishes (see .claude-plugin/marketplace.json). */
18
+ export const MARKETPLACE_NAME = "moshcode";
19
+
20
+ /**
21
+ * Where the marketplace is fetched from. A GitHub `owner/repo` by default;
22
+ * point it at a checkout to test an unreleased plugin:
23
+ * MOSHCODE_PLUGIN_SOURCE=. moshcode plugin install
24
+ */
25
+ export function marketplaceSource(env = process.env) {
26
+ return String(env.MOSHCODE_PLUGIN_SOURCE || "moshcoder/moshcode").trim() || "moshcoder/moshcode";
27
+ }
28
+
29
+ /** The plugins this marketplace ships. Mirrors .claude-plugin/marketplace.json. */
30
+ export const PLUGINS = [
31
+ {
32
+ name: "ticker",
33
+ description: "equity research slash commands backed by advis0r.com",
34
+ commands: ["/ticker", "/signals", "/research", "/lookup", "/reports", "/discover"],
35
+ },
36
+ {
37
+ name: "crypto",
38
+ description: "crypto market data slash commands backed by advis0r.com",
39
+ commands: ["/crypto", "/quote", "/book", "/bars", "/spark", "/pairs", "/coin"],
40
+ },
41
+ ];
42
+
43
+ export const DEFAULT_PLUGIN = PLUGINS[0].name;
44
+
45
+ export function resolvePlugin(name) {
46
+ if (!name) return PLUGINS.find((p) => p.name === DEFAULT_PLUGIN) ?? null;
47
+ const key = String(name).toLowerCase().replace(/@.*$/, "");
48
+ return PLUGINS.find((p) => p.name === key) ?? null;
49
+ }
50
+
51
+ /** Fully-qualified plugin id, the form `claude plugin install` disambiguates with. */
52
+ export function pluginId(name) {
53
+ return `${name}@${MARKETPLACE_NAME}`;
54
+ }
55
+
56
+ /**
57
+ * The commands one engine needs to install a plugin.
58
+ *
59
+ * Adding the marketplace is idempotent and separate from installing, so it runs
60
+ * every time: a machine that added the marketplace before this plugin existed
61
+ * would otherwise fail the install with "not found in any marketplace".
62
+ */
63
+ export function pluginInstallActions(key, { plugin, source, scope }) {
64
+ switch (key) {
65
+ case "claude":
66
+ return [
67
+ { cmd: "claude", args: ["plugin", "marketplace", "add", source, ...(scope ? ["--scope", scope] : [])] },
68
+ { cmd: "claude", args: ["plugin", "install", pluginId(plugin.name), ...(scope ? ["--scope", scope] : [])] },
69
+ ];
70
+ default:
71
+ return { skip: "no plugin primitive" };
72
+ }
73
+ }
74
+
75
+ export function pluginRemoveActions(key, { plugin }) {
76
+ switch (key) {
77
+ case "claude":
78
+ return [{ cmd: "claude", args: ["plugin", "uninstall", pluginId(plugin.name)] }];
79
+ default:
80
+ return { skip: "no plugin primitive" };
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Plan the fan-out: one entry per engine, with its actions or its skip reason.
86
+ * Derived from ENGINES so an engine added later cannot fall out of the summary.
87
+ */
88
+ export function planPluginCommand(spec, { installedSet, verb = "install" } = {}) {
89
+ const build = verb === "remove" ? pluginRemoveActions : pluginInstallActions;
90
+ const rest = Object.keys(ENGINES).filter((key) => !PLUGIN_ENGINES.includes(key));
91
+ return [...PLUGIN_ENGINES, ...rest].map((key) => {
92
+ const bin = ENGINES[key].bin;
93
+ const installed = installedSet ? installedSet.has(key) : isInstalled(bin, ENGINES[key].binDirs);
94
+ const actions = build(key, spec);
95
+ return Array.isArray(actions)
96
+ ? { key, bin, installed, actions }
97
+ : { key, bin, installed, ...actions };
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Execute a plan. Returns [{ key, status, reason?, code? }] with
103
+ * status one of installed | removed | skipped | failed | not-installed.
104
+ * `run` is injectable for tests.
105
+ */
106
+ export async function runPluginCommand(plan, { run = runCmd, verb = "install" } = {}) {
107
+ const done = verb === "remove" ? "removed" : "installed";
108
+ const results = [];
109
+ for (const item of plan) {
110
+ if (item.skip) { results.push({ key: item.key, status: "skipped", reason: item.skip }); continue; }
111
+ if (!item.installed) { results.push({ key: item.key, status: "not-installed" }); continue; }
112
+ let last = null;
113
+ let failed = false;
114
+ for (const action of item.actions) {
115
+ last = await run(action.cmd, action.args);
116
+ if (!ranOk(last)) { failed = true; break; }
117
+ }
118
+ results.push({
119
+ key: item.key,
120
+ status: failed ? "failed" : done,
121
+ code: last?.code ?? null,
122
+ signal: last?.signal ?? null,
123
+ });
124
+ }
125
+ return results;
126
+ }
@@ -0,0 +1,72 @@
1
+ import { canOpenBrowser, openBrowser } from "./open-url.mjs";
2
+
3
+ const DEFAULT_APP = "https://app.moshcode.sh";
4
+
5
+ export const SOCIALS = [
6
+ {
7
+ name: "bluesky",
8
+ aliases: ["bsky"],
9
+ description: "official Bluesky browser composer",
10
+ },
11
+ {
12
+ name: "nostr",
13
+ aliases: [],
14
+ description: "NIP-07/NIP-46 browser signer + relay publish",
15
+ },
16
+ ];
17
+
18
+ export function resolveSocial(name) {
19
+ const wanted = String(name ?? "").trim().toLowerCase();
20
+ return SOCIALS.find((social) =>
21
+ social.name === wanted || social.aliases.includes(wanted)) ?? null;
22
+ }
23
+
24
+ function appOrigin(env = process.env) {
25
+ return String(env.MOSHCODE_API || DEFAULT_APP).replace(/\/+$/, "");
26
+ }
27
+
28
+ /**
29
+ * Build the browser hand-off without opening anything. Nostr keeps the draft
30
+ * in the fragment so it never reaches app.moshcode.sh access logs or Referer
31
+ * headers; the composer reads it entirely in the browser.
32
+ */
33
+ export function socialPostUrl(name, message, { env = process.env } = {}) {
34
+ const social = resolveSocial(name);
35
+ if (!social) return null;
36
+ const text = String(message ?? "");
37
+ if (social.name === "bluesky") {
38
+ return `https://bsky.app/intent/compose?${new URLSearchParams({ text })}`;
39
+ }
40
+ return `${appOrigin(env)}/socials/nostr#${new URLSearchParams({ text })}`;
41
+ }
42
+
43
+ export function socialRoster() {
44
+ return SOCIALS.map((social) => ({ ...social, aliases: [...social.aliases] }));
45
+ }
46
+
47
+ /**
48
+ * Open a provider composer. Posting remains an explicit browser confirmation:
49
+ * Bluesky requires it, and Nostr asks the browser signer before relay publish.
50
+ */
51
+ export function postSocial(args, {
52
+ env = process.env,
53
+ canOpen = canOpenBrowser,
54
+ open = openBrowser,
55
+ } = {}) {
56
+ const [requested, ...words] = Array.isArray(args) ? args : [];
57
+ const social = resolveSocial(requested);
58
+ if (!requested) return { ok: false, error: 'usage: /post <social> "message"' };
59
+ if (!social) {
60
+ return {
61
+ ok: false,
62
+ error: `unknown social "${requested}". try: ${SOCIALS.map((entry) => entry.name).join(", ")}`,
63
+ };
64
+ }
65
+
66
+ const message = words.join(" ").trim();
67
+ if (!message) return { ok: false, error: 'usage: /post <social> "message"' };
68
+
69
+ const url = socialPostUrl(social.name, message, { env });
70
+ const opened = Boolean(canOpen() && open(url));
71
+ return { ok: true, social: social.name, message, url, opened };
72
+ }
package/src/tui.mjs CHANGED
@@ -10,6 +10,7 @@ import path from "node:path";
10
10
  import { ENGINES, agentLaunchArgs, resolveEngine, engineStatus, openSession } from "./engines.mjs";
11
11
  import { TOOLS, resolveTool, toolStatus, openTool } from "./tools.mjs";
12
12
  import { tradeArgs, tradeUsage } from "./trade.mjs";
13
+ import { postSocial, socialRoster } from "./socials.mjs";
13
14
  import { runUpgrade } from "./upgrade.mjs";
14
15
  import { locate, tilde } from "./pwd.mjs";
15
16
  import { createPrd, listPrds, authoringPrompt } from "./prd.mjs";
@@ -18,7 +19,10 @@ import { createMirror, teeOutput } from "./mirror.mjs";
18
19
  import { fetchMotdAd } from "./ads.mjs";
19
20
  import { runScript } from "./runtime.mjs";
20
21
  import { moshVocabulary } from "./commands.mjs";
21
- import { mcpCommand, skillCommand } from "./integrations.mjs";
22
+ import { mcpCommand, pluginCommand, skillCommand } from "./integrations.mjs";
23
+ import { tickerCommand } from "./advisor.mjs";
24
+ import { cryptoCommand } from "./crypto.mjs";
25
+ import { canOpenBrowser, openBrowser } from "./open-url.mjs";
22
26
  import { banner, hr, acid, ash, bone, dim, ok, err, warn, info, moshcodeVersion } from "./ui.mjs";
23
27
  import { CORE_CLI_COMMAND_NAMES } from "./cli-schema.mjs";
24
28
  import { findPitCommand, pitHelpModel, renderPitCommand, suggest, wantsHelp } from "./help.mjs";
@@ -156,6 +160,15 @@ function printTools() {
156
160
  console.log(ash(" → ") + acid("https://dev.profullstack.com/"));
157
161
  }
158
162
 
163
+ function printSocials() {
164
+ console.log(bone(" socials") + ash(" — compose with ") + acid('/post <social> "message"'));
165
+ for (const social of socialRoster()) {
166
+ const aliases = social.aliases.length ? ` (${social.aliases.join(", ")})` : "";
167
+ console.log(` ${acid("●")} ${bone(social.name.padEnd(9))} ${ash(social.description + aliases)}`);
168
+ }
169
+ console.log(ash(" the browser always asks you to confirm before anything is published"));
170
+ }
171
+
159
172
  /**
160
173
  * The moshscript vocabulary, split the way the CLI's help splits it.
161
174
  *
@@ -660,6 +673,37 @@ export async function tui() {
660
673
  rl = mkrl();
661
674
  continue;
662
675
  }
676
+ // `/ticker` renders in the pit rather than handing the terminal to a tool:
677
+ // there is no advis0r binary to launch, only a public read-only API.
678
+ if (cmd === "ticker" || cmd === "advisor") {
679
+ await tickerCommand(rest, { openUrl: (url) => canOpenBrowser() && openBrowser(url) });
680
+ continue;
681
+ }
682
+ // `/crypto` renders in the pit for the same reason `/ticker` does: there is
683
+ // no crypto binary to hand the terminal to, only a public read-only API.
684
+ if (cmd === "crypto" || cmd === "coins") {
685
+ await cryptoCommand(rest, { openUrl: (url) => canOpenBrowser() && openBrowser(url) });
686
+ continue;
687
+ }
688
+ if (cmd === "plugin" || cmd === "plugins") {
689
+ await pluginCommand(rest);
690
+ continue;
691
+ }
692
+ if (cmd === "socials" || cmd === "social") {
693
+ printSocials();
694
+ continue;
695
+ }
696
+ if (cmd === "post") {
697
+ const result = postSocial(rest);
698
+ if (!result.ok) { console.log(err(result.error)); continue; }
699
+ if (result.opened) {
700
+ console.log(ok(`opened the ${result.social} composer — confirm the post in your browser 🤘`));
701
+ } else {
702
+ console.log(info(`open this ${result.social} composer in a browser:`));
703
+ console.log(` ${result.url}`);
704
+ }
705
+ continue;
706
+ }
663
707
  // Bare engine name → open it.
664
708
  const resolved = resolveEngine(cmd);
665
709
  if (resolved) {