", "discover: quarters to look ahead (1 or 2)", "2"],
+ ["--provider ", "discover: analysis provider", "offline"],
+ ],
+ examples: [
+ ["moshcode ticker NVDA", "score, technicals, thesis, signals, sources"],
+ ["moshcode ticker lookup rivian", "company name → RIVN"],
+ ["moshcode ticker signals AAPL", "what was actually said, with sources"],
+ ["moshcode ticker search 'data center'", "across every indexed transcript"],
+ ["moshcode ticker reports --limit 10", "the stored index, best score first"],
+ ],
+ seeAlso: ["trade", "plugin", "tools"],
+ note: "research aid, not advice — reports are stored snapshots and every one prints when it was generated. Set MOSHCODE_ADVISOR_URL to point at another instance.",
+ },
+ { name: "advisor", aliasOf: "ticker", description: "alias for ticker" },
+ {
+ name: "plugin",
+ group: "extend",
+ description: "install moshcode's slash commands into Claude Code",
+ synopsis: [["moshcode plugin [name]", ""]],
+ verbs: "PLUGIN_VERBS",
+ flags: [["--json", "machine-readable", ""]],
+ examples: [
+ ["moshcode plugin install", "add the marketplace and install ticker"],
+ ["moshcode plugin list", "what this marketplace ships, and what is installed"],
+ ],
+ seeAlso: ["skill", "mcp", "ticker"],
+ note: "Claude Code is the only engine with a plugin primitive; the others are reported as skipped, exactly as they are for skills.",
+ },
+ { name: "plugins", aliasOf: "plugin", description: "alias for plugin" },
{
name: "commands",
group: "script",
@@ -466,6 +508,51 @@ export const DNS_VERBS = [
{ name: "trust", description: "trust one name's certificate, after checking it against the registry pin" },
];
+/**
+ * `ticker`'s verbs.
+ *
+ * `report` exists so a symbol that collides with a verb name still has an
+ * unambiguous spelling; without it, the bare-symbol shortcut would have no
+ * escape hatch. src/advisor.mjs owns the parser and test/advisor.test.mjs
+ * fails when the two lists disagree.
+ */
+export const TICKER_VERBS = [
+ { name: "report", description: "the stored research report for one ticker", synopsis: [["moshcode ticker report ", "same as `moshcode ticker `"]] },
+ { name: "signals", description: "every extracted signal for a ticker", synopsis: [["moshcode ticker signals ", ""]] },
+ {
+ name: "search", description: "full-text search across indexed transcripts",
+ synopsis: [["moshcode ticker search [--limit n]", ""]],
+ },
+ {
+ name: "lookup", description: "find a ticker by company name",
+ synopsis: [["moshcode ticker lookup [--limit n]", "rivian → RIVN"]],
+ },
+ {
+ name: "reports", description: "every stored report",
+ synopsis: [["moshcode ticker reports [--sort recent|score|ticker] [--limit n]", ""]],
+ },
+ {
+ name: "discover", description: "a ranked watchlist for a topic",
+ synopsis: [["moshcode ticker discover [topic…] [--horizon 1|2] [--provider p] [--limit n]", ""]],
+ note: "ranks by analyzing each candidate — this one takes minutes, not milliseconds.",
+ },
+ { name: "tickers", description: "every ticker present in the index", synopsis: [["moshcode ticker tickers", ""]] },
+ { name: "stats", description: "index coverage counts", synopsis: [["moshcode ticker stats", ""]] },
+ { name: "open", description: "open the shareable report page in a browser", synopsis: [["moshcode ticker open ", ""]] },
+];
+
+export const PLUGIN_VERBS = [
+ {
+ name: "install", description: "add the marketplace and install a plugin",
+ synopsis: [
+ ["moshcode plugin install", "the default plugin (ticker)"],
+ ["moshcode plugin install ", ""],
+ ],
+ },
+ { name: "list", description: "show what the marketplace ships and what is installed", synopsis: [["moshcode plugin list [--json]", ""]] },
+ { name: "remove", description: "uninstall a plugin from Claude Code", synopsis: [["moshcode plugin remove ", ""]] },
+];
+
/** Sub-verb tables, by the name a command's `verbs` field refers to. */
export const VERB_TABLES = {
MCP_VERBS,
@@ -473,6 +560,8 @@ export const VERB_TABLES = {
UPGRADE_TARGETS,
DNS_VERBS,
TRADE_VERBS,
+ TICKER_VERBS,
+ PLUGIN_VERBS,
};
/**
@@ -499,6 +588,10 @@ export const PIT_COMMANDS = [
description: "list workflow tools, or run one" },
{ name: "trade", args: " [args…]", cli: "trade",
description: "look up markets and preview/place Alpaca orders" },
+ { name: "ticker", aliases: ["advisor"], args: " [args…]", cli: "ticker",
+ description: "equity research from advis0r.com" },
+ { name: "plugin", aliases: ["plugins"], args: " [name]", cli: "plugin",
+ description: "install moshcode's slash commands into Claude Code" },
{ name: "socials", aliases: ["social"], pitOnly: true,
description: "list social networks available for posting" },
{ name: "post", args: ' "message"', pitOnly: true,
diff --git a/src/integrations.mjs b/src/integrations.mjs
index 1ca8e2b..62599f7 100644
--- a/src/integrations.mjs
+++ b/src/integrations.mjs
@@ -8,8 +8,12 @@ import {
import {
SKILL_ENGINES, planSkillInstall, runSkillInstall, skillName,
} from "./skills.mjs";
+import {
+ MARKETPLACE_NAME, PLUGINS, PLUGIN_ENGINES, marketplaceSource, planPluginCommand,
+ pluginId, resolvePlugin, runPluginCommand,
+} from "./plugins.mjs";
import { catalogList, resolveCatalog } from "./mcp-catalog.mjs";
-import { MCP_VERBS, SKILL_VERBS } from "./cli-schema.mjs";
+import { MCP_VERBS, PLUGIN_VERBS, SKILL_VERBS } from "./cli-schema.mjs";
import { acid, ash, bone, ok, err, info } from "./ui.mjs";
function splitKV(pair) {
@@ -180,7 +184,7 @@ export function printSkillTargets(json = false) {
function summarize(results) {
for (const r of results) {
- if (r.status === "added" || r.status === "installed") console.log(line(r.key, ok(r.status)));
+ if (r.status === "added" || r.status === "installed" || r.status === "removed") console.log(line(r.key, ok(r.status)));
else if (r.status === "failed") console.log(line(r.key, err(`failed${r.code != null ? ` (code ${r.code})` : r.signal ? ` (${r.signal})` : ""}`)));
else if (r.status === "not-installed") console.log(line(r.key, ash("not installed — /install " + r.key)));
else console.log(line(r.key, ash(`skipped — ${r.reason}`)));
@@ -263,3 +267,73 @@ export async function skillCommand(tokens, { run, installedSet } = {}) {
summarize(results);
return anyFailed(results) ? 1 : 0;
}
+
+/**
+ * `/plugin list` — what this marketplace ships, and which engines can take it.
+ *
+ * Two tables rather than one: the plugin list is a property of moshcode, the
+ * engine support is a property of this machine, and merging them into a single
+ * list is how "installed" and "installable" get confused.
+ */
+export function printPluginTargets(json = false, { installedSet } = {}) {
+ const targets = integrationTargetStatus(PLUGIN_ENGINES, { installedSet }).map((t) => ({
+ ...t, supported: PLUGIN_ENGINES.includes(t.name),
+ }));
+ if (json) {
+ console.log(JSON.stringify({
+ marketplace: { name: MARKETPLACE_NAME, source: marketplaceSource() },
+ plugins: PLUGINS,
+ engines: targets,
+ }, null, 2));
+ return;
+ }
+ console.log(bone(" plugins") + ash(" — install moshcode's slash commands with ") + acid("/plugin install"));
+ for (const plugin of PLUGINS) {
+ console.log(` ${acid(pluginId(plugin.name).padEnd(18))}${ash(plugin.description)}`);
+ console.log(` ${" ".repeat(18)}${ash(plugin.commands.join(" "))}`);
+ }
+ console.log("");
+ for (const target of targets) {
+ const dot = target.supported && target.installed ? DOT.installed : DOT.missing;
+ console.log(` ${dot} ${bone(target.name.padEnd(9))} ${ash(target.supported ? "plugins supported" : "no plugin primitive")}`);
+ }
+}
+
+/** Run `/plugin …`. `tokens` are the words after `plugin`. */
+export async function pluginCommand(tokens, { run, installedSet } = {}) {
+ const verb = tokens[0];
+ if (!verb || verb === "list") {
+ printPluginTargets(tokens.slice(1).includes("--json"), { installedSet });
+ return 0;
+ }
+ if (verb !== "install" && verb !== "remove") {
+ console.log(err(`unknown plugin verb "${verb}" — try ${PLUGIN_VERBS.map(({ name }) => name).join(", ")}`));
+ return 1;
+ }
+
+ const rest = tokens.slice(1).filter((t) => t !== "--json");
+ const stray = rest.find((t) => String(t).startsWith("-"));
+ if (stray) { console.log(err(`unknown plugin flag "${stray}"`)); return 1; }
+
+ const plugin = resolvePlugin(rest[0]);
+ if (!plugin) {
+ console.log(err(`unknown plugin "${rest[0]}" — this marketplace ships ${PLUGINS.map((p) => p.name).join(", ")}`));
+ return 1;
+ }
+
+ const source = marketplaceSource();
+ console.log(verb === "install"
+ ? info(`installing ${bone(pluginId(plugin.name))} ${ash(`from ${source}`)} across plugin engines…`)
+ : info(`removing ${bone(pluginId(plugin.name))} from plugin engines…`));
+
+ const plan = planPluginCommand({ plugin, source }, { installedSet, verb });
+ const results = await runPluginCommand(plan, { verb, ...(run ? { run } : {}) });
+ summarize(results);
+
+ // A newly installed plugin is not live in an already-running engine, and the
+ // first thing anyone does is type the slash command and conclude it failed.
+ if (!anyFailed(results) && verb === "install" && results.some((r) => r.status === "installed")) {
+ console.log(info(`restart the engine, then try ${acid(`${plugin.commands[0]} NVDA`)}`));
+ }
+ return anyFailed(results) ? 1 : 0;
+}
diff --git a/src/plugins.mjs b/src/plugins.mjs
new file mode 100644
index 0000000..696ad90
--- /dev/null
+++ b/src/plugins.mjs
@@ -0,0 +1,121 @@
+// `moshcode plugin` — install moshcode's own slash commands into an engine.
+//
+// The same shape as src/skills.mjs, for the same reason: one source, a plan of
+// per-engine actions, and a summary that names the engines it *skipped* as well
+// as the ones it touched. An engine silently missing from the summary reads as
+// "installed everywhere", which is exactly the confusion prd/0003 R8 exists to
+// prevent.
+//
+// Claude Code is currently the only engine with a plugin primitive. That is a
+// fact about the engines, not an assumption baked into the fan-out — adding a
+// second one means adding a case to pluginInstallActions, nothing else.
+import { ENGINES, isInstalled, ranOk, runCmd } from "./engines.mjs";
+
+/** Engines with a plugin primitive. */
+export const PLUGIN_ENGINES = ["claude"];
+
+/** The marketplace this repo publishes (see .claude-plugin/marketplace.json). */
+export const MARKETPLACE_NAME = "moshcode";
+
+/**
+ * Where the marketplace is fetched from. A GitHub `owner/repo` by default;
+ * point it at a checkout to test an unreleased plugin:
+ * MOSHCODE_PLUGIN_SOURCE=. moshcode plugin install
+ */
+export function marketplaceSource(env = process.env) {
+ return String(env.MOSHCODE_PLUGIN_SOURCE || "moshcoder/moshcode").trim() || "moshcoder/moshcode";
+}
+
+/** The plugins this marketplace ships. Mirrors .claude-plugin/marketplace.json. */
+export const PLUGINS = [
+ {
+ name: "ticker",
+ description: "equity research slash commands backed by advis0r.com",
+ commands: ["/ticker", "/signals", "/research", "/lookup", "/reports", "/discover"],
+ },
+];
+
+export const DEFAULT_PLUGIN = PLUGINS[0].name;
+
+export function resolvePlugin(name) {
+ if (!name) return PLUGINS.find((p) => p.name === DEFAULT_PLUGIN) ?? null;
+ const key = String(name).toLowerCase().replace(/@.*$/, "");
+ return PLUGINS.find((p) => p.name === key) ?? null;
+}
+
+/** Fully-qualified plugin id, the form `claude plugin install` disambiguates with. */
+export function pluginId(name) {
+ return `${name}@${MARKETPLACE_NAME}`;
+}
+
+/**
+ * The commands one engine needs to install a plugin.
+ *
+ * Adding the marketplace is idempotent and separate from installing, so it runs
+ * every time: a machine that added the marketplace before this plugin existed
+ * would otherwise fail the install with "not found in any marketplace".
+ */
+export function pluginInstallActions(key, { plugin, source, scope }) {
+ switch (key) {
+ case "claude":
+ return [
+ { cmd: "claude", args: ["plugin", "marketplace", "add", source, ...(scope ? ["--scope", scope] : [])] },
+ { cmd: "claude", args: ["plugin", "install", pluginId(plugin.name), ...(scope ? ["--scope", scope] : [])] },
+ ];
+ default:
+ return { skip: "no plugin primitive" };
+ }
+}
+
+export function pluginRemoveActions(key, { plugin }) {
+ switch (key) {
+ case "claude":
+ return [{ cmd: "claude", args: ["plugin", "uninstall", pluginId(plugin.name)] }];
+ default:
+ return { skip: "no plugin primitive" };
+ }
+}
+
+/**
+ * Plan the fan-out: one entry per engine, with its actions or its skip reason.
+ * Derived from ENGINES so an engine added later cannot fall out of the summary.
+ */
+export function planPluginCommand(spec, { installedSet, verb = "install" } = {}) {
+ const build = verb === "remove" ? pluginRemoveActions : pluginInstallActions;
+ const rest = Object.keys(ENGINES).filter((key) => !PLUGIN_ENGINES.includes(key));
+ return [...PLUGIN_ENGINES, ...rest].map((key) => {
+ const bin = ENGINES[key].bin;
+ const installed = installedSet ? installedSet.has(key) : isInstalled(bin, ENGINES[key].binDirs);
+ const actions = build(key, spec);
+ return Array.isArray(actions)
+ ? { key, bin, installed, actions }
+ : { key, bin, installed, ...actions };
+ });
+}
+
+/**
+ * Execute a plan. Returns [{ key, status, reason?, code? }] with
+ * status one of installed | removed | skipped | failed | not-installed.
+ * `run` is injectable for tests.
+ */
+export async function runPluginCommand(plan, { run = runCmd, verb = "install" } = {}) {
+ const done = verb === "remove" ? "removed" : "installed";
+ const results = [];
+ for (const item of plan) {
+ if (item.skip) { results.push({ key: item.key, status: "skipped", reason: item.skip }); continue; }
+ if (!item.installed) { results.push({ key: item.key, status: "not-installed" }); continue; }
+ let last = null;
+ let failed = false;
+ for (const action of item.actions) {
+ last = await run(action.cmd, action.args);
+ if (!ranOk(last)) { failed = true; break; }
+ }
+ results.push({
+ key: item.key,
+ status: failed ? "failed" : done,
+ code: last?.code ?? null,
+ signal: last?.signal ?? null,
+ });
+ }
+ return results;
+}
diff --git a/src/tui.mjs b/src/tui.mjs
index 2085469..26b8dad 100644
--- a/src/tui.mjs
+++ b/src/tui.mjs
@@ -19,7 +19,9 @@ import { createMirror, teeOutput } from "./mirror.mjs";
import { fetchMotdAd } from "./ads.mjs";
import { runScript } from "./runtime.mjs";
import { moshVocabulary } from "./commands.mjs";
-import { mcpCommand, skillCommand } from "./integrations.mjs";
+import { mcpCommand, pluginCommand, skillCommand } from "./integrations.mjs";
+import { tickerCommand } from "./advisor.mjs";
+import { canOpenBrowser, openBrowser } from "./open-url.mjs";
import { banner, hr, acid, ash, bone, dim, ok, err, warn, info, moshcodeVersion } from "./ui.mjs";
import { CORE_CLI_COMMAND_NAMES } from "./cli-schema.mjs";
import { findPitCommand, pitHelpModel, renderPitCommand, suggest, wantsHelp } from "./help.mjs";
@@ -670,6 +672,16 @@ export async function tui() {
rl = mkrl();
continue;
}
+ // `/ticker` renders in the pit rather than handing the terminal to a tool:
+ // there is no advis0r binary to launch, only a public read-only API.
+ if (cmd === "ticker" || cmd === "advisor") {
+ await tickerCommand(rest, { openUrl: (url) => canOpenBrowser() && openBrowser(url) });
+ continue;
+ }
+ if (cmd === "plugin" || cmd === "plugins") {
+ await pluginCommand(rest);
+ continue;
+ }
if (cmd === "socials" || cmd === "social") {
printSocials();
continue;
diff --git a/test/advisor.test.mjs b/test/advisor.test.mjs
new file mode 100644
index 0000000..a710817
--- /dev/null
+++ b/test/advisor.test.mjs
@@ -0,0 +1,248 @@
+// `moshcode ticker` — argument translation, request building, and the two
+// things this command must never get wrong: presenting a stored snapshot as a
+// live quote, and dropping the API's disclaimer.
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ DEFAULT_ADVISOR_URL, TICKER_VERB_NAMES, advisorBase, advisorUrl, fetchAdvisor,
+ normalizeSymbol, renderAdvisor, resolveVerb, tickerArgs, tickerCommand, tickerUsage,
+} from "../src/advisor.mjs";
+import { TICKER_VERBS } from "../src/cli-schema.mjs";
+
+// --- the bare-symbol shortcut ------------------------------------------------
+
+test("a bare symbol is the report, and is upper-cased on the way out", () => {
+ assert.deepEqual(tickerArgs(["nvda"]), {
+ verb: "report", symbol: "NVDA", path: "/api/ticker", query: { symbol: "NVDA" }, json: false,
+ });
+ assert.deepEqual(tickerArgs(["report", "brk.b"]), {
+ verb: "report", symbol: "BRK.B", path: "/api/ticker", query: { symbol: "BRK.B" }, json: false,
+ });
+});
+
+test("no arguments prints usage rather than guessing a symbol", () => {
+ assert.deepEqual(tickerArgs([]), { usage: true });
+ assert.match(tickerUsage(), /usage: moshcode ticker/);
+});
+
+test("a company name is refused with the lookup that resolves it", () => {
+ const result = tickerArgs(["some very long company name"]);
+ assert.match(result.error, /is not a ticker symbol/);
+ assert.match(result.error, /moshcode ticker lookup/);
+});
+
+// --- verbs -------------------------------------------------------------------
+
+test("every verb the schema documents is one the parser resolves", () => {
+ // The schema drives help and completion; the parser drives behaviour. A verb
+ // in one and not the other is a command that completes and then fails.
+ assert.deepEqual(TICKER_VERBS.map(({ name }) => name).sort(), [...TICKER_VERB_NAMES].sort());
+ for (const { name } of TICKER_VERBS) assert.equal(resolveVerb(name), name, `${name} does not resolve`);
+});
+
+test("aliases resolve, and anything else is treated as a symbol", () => {
+ assert.equal(resolveVerb("news"), "signals");
+ assert.equal(resolveVerb("watchlist"), "discover");
+ assert.equal(resolveVerb("quote"), "report");
+ assert.equal(resolveVerb("NVDA"), null);
+});
+
+test("search and lookup take words, not symbols", () => {
+ assert.deepEqual(tickerArgs(["search", "data", "center", "--limit", "5"]), {
+ verb: "search", path: "/api/search", query: { q: "data center", limit: "5" }, json: false,
+ });
+ assert.deepEqual(tickerArgs(["lookup", "rivian"]), {
+ verb: "lookup", path: "/api/lookup", query: { q: "rivian" }, json: false,
+ });
+});
+
+test("reports defaults to score order and discover to the offline provider", () => {
+ assert.deepEqual(tickerArgs(["reports"]).query, { sort: "score" });
+ assert.deepEqual(tickerArgs(["reports", "--sort", "recent"]).query, { sort: "recent" });
+ assert.deepEqual(tickerArgs(["discover", "fusion"]).query, {
+ topic: "fusion", provider: "offline", horizon: "2",
+ });
+});
+
+test("discover is marked slow, because it analyzes every candidate", () => {
+ // The flag is what buys it the longer timeout in fetchAdvisor. Without it the
+ // route reliably aborts mid-ranking and looks like an outage.
+ assert.equal(tickerArgs(["discover"]).slow, true);
+ assert.equal(tickerArgs(["reports"]).slow, undefined);
+});
+
+// --- flags -------------------------------------------------------------------
+
+test("flag values are validated rather than forwarded", () => {
+ assert.match(tickerArgs(["reports", "--limit", "0"]).error, /positive number/);
+ assert.match(tickerArgs(["reports", "--limit"]).error, /positive number/);
+ assert.match(tickerArgs(["reports", "--sort", "sideways"]).error, /recent, score, ticker/);
+ assert.match(tickerArgs(["discover", "--horizon", "9"]).error, /must be 1 or 2/);
+});
+
+test("an unknown flag is an error, not a search term", () => {
+ // Left alone it would join `q` and be searched for verbatim, which returns
+ // nothing and reads as "the index has no coverage".
+ assert.match(tickerArgs(["search", "--depth", "3"]).error, /unknown ticker flag/);
+});
+
+test("--json survives anywhere in the argument list", () => {
+ assert.equal(tickerArgs(["--json", "nvda"]).json, true);
+ assert.equal(tickerArgs(["nvda", "--json"]).json, true);
+});
+
+// --- symbols -----------------------------------------------------------------
+
+test("normalizeSymbol accepts tickers and refuses prose", () => {
+ assert.equal(normalizeSymbol("aapl"), "AAPL");
+ assert.equal(normalizeSymbol(" brk.b "), "BRK.B");
+ assert.equal(normalizeSymbol("rivian automotive"), null);
+ assert.equal(normalizeSymbol(""), null);
+});
+
+// --- URLs --------------------------------------------------------------------
+
+test("the base URL is overridable, so a local instance is testable", () => {
+ assert.equal(advisorBase({}), DEFAULT_ADVISOR_URL);
+ assert.equal(advisorBase({ MOSHCODE_ADVISOR_URL: "http://localhost:8080/" }), "http://localhost:8080");
+});
+
+test("query values are encoded, not concatenated", () => {
+ const url = advisorUrl(tickerArgs(["search", "data center & more"]), { base: "https://example.test" });
+ assert.equal(url, "https://example.test/api/search?q=data+center+%26+more");
+});
+
+test("open builds the shareable page URL and makes no request", () => {
+ const request = tickerArgs(["open", "nvda"]);
+ assert.equal(request.path, undefined);
+ assert.equal(advisorUrl(request, { base: "https://example.test" }), "https://example.test/ticker/NVDA");
+});
+
+// --- fetching ----------------------------------------------------------------
+
+const jsonResponse = (body, { ok = true, status = 200 } = {}) => ({
+ ok, status, text: async () => JSON.stringify(body),
+});
+
+test("a non-JSON body is a failure, not a silent empty render", () => {
+ return fetchAdvisor(tickerArgs(["stats"]), {
+ fetchImpl: async () => ({ ok: false, status: 502, text: async () => "bad gateway" }),
+ }).then((res) => {
+ assert.equal(res.ok, false);
+ assert.match(res.error, /502/);
+ });
+});
+
+test("a transport failure is reported, never thrown at the caller", async () => {
+ const res = await fetchAdvisor(tickerArgs(["stats"]), {
+ fetchImpl: async () => { throw new Error("ECONNREFUSED"); },
+ });
+ assert.equal(res.ok, false);
+ assert.match(res.error, /ECONNREFUSED/);
+});
+
+// --- rendering ---------------------------------------------------------------
+
+const REPORT = {
+ ticker: "NVDA", companyName: "NVIDIA CORP", exchange: "NASDAQ",
+ lastPrice: 207.23, priceTimestamp: "2026-08-03T19:59:58Z", delayed: true, marketSource: "iex",
+ overallScore: 64.61, confidence: 77.5, classification: "conservative",
+ technical: { rsi14: 53.37, sma: { 50: 205.78, 200: 193.05 }, atr14: 7.64, relativeVolume: 0.47 },
+ facts: { source: "sec", marketCap: 5.01e12, revenue: 2.69e10, revenueGrowth: 61.4 },
+ analysis: { thesis: "offline thesis" },
+ signals: [{ signal_type: "commercial_launch", direction: "positive", quote: "a quote", event_date: "2026-05-20" }],
+ sources: [{ url: "https://example.test/a", title: "A source", publishedAt: "2026-07-24" }],
+ cached: true, reportGeneratedAt: "2026-08-03T16:25:48.040Z",
+ disclaimer: "Research aid, not advice.",
+};
+
+test("a report states when the snapshot was generated", () => {
+ // The one rule this surface cannot break: a stored price rendered as a live
+ // quote. `delayed`, the feed, and the generated-at stamp all have to survive.
+ const out = renderAdvisor("report", REPORT, { columns: 88 });
+ assert.match(out, /2026-08-03T16:25:48\.040Z/, "the generated-at stamp is missing");
+ assert.match(out, /delayed/, "the delayed marker is missing");
+ assert.match(out, /cached/);
+});
+
+test("a report carries the API's own disclaimer", () => {
+ assert.match(renderAdvisor("report", REPORT, { columns: 88 }), /Research aid, not advice/);
+});
+
+test("an offline thesis is labelled offline, and a model thesis names the model", () => {
+ assert.match(renderAdvisor("report", REPORT, { columns: 88 }), /thesis \(offline\)/);
+ const withAi = { ...REPORT, aiAnalysis: { provider: "anthropic", model: "claude-sonnet-5", analysis: { thesis: "model thesis" } } };
+ const out = renderAdvisor("report", withAi, { columns: 88 });
+ assert.match(out, /thesis \(anthropic\/claude-sonnet-5\)/);
+ assert.match(out, /model thesis/);
+});
+
+test("a report missing its optional sections still renders", () => {
+ // Every one of these is genuinely absent in live responses when SEC or the
+ // market feed rate-limits, and a throw here would mean no report at all.
+ const bare = { ticker: "AAA", lastPrice: null, disclaimer: "d" };
+ assert.match(renderAdvisor("report", bare, { columns: 88 }), /AAA/);
+});
+
+test("empty result sets say so instead of rendering an empty table", () => {
+ assert.match(renderAdvisor("signals", { ticker: "AAA", signals: [] }), /no signals indexed/);
+ assert.match(renderAdvisor("search", { query: "zzz", results: [] }), /nothing indexed matches/);
+ assert.match(renderAdvisor("lookup", { query: "zzz", matches: [] }), /no ticker matches/);
+ assert.match(renderAdvisor("reports", { reports: [] }), /no stored reports/);
+});
+
+// --- the command end to end --------------------------------------------------
+
+function capture() {
+ const lines = [];
+ return { lines, out: (s) => lines.push(s), fail: (s) => lines.push(s) };
+}
+
+test("--json prints the response verbatim and renders nothing", async () => {
+ const io = capture();
+ const code = await tickerCommand(["stats", "--json"], {
+ ...io, fetchImpl: async () => jsonResponse({ documents: 3 }),
+ });
+ assert.equal(code, 0);
+ assert.deepEqual(JSON.parse(io.lines.join("\n")), { documents: 3 });
+});
+
+test("the API's own error is surfaced, with its did-you-mean", async () => {
+ const io = capture();
+ const code = await tickerCommand(["RIVIAN"], {
+ ...io,
+ fetchImpl: async () => jsonResponse(
+ { error: '"RIVIAN" is not a ticker — did you mean RIVN?', didYouMean: { symbol: "RIVN", name: "Rivian" } },
+ { ok: false, status: 400 },
+ ),
+ });
+ assert.equal(code, 1, "a failed lookup must not exit 0");
+ const output = io.lines.join("\n");
+ assert.match(output, /did you mean RIVN/);
+ assert.match(output, /moshcode ticker RIVN/);
+});
+
+test("a bad argument exits non-zero without touching the network", async () => {
+ const io = capture();
+ let called = false;
+ const code = await tickerCommand(["reports", "--sort", "sideways"], {
+ ...io, fetchImpl: async () => { called = true; return jsonResponse({}); },
+ });
+ assert.equal(code, 1);
+ assert.equal(called, false, "a rejected argument still made a request");
+});
+
+test("open never makes a request, and prints the URL when no browser opens", async () => {
+ const io = capture();
+ let called = false;
+ const code = await tickerCommand(["open", "nvda"], {
+ ...io,
+ base: "https://example.test",
+ openUrl: () => false,
+ fetchImpl: async () => { called = true; return jsonResponse({}); },
+ });
+ assert.equal(code, 0);
+ assert.equal(called, false);
+ assert.match(io.lines.join("\n"), /https:\/\/example\.test\/ticker\/NVDA/);
+});
diff --git a/test/plugins.test.mjs b/test/plugins.test.mjs
new file mode 100644
index 0000000..d6d12fa
--- /dev/null
+++ b/test/plugins.test.mjs
@@ -0,0 +1,147 @@
+// `moshcode plugin` — the same fan-out contract as prd/0003 R8 for skills: the
+// plan covers every engine, and an engine with no plugin primitive is reported
+// as skipped rather than silently omitted.
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+
+import { ENGINES } from "../src/engines.mjs";
+import {
+ MARKETPLACE_NAME, PLUGINS, PLUGIN_ENGINES, marketplaceSource, planPluginCommand,
+ pluginId, resolvePlugin, runPluginCommand,
+} from "../src/plugins.mjs";
+
+const ROOT = fileURLToPath(new URL("..", import.meta.url));
+const NO_PRIMITIVE = Object.keys(ENGINES).filter((key) => !PLUGIN_ENGINES.includes(key));
+const SPEC = { plugin: PLUGINS[0], source: "moshcoder/moshcode" };
+const byKey = (results) => Object.fromEntries(results.map((r) => [r.key, r]));
+
+// --- the fan-out contract ----------------------------------------------------
+
+test("the plan covers every engine, not just the ones with a primitive", () => {
+ const keys = planPluginCommand(SPEC, { installedSet: new Set() }).map((p) => p.key);
+ assert.deepEqual([...keys].sort(), Object.keys(ENGINES).sort());
+});
+
+test("every engine without a primitive carries the skip reason", () => {
+ const plan = byKey(planPluginCommand(SPEC, { installedSet: new Set() }));
+ for (const key of NO_PRIMITIVE) {
+ assert.equal(plan[key]?.skip, "no plugin primitive", `${key} has no skip reason`);
+ }
+});
+
+test("an engine that is not installed is reported, not attempted", async () => {
+ const results = byKey(await runPluginCommand(
+ planPluginCommand(SPEC, { installedSet: new Set() }),
+ { run: async () => assert.fail("ran a command for an absent engine") },
+ ));
+ assert.equal(results.claude.status, "not-installed");
+});
+
+// --- what actually runs ------------------------------------------------------
+
+test("install adds the marketplace before installing, every time", () => {
+ // A machine that added this marketplace before the plugin existed would fail
+ // the install with "not found in any marketplace" if `add` were skipped.
+ const plan = byKey(planPluginCommand(SPEC, { installedSet: new Set(["claude"]) }));
+ assert.deepEqual(plan.claude.actions.map((a) => a.args), [
+ ["plugin", "marketplace", "add", "moshcoder/moshcode"],
+ ["plugin", "install", "ticker@moshcode"],
+ ]);
+});
+
+test("a failing step stops the chain rather than installing from nothing", async () => {
+ const ran = [];
+ const results = byKey(await runPluginCommand(
+ planPluginCommand(SPEC, { installedSet: new Set(["claude"]) }),
+ {
+ run: async (cmd, args) => { ran.push(args[1]); return { ok: true, code: 1 }; },
+ },
+ ));
+ assert.equal(results.claude.status, "failed");
+ assert.deepEqual(ran, ["marketplace"], "it kept going after the marketplace failed");
+});
+
+test("remove uninstalls the qualified id, and touches no marketplace", () => {
+ const plan = byKey(planPluginCommand(SPEC, { installedSet: new Set(["claude"]), verb: "remove" }));
+ assert.deepEqual(plan.claude.actions.map((a) => a.args), [["plugin", "uninstall", "ticker@moshcode"]]);
+});
+
+test("a successful run reports installed / removed, matching the verb", async () => {
+ const ok = async () => ({ ok: true, code: 0 });
+ const installed = byKey(await runPluginCommand(planPluginCommand(SPEC, { installedSet: new Set(["claude"]) }), { run: ok }));
+ assert.equal(installed.claude.status, "installed");
+ const removed = byKey(await runPluginCommand(
+ planPluginCommand(SPEC, { installedSet: new Set(["claude"]), verb: "remove" }),
+ { run: ok, verb: "remove" },
+ ));
+ assert.equal(removed.claude.status, "removed");
+});
+
+// --- names -------------------------------------------------------------------
+
+test("the default plugin resolves from nothing, and an unknown one does not", () => {
+ assert.equal(resolvePlugin()?.name, "ticker");
+ assert.equal(resolvePlugin("ticker@moshcode")?.name, "ticker", "a qualified id should resolve");
+ assert.equal(resolvePlugin("nonsense"), null);
+});
+
+test("the source is overridable, so an unreleased plugin is installable", () => {
+ assert.equal(marketplaceSource({}), "moshcoder/moshcode");
+ assert.equal(marketplaceSource({ MOSHCODE_PLUGIN_SOURCE: "/tmp/checkout" }), "/tmp/checkout");
+});
+
+// --- the manifests on disk ---------------------------------------------------
+
+test("the shipped marketplace manifest matches the catalog this module fans out", () => {
+ // These two drift apart silently: the manifest is what Claude Code reads, the
+ // catalog is what `/plugin list` prints and what `install` names.
+ const manifest = JSON.parse(fs.readFileSync(new URL("../.claude-plugin/marketplace.json", import.meta.url), "utf8"));
+ assert.equal(manifest.name, MARKETPLACE_NAME);
+ assert.deepEqual(manifest.plugins.map((p) => p.name).sort(), PLUGINS.map((p) => p.name).sort());
+});
+
+test("every plugin the marketplace lists exists, with a manifest and its commands", () => {
+ const manifest = JSON.parse(fs.readFileSync(new URL("../.claude-plugin/marketplace.json", import.meta.url), "utf8"));
+ for (const entry of manifest.plugins) {
+ const dir = new URL(`../${String(entry.source).replace(/^\.\//, "")}/`, import.meta.url);
+ const plugin = JSON.parse(fs.readFileSync(new URL(".claude-plugin/plugin.json", dir), "utf8"));
+ assert.equal(plugin.name, entry.name, `${entry.name}'s manifest disagrees with the marketplace`);
+
+ const catalog = PLUGINS.find((p) => p.name === entry.name);
+ const files = fs.readdirSync(new URL("commands/", dir)).filter((f) => f.endsWith(".md"));
+ assert.deepEqual(
+ files.map((f) => `/${f.replace(/\.md$/, "")}`).sort(),
+ [...catalog.commands].sort(),
+ `${entry.name} advertises commands it does not ship`,
+ );
+ }
+});
+
+test("every shipped command declares a description and parseable frontmatter", () => {
+ // Unparseable frontmatter loads the command with empty metadata — no
+ // description, no allowed-tools — and nothing at runtime says so.
+ const dir = new URL("../plugins/ticker/commands/", import.meta.url);
+ for (const file of fs.readdirSync(dir).filter((f) => f.endsWith(".md"))) {
+ const text = fs.readFileSync(new URL(file, dir), "utf8");
+ const match = text.match(/^---\n([\s\S]*?)\n---\n/);
+ assert.ok(match, `${file} has no frontmatter block`);
+ assert.match(match[1], /^description: \S/m, `${file} has no description`);
+ // A value opening with `[` is a YAML flow sequence, and `[--limit n]` in one
+ // is a parse error that silently drops every field in the block.
+ for (const line of match[1].split("\n")) {
+ const value = line.match(/^[a-z-]+: (.*)$/)?.[1];
+ if (value?.startsWith("[")) assert.fail(`${file}: unquoted "[" in frontmatter — ${line}`);
+ }
+ }
+});
+
+test("pluginId is the form the engine disambiguates with", () => {
+ assert.equal(pluginId("ticker"), "ticker@moshcode");
+});
+
+test("the README documents the install command it actually ships", () => {
+ const readme = fs.readFileSync(`${ROOT}README.md`, "utf8");
+ assert.match(readme, /moshcode plugin install/);
+});