diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..1205359 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "moshcode", + "description": "moshcode's own Claude Code plugins — the pit's slash commands, in your engine", + "owner": { + "name": "moshcoder", + "url": "https://moshcode.sh" + }, + "plugins": [ + { + "name": "ticker", + "description": "Equity research slash commands backed by advis0r.com: scored reports, extracted signals, transcript search, company-name lookup, and ranked watchlists.", + "source": "./plugins/ticker", + "category": "productivity", + "author": { + "name": "moshcoder", + "url": "https://moshcode.sh" + }, + "homepage": "https://github.com/moshcoder/moshcode#ticker", + "keywords": ["stocks", "equity", "research", "markets", "advis0r"] + } + ] +} diff --git a/README.md b/README.md index e74dda3..57ffad9 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ or miss one that does. A test fails the build when it drifts. | `moshcode engines` | engines | list engines and installation status | | `moshcode tools` | tools | list workflow tools and installation status | | `moshcode trade` | tools | look up markets and trade through Alpaca | +| `moshcode ticker`
`advisor` | tools | equity research from advis0r.com | +| `moshcode plugin`
`plugins` | extend | install moshcode's slash commands into Claude Code | | `moshcode commands` | script | list built-in moshscript commands | | `moshcode completion` | extend | print a shell completion script | | `moshcode run` | script | run a moshscript | @@ -211,6 +213,31 @@ Alpaca's CLI has no confirmation prompts; `--submit` intentionally removes MoshCode's preview guard. Live trading additionally requires Alpaca's `--live` opt-in or corresponding environment setting. +### Equity research (`moshcode ticker`) + +Where `trade` is Alpaca's order book, `ticker` is the research desk: +[advis0r.com](https://advis0r.com/api)'s public read-only API, rendered in the +pit. No key, no login, no write routes, no binary to install: + +```sh +moshcode ticker NVDA # score, technicals, fundamentals, thesis, signals +moshcode ticker lookup rivian # company name → RIVN +moshcode ticker signals AAPL # what was said, quoted and sourced +moshcode ticker search "data center" # across every indexed transcript +moshcode ticker reports --limit 10 # the stored index, best score first +moshcode ticker discover fusion # a ranked watchlist (slow — analyzes each candidate) +moshcode ticker open NVDA # the shareable report page +``` + +Add `--json` to any of them for the raw response. The same facade is `/ticker …` +in the pit, and `MOSHCODE_ADVISOR_URL` points it at another instance. + +Reports are **stored snapshots**, not live quotes: every response carries +`reportGeneratedAt` and every renderer prints it, alongside whether the price is +delayed and which feed produced it. Scores labelled `offline` come from +deterministic rules rather than a model. It is a research aid, not advice, and +nothing under `ticker` can place an order. + ### Social posting from the pit The pit can hand a prepared post to Bluesky or Nostr without storing either @@ -305,6 +332,34 @@ from and five to rotate. Porkbun's API access is off by default and enabled per-domain — and its documentation tools work with no keys at all, which is a sensible way to try the server before trusting it with DNS writes. +## Claude Code plugins + +MoshCode publishes its own plugin marketplace, so the pit's slash commands work +inside your engine too: + +```sh +moshcode plugin list # what the marketplace ships, and who can take it +moshcode plugin install # add the marketplace + install `ticker` +moshcode plugin remove ticker # take it back off +``` + +`ticker@moshcode` adds `/ticker`, `/signals`, `/research`, `/lookup`, +`/reports`, and `/discover` — the same advis0r research surface described above, +driven from inside a coding session. Restart the engine afterwards; a newly +installed plugin is not live in a session that is already running. + +The equivalent by hand: + +```sh +claude plugin marketplace add moshcoder/moshcode +claude plugin install ticker@moshcode +``` + +Claude Code is currently the only engine with a plugin primitive. The others are +reported as skipped with a reason, the same way they are for skills, rather than +being left out of the summary. `MOSHCODE_PLUGIN_SOURCE=.` installs from a local +checkout instead of GitHub, which is how you try an unreleased plugin. + ## Upgrade everything ```sh diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs index 410f263..09d8565 100755 --- a/bin/moshcode.mjs +++ b/bin/moshcode.mjs @@ -19,7 +19,9 @@ import { tradeArgs, tradeUsage } from "../src/trade.mjs"; import { runUpgrade } from "../src/upgrade.mjs"; import { selfUpdateCommand } from "../src/selfupdate.mjs"; import { describeUninstall, uninstallPlan } from "../src/uninstall.mjs"; -import { mcpCommand, skillCommand } from "../src/integrations.mjs"; +import { mcpCommand, pluginCommand, skillCommand } from "../src/integrations.mjs"; +import { tickerCommand } from "../src/advisor.mjs"; +import { canOpenBrowser, openBrowser } from "../src/open-url.mjs"; import { locate, tilde } from "../src/pwd.mjs"; import { createPrd, listPrds, authoringPrompt } from "../src/prd.mjs"; import { loginAuto, whoami, logout } from "../src/auth.mjs"; @@ -344,6 +346,18 @@ async function main() { propagateExit(r.code, r.signal); return; } + if (cmd === "ticker" || cmd === "advisor") { + const code = await tickerCommand(rest, { + openUrl: (url) => canOpenBrowser() && openBrowser(url), + }); + if (code) process.exitCode = code; + return; + } + if (cmd === "plugin" || cmd === "plugins") { + const code = await pluginCommand(rest); + if (code) process.exitCode = code; + return; + } if (cmd === "console") { const code = await consoleCommand(rest); if (code) process.exitCode = code; diff --git a/package.json b/package.json index 3e7fb4d..90fd34a 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "src", "examples", "prd", + ".claude-plugin", + "plugins", "install.sh", "README.md" ], diff --git a/plugins/ticker/.claude-plugin/plugin.json b/plugins/ticker/.claude-plugin/plugin.json new file mode 100644 index 0000000..4ccd3e3 --- /dev/null +++ b/plugins/ticker/.claude-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://anthropic.com/claude-code/plugin.schema.json", + "name": "ticker", + "description": "Equity research slash commands backed by advis0r.com: scored reports, extracted signals, transcript search, company-name lookup, and ranked watchlists.", + "version": "0.1.0", + "author": { + "name": "moshcoder", + "url": "https://moshcode.sh" + }, + "homepage": "https://github.com/moshcoder/moshcode#ticker", + "license": "MIT", + "keywords": ["stocks", "equity", "research", "markets", "advis0r"] +} diff --git a/plugins/ticker/README.md b/plugins/ticker/README.md new file mode 100644 index 0000000..a4a25dc --- /dev/null +++ b/plugins/ticker/README.md @@ -0,0 +1,49 @@ +# ticker — equity research in your engine 🤘 + +Slash commands backed by [advis0r.com](https://advis0r.com/api): scored research +reports, extracted signals with sources, transcript search, company-name lookup, +and ranked watchlists. + +| command | what it does | +| --- | --- | +| `/ticker NVDA` | score, technicals, fundamentals, thesis, signals, sources | +| `/signals AAPL` | what was actually said, quoted and sourced | +| `/research data center` | full-text search across every indexed transcript | +| `/lookup rivian` | company name → `RIVN` | +| `/reports` | every stored report, best score first | +| `/discover fusion` | a ranked watchlist for a topic (slow) | + +## Install + +```bash +moshcode plugin install +``` + +Or straight from Claude Code: + +```bash +claude plugin marketplace add moshcoder/moshcode +claude plugin install ticker@moshcode +``` + +Restart the engine afterwards — a newly installed plugin is not live in a +session that is already running. + +## How it works + +Each command shells out to `moshcode ticker …`, which calls advis0r's public, +read-only API. No key, no login, no write routes. With `moshcode` absent, every +command falls back to `curl` against the same endpoints. + +Point the commands at another instance with `MOSHCODE_ADVISOR_URL`. + +## What this is not + +A research aid, not advice. Reports are **stored snapshots** — every response +carries `reportGeneratedAt`, and every command is instructed to print it, because +a stale price presented as a live one is the one failure mode that actually costs +someone money. Scores marked `offline` come from deterministic rules, not a model. + +Trading lives behind a different verb: `moshcode trade` wraps Alpaca, previews +orders by default, and requires an explicit `--submit`. Nothing in this plugin +can place an order. diff --git a/plugins/ticker/commands/discover.md b/plugins/ticker/commands/discover.md new file mode 100644 index 0000000..451f51d --- /dev/null +++ b/plugins/ticker/commands/discover.md @@ -0,0 +1,37 @@ +--- +description: Build a ranked watchlist for a topic (slow — it analyzes each candidate). +argument-hint: "[topic]" +allowed-tools: Bash(moshcode ticker:*), Bash(curl -sS https://advis0r.com/api/:*) +--- + +## Task + +Rank candidates for `$ARGUMENTS` (no topic → the default watchlist). + +```bash +moshcode ticker discover $ARGUMENTS --limit 10 --json +``` + +Fallback: `curl -sS --max-time 180 "https://advis0r.com/api/discover?topic=&provider=offline&horizon=2&limit=10"` + +**This route runs an analysis per candidate and can take minutes.** Tell the +user it is working before you start, and do not retry on a timeout — re-running +it costs the same minutes again. + +## Reading the response + +`candidates` is ranked, each with `rank`, `ticker`, `companyName`, `lastPrice`, +`overallScore`, `confidence`, `classification`, `thesis`, `primaryCatalyst`, +`mainRisk`, `independentConfirmation`, plus liquidity fields +(`bidAskSpreadPercent`, `avgVolume`, `float`, `marketCap`). + +## Rules + +- Lead with `rank`, `ticker`, `overallScore`, and `classification`. +- **Print `mainRisk` next to every thesis.** A ranked list that shows only the + bull case is a pitch, not research. +- `provider: offline` means these scores are deterministic rules, not a model. + Say which provider produced the ranking. +- Flag illiquidity: a wide `bidAskSpreadPercent` or thin `avgVolume` matters + more than the score for anything small-cap. +- End with the response's own `disclaimer`. diff --git a/plugins/ticker/commands/lookup.md b/plugins/ticker/commands/lookup.md new file mode 100644 index 0000000..9a200f8 --- /dev/null +++ b/plugins/ticker/commands/lookup.md @@ -0,0 +1,27 @@ +--- +description: Find a ticker symbol by company name (rivian → RIVN). +argument-hint: +allowed-tools: Bash(moshcode ticker:*), Bash(curl -sS https://advis0r.com/api/:*) +--- + +## Task + +Resolve `$ARGUMENTS` to a ticker symbol. + +```bash +moshcode ticker lookup $ARGUMENTS --limit 10 --json +``` + +Fallback: `curl -sS "https://advis0r.com/api/lookup?q=&limit=10"` + +## Reading the response + +`matches` is a list of `{ symbol, name, exchange, hasReport }`. +`hasReport: true` means advis0r already has a stored research snapshot. + +## Rules + +- Show every match with its exchange — "Delta" is an airline and a faucet company. +- Mark which ones have a report, and offer `/ticker ` for those. +- One unambiguous match: say the symbol and go straight to offering the report. +- No match: say the *directory* has no match, and do not invent a symbol. diff --git a/plugins/ticker/commands/reports.md b/plugins/ticker/commands/reports.md new file mode 100644 index 0000000..fab7476 --- /dev/null +++ b/plugins/ticker/commands/reports.md @@ -0,0 +1,30 @@ +--- +description: Every stored advis0r research report, best score first. +argument-hint: "[--limit n] [--sort recent|score|ticker]" +allowed-tools: Bash(moshcode ticker:*), Bash(curl -sS https://advis0r.com/api/:*) +--- + +## Task + +List the stored reports. + +```bash +moshcode ticker reports $ARGUMENTS --json +``` + +Fallback: `curl -sS "https://advis0r.com/api/reports?sort=score&limit=25"` + +## Reading the response + +`reports` is a list of `{ ticker, companyName, lastPrice, overallScore, +confidence, classification, aiProvider, aiModel, sourceCount, signalCount, +generatedAt }`, and `total` is how many exist. + +## Rules + +- Render as a table: ticker, score, classification, price, generated-at. +- **`generatedAt` per row, always.** These are snapshots taken at different + times; a table that hides that reads as one consistent as-of date. +- A row with no `aiProvider` was scored deterministically, not by a model. +- Offer `/ticker ` for anything worth a closer look. +- This is a coverage list, not a recommendation list. Rank order is score order. diff --git a/plugins/ticker/commands/research.md b/plugins/ticker/commands/research.md new file mode 100644 index 0000000..41d7f40 --- /dev/null +++ b/plugins/ticker/commands/research.md @@ -0,0 +1,29 @@ +--- +description: Full-text search across every indexed earnings transcript and article. +argument-hint: +allowed-tools: Bash(moshcode ticker:*), Bash(curl -sS https://advis0r.com/api/:*) +--- + +## Task + +Search the transcript index for `$ARGUMENTS`. + +```bash +moshcode ticker search $ARGUMENTS --limit 20 --json +``` + +Fallback: `curl -sS "https://advis0r.com/api/search?q=&limit=20"` + +## Reading the response + +`results` is a list of segments: `text`, `speaker`, `ticker`, `event_date`. +The API tries full-text search first and falls back to a substring scan, so a +hit is a hit — but relevance is not ranked. Read before summarizing. + +## Rules + +- Cluster the hits by ticker and say which companies came up, with dates. +- Quote sparingly and attribute each quote to its speaker and ticker. +- If nothing matches, say the *index* has no match — this searches advis0r's + indexed corpus, not the whole web. Suggest `/lookup` if the query looks like + a company name. diff --git a/plugins/ticker/commands/signals.md b/plugins/ticker/commands/signals.md new file mode 100644 index 0000000..182deeb --- /dev/null +++ b/plugins/ticker/commands/signals.md @@ -0,0 +1,30 @@ +--- +description: What was actually said about a ticker — extracted signals with quotes and sources. +argument-hint: +allowed-tools: Bash(moshcode ticker:*), Bash(curl -sS https://advis0r.com/api/:*) +--- + +## Task + +List the extracted signals for `$ARGUMENTS`. + +```bash +moshcode ticker signals $ARGUMENTS --json +``` + +Fallback: `curl -sS "https://advis0r.com/api/signals?ticker=$ARGUMENTS"` + +## Reading the response + +Each signal carries `signal_type`, `direction` (positive/negative/neutral), +`strength`, `specificity`, `quote`, `event_date`, `speaker`, `speaker_title`, +`source_url`, and `source_tier`. + +## Rules + +- Group by direction and lead with the most recent. Note the positive/negative split. +- **Every claim keeps its `source_url`.** These are extracted quotes from + transcripts and articles — a signal repeated without its source is a rumor. +- `strength` and `specificity` are the extractor's confidence, not the market's. + A strong signal from a low `source_tier` is still a low-tier source; say so. +- End with the response's own `disclaimer`. diff --git a/plugins/ticker/commands/ticker.md b/plugins/ticker/commands/ticker.md new file mode 100644 index 0000000..657499f --- /dev/null +++ b/plugins/ticker/commands/ticker.md @@ -0,0 +1,42 @@ +--- +description: Research one ticker — score, technicals, fundamentals, thesis, signals, and sources. +argument-hint: +allowed-tools: Bash(moshcode ticker:*), Bash(curl -sS https://advis0r.com/api/:*) +--- + +## Task + +Pull the stored research report for `$ARGUMENTS` and summarize it for the user. + +Run: + +```bash +moshcode ticker $ARGUMENTS --json +``` + +If `moshcode` is not installed, fall back to the API directly: + +```bash +curl -sS "https://advis0r.com/api/ticker?symbol=$ARGUMENTS" +``` + +## Reading the response + +- `overallScore` (0–100), `confidence`, and `classification` are the headline. A + low score is a *finding*, not a failure to report. +- `aiAnalysis.analysis.thesis` is a hosted-model take; `analysis.thesis` is a + deterministic offline one. Say which you are quoting — they carry different weight. +- `technical` holds rsi14 / sma / macd / atr / relativeVolume. +- `facts` holds SEC fundamentals; `facts.source === "unavailable"` means the + fundamentals section is missing, not that the company has none. +- `signals` are extracted quotes with `direction` and `source_url`. +- `sources` are the documents behind them. + +## Rules + +- **`reportGeneratedAt` is when this snapshot was built.** State it. The price in + a stored report is not a live quote, and must never be presented as one. +- If the response is a 400 with `didYouMean`, the user typed a company name + rather than a symbol — re-run against the suggested symbol and say you did. +- End with the response's own `disclaimer`. This is research, not advice. +- Link the shareable report: `https://advis0r.com/ticker/`. diff --git a/prd/0008-ticker-research-and-plugin-marketplace.md b/prd/0008-ticker-research-and-plugin-marketplace.md new file mode 100644 index 0000000..a0107ba --- /dev/null +++ b/prd/0008-ticker-research-and-plugin-marketplace.md @@ -0,0 +1,130 @@ +--- +openprd: "0.2" +id: "0008" +title: "Bring equity research into the pit, and ship the pit's slash commands as a plugin" +status: Draft +authors: + - anthony@profullstack.com +created: 2026-08-06 +updated: 2026-08-06 +repo: https://github.com/moshcoder/moshcode +discussion: +implementation: +tags: + - research + - plugins + - advis0r +supersedes: +superseded-by: +--- + +## Problem + +`moshcode trade` can look up a quote and place an order. It cannot answer the +question that comes before either one — *is this worth buying?* Everything that +would inform that lives in [advis0r.com](https://advis0r.com/api): indexed +earnings transcripts and news, extracted signals with sources, SEC fundamentals, +technicals, and a composite score. Today that means leaving the pit for a +browser, and it means an agent working in a session has no path to it at all. + +Two separate gaps, one cause: + +1. **In the pit.** There is no research verb. `/trade quote AAPL` returns a + price and nothing about why it is that price. +2. **In the engine.** moshcode already fans MCP servers and Agent Skills out + across engines, but it publishes no commands of its own. A slash command that + exists at the mosh prompt does not exist inside Claude Code, and there is no + mechanism by which it could. + +## Goals + +- Research a ticker without leaving the terminal, in one short command. +- An agent mid-session can pull sourced evidence about a company rather than + recalling it from training data. +- moshcode's own slash commands become installable into the engines it drives, + through the engines' native plugin mechanism rather than a moshcode-specific one. +- A stored snapshot is never mistaken for a live quote. + +## Non-Goals + +- Placing orders. `trade` owns that, with its preview-by-default guard; nothing + under `ticker` writes anything anywhere. +- Reimplementing advis0r. Every route used is public, read-only, and unauthenticated; + scoring, ingestion, and analysis stay server-side. +- Authentication. The routes that need a sign-in (`/api/digest`, + `/api/report/regenerate`) are deliberately out of scope — adding credentials + would make this the first moshcode verb that holds one. +- A general plugin framework. One marketplace, one plugin, extended by adding a + directory. + +## Users + +- Someone in the pit deciding what to look at before opening the trading CLI. +- A coding agent asked about a company, which should cite indexed sources rather + than assert from memory. + +## Requirements + +- R1 [P0] `moshcode ticker ` prints the stored report: score, confidence, + classification, technicals, fundamentals, thesis, recent signals, and sources. +- R2 [P0] Verbs for the rest of the surface: `signals`, `search`, `lookup`, + `reports`, `discover`, `tickers`, `stats`, `open`. A first argument that is not + a verb is a symbol; `report` is the unambiguous spelling for a symbol that + collides with one. +- R3 [P0] Every rendered report states `reportGeneratedAt`, whether the price is + delayed, and which feed produced it. A stored price must never render as a live one. +- R4 [P0] Every substantive response carries the API's own `disclaimer` through + to the output. +- R5 [P0] `--json` on any verb prints the raw response, so scripts and agents get + the full document rather than the rendered subset. +- R6 [P0] The same surface is `/ticker …` at the mosh prompt. +- R7 [P1] A non-symbol argument is refused with the `lookup` that resolves it, + and a 400 carrying `didYouMean` surfaces the suggestion. +- R8 [P0] `.claude-plugin/marketplace.json` publishes a `ticker` plugin providing + `/ticker`, `/signals`, `/research`, `/lookup`, `/reports`, `/discover`. +- R9 [P0] `moshcode plugin install` adds the marketplace and installs the plugin, + fanning out across engines and reporting every engine without a plugin + primitive as skipped — the same contract as 0003 R8 for skills. +- R10 [P1] `MOSHCODE_ADVISOR_URL` and `MOSHCODE_PLUGIN_SOURCE` redirect the API + and the marketplace, so both are testable against a checkout. +- R11 [P1] The verb table in the schema and the parser's own list are checked + against each other, so a verb cannot complete and then fail. + +## UX Notes + +`ticker` renders in-process rather than handing the terminal to a tool, because +unlike every other entry in `tools`, there is no advis0r binary — only an HTTP +API. That makes it the first moshcode verb that formats a remote response itself, +so the rendering rules matter more than usual: + +- Direction is colour: acid for positive signals, red for negative. +- A model-written thesis is labelled with its provider and model; a + deterministic one is labelled `offline`. They carry different weight and must + not look alike. +- Optional sections (fundamentals, technicals, analysis) are genuinely absent + when SEC or the market feed rate-limits. A missing section degrades the report; + it never prevents one. +- `discover` ranks by analyzing each candidate and legitimately takes minutes. It + gets a longer timeout than the row-read routes, and says so up front. + +## Success Metrics + +- `/ticker ` answers in one line of input, with sources. +- The plugin installs into Claude Code and its six commands appear, verified by + `claude plugin validate` and an end-to-end install. +- No rendered output anywhere presents a stored price as a live quote. + +## Risks & Open Questions + +- **Scored equity research one verb away from an order-placing CLI.** The + mitigation is structural rather than advisory: `ticker` has no write path, + `trade` keeps its preview guard, and the disclaimer travels with the data. +- **Snapshot staleness.** advis0r rebuilds a report when it is missing or when a + watchlist member asks; moshcode cannot trigger a rebuild without + authentication. Printing the generated-at stamp is the honest answer, not a + workaround. +- Single upstream: if advis0r is down, the verb is down. Acceptable — it is a + research aid, not a dependency of anything else in moshcode. +- Open: whether `plugin` should later fan out to other engines as they gain + plugin primitives, or stay Claude-specific. The plan structure already allows + the first without a rewrite. diff --git a/prd/README.md b/prd/README.md index bf6deef..0b65bbe 100644 --- a/prd/README.md +++ b/prd/README.md @@ -23,4 +23,5 @@ Start one with `moshcode prd ""` (TUI: `/prd`). | [0005](0005-hosted-moshpit-resolver.md) | A hosted Moshpit resolver, for the devices that cannot run the bridge | Draft | | [0006](0006-help.md) | --help | Draft | | [0007](0007-profullstack-site-init.md) | Generate batteries-included Profullstack sites for Moshpit names | Draft | +| [0008](0008-ticker-research-and-plugin-marketplace.md) | Bring equity research into the pit, and ship the pit's slash commands as a plugin | Draft | diff --git a/src/advisor.mjs b/src/advisor.mjs new file mode 100644 index 0000000..a5c1583 --- /dev/null +++ b/src/advisor.mjs @@ -0,0 +1,588 @@ +// `moshcode ticker` — equity research from advis0r.com, in the pit. +// +// Same split as src/trade.mjs: argument translation is pure and testable, the +// network call is injectable, and rendering is a function of the decoded JSON. +// Nothing here holds credentials — every route this touches is public and +// read-only, which is why there is no login verb and no write verb. +// +// The API is documented at https://advis0r.com/api and returns *stored* +// snapshots: a report carries `reportGeneratedAt`, and every renderer prints it. +// A stale price is fine; a stale price dressed up as a live one is not. +import { acid, ash, amber, bone, danger, dim } from "./ui.mjs"; + +export const DEFAULT_ADVISOR_URL = "https://advis0r.com"; + +/** The advis0r base URL, overridable for a local instance or a test server. */ +export function advisorBase(env = process.env) { + const raw = String(env.MOSHCODE_ADVISOR_URL || DEFAULT_ADVISOR_URL).trim(); + return (raw || DEFAULT_ADVISOR_URL).replace(/\/+$/, ""); +} + +const USAGE = `usage: moshcode ticker [args…] + + the stored research report for one ticker + report same thing, when a symbol looks like a verb + signals every extracted signal for a ticker + search full-text search across indexed transcripts + lookup find a ticker by company name (rivian → RIVN) + reports every stored report, best score first + discover [topic…] a ranked watchlist for a topic + tickers every ticker present in the index + stats index coverage counts + open open the shareable report page in a browser + + --json print the raw API response + --limit cap results (search/lookup/reports/discover) + --sort order reports (default: score) + --horizon <1|2> discover: quarters to look ahead (default: 2) + --provider discover: analysis provider (default: offline) + +Research aid, not advice. Every route is public, read-only, and served from +stored snapshots — see the generated-at stamp printed with each report.`; + +export function tickerUsage() { + return USAGE; +} + +/** Verb names, in help order. cli-schema's TICKER_VERBS must match (drift test). */ +export const TICKER_VERB_NAMES = [ + "report", "signals", "search", "lookup", "reports", "discover", "tickers", "stats", "open", +]; + +// Aliases exist because muscle memory differs: `/ticker news AAPL` and +// `/ticker quotes AAPL` should not be errors when the intent is obvious. +const VERB_ALIASES = { + signal: "signals", news: "signals", + find: "search", grep: "search", q: "search", + symbol: "lookup", company: "lookup", name: "lookup", + index: "reports", list: "reports", + watchlist: "discover", rank: "discover", + symbols: "tickers", + coverage: "stats", status: "stats", + browse: "open", www: "open", web: "open", + detail: "report", quote: "report", snapshot: "report", +}; + +/** Resolve a first argument to a canonical verb, or null when it is a symbol. */ +export function resolveVerb(word) { + const key = String(word ?? "").toLowerCase(); + if (TICKER_VERB_NAMES.includes(key)) return key; + return VERB_ALIASES[key] ?? null; +} + +/** + * A ticker symbol as the API will accept it, or null. + * + * Deliberately narrow — 1-6 letters with an optional class suffix (BRK.B) — + * because the whole point of the check is to tell "AAPL" from "rivian", and + * send the second one to /api/lookup with a useful message instead of a 400. + */ +export function normalizeSymbol(input) { + const raw = String(input ?? "").trim().toUpperCase(); + return /^[A-Z]{1,6}(?:[.-][A-Z]{1,2})?$/.test(raw) ? raw : null; +} + +function takeFlag(args, name, { boolean = false } = {}) { + const out = { value: null, rest: [], missing: false, present: false }; + for (let i = 0; i < args.length; i++) { + const arg = String(args[i]); + if (arg === name) { + out.present = true; + if (boolean) continue; + const next = args[i + 1]; + if (next == null || String(next).startsWith("-")) out.missing = true; + else { out.value = String(next); i++; } + continue; + } + if (!boolean && arg.startsWith(`${name}=`)) { + out.present = true; + const value = arg.slice(name.length + 1); + if (value === "") out.missing = true; else out.value = value; + continue; + } + out.rest.push(arg); + } + return out; +} + +function positiveInt(value, { max }) { + const n = Number(value); + if (!Number.isInteger(n) || n < 1) return null; + return Math.min(n, max); +} + +const SORTS = ["recent", "score", "ticker"]; + +/** + * Translate `ticker` arguments into a request the caller can execute. + * + * Returns one of `{ usage }`, `{ error }`, or + * `{ verb, path, query, json, open? }` — never performs IO, so the whole + * argument surface is testable without a network. + */ +export function tickerArgs(input = []) { + const args = input.map(String); + const jsonFlag = takeFlag(args, "--json", { boolean: true }); + let rest = jsonFlag.rest; + const json = jsonFlag.present; + + const limitFlag = takeFlag(rest, "--limit"); rest = limitFlag.rest; + const sortFlag = takeFlag(rest, "--sort"); rest = sortFlag.rest; + const horizonFlag = takeFlag(rest, "--horizon"); rest = horizonFlag.rest; + const providerFlag = takeFlag(rest, "--provider"); rest = providerFlag.rest; + + if (limitFlag.missing) return { error: "ticker --limit requires a positive number" }; + if (sortFlag.missing) return { error: `ticker --sort requires one of ${SORTS.join(", ")}` }; + if (horizonFlag.missing) return { error: "ticker --horizon requires 1 or 2" }; + if (providerFlag.missing) return { error: "ticker --provider requires a name" }; + + // A limit above the server's own cap is silently clamped there; clamping here + // too keeps `--limit 9999` from reading like a promise the API never made. + const limit = limitFlag.value == null ? null : positiveInt(limitFlag.value, { max: 50 }); + if (limitFlag.value != null && limit == null) { + return { error: "ticker --limit requires a positive number" }; + } + if (sortFlag.value != null && !SORTS.includes(sortFlag.value.toLowerCase())) { + return { error: `ticker --sort must be one of ${SORTS.join(", ")}` }; + } + if (horizonFlag.value != null && !["1", "2"].includes(String(horizonFlag.value))) { + return { error: "ticker --horizon must be 1 or 2" }; + } + + const stray = rest.find((arg) => arg.startsWith("-") && arg !== "-"); + if (stray) return { error: `unknown ticker flag ${JSON.stringify(stray)}` }; + + const [first, ...tail] = rest; + if (!first) return { usage: true }; + + const verb = resolveVerb(first); + const words = verb ? tail : rest; + + // No verb → the first word is the ticker. `/ticker AAPL` is the headline + // case and must stay the shortest thing anyone types. + const wantsReport = verb == null || verb === "report" || verb === "open"; + if (wantsReport) { + const raw = words[0]; + if (!raw) return { error: `ticker ${verb === "open" ? "open" : "report"} requires a ticker symbol` }; + const symbol = normalizeSymbol(raw); + if (!symbol) { + return { + error: `${JSON.stringify(String(raw))} is not a ticker symbol — try: moshcode ticker lookup ${String(raw)}`, + }; + } + if (verb === "open") return { verb: "open", symbol, open: `/ticker/${encodeURIComponent(symbol)}`, json }; + return { verb: "report", symbol, path: "/api/ticker", query: { symbol }, json }; + } + + if (verb === "signals") { + const symbol = normalizeSymbol(words[0]); + if (!words[0]) return { error: "ticker signals requires a ticker symbol" }; + if (!symbol) { + return { error: `${JSON.stringify(String(words[0]))} is not a ticker symbol — try: moshcode ticker lookup ${words[0]}` }; + } + return { verb, symbol, path: "/api/signals", query: { ticker: symbol }, json }; + } + + if (verb === "search" || verb === "lookup") { + const q = words.join(" ").trim(); + if (!q) return { error: `ticker ${verb} requires something to look for` }; + const path = verb === "search" ? "/api/search" : "/api/lookup"; + return { verb, path, query: { q, ...(limit ? { limit: String(limit) } : {}) }, json }; + } + + if (verb === "reports") { + return { + verb, + path: "/api/reports", + query: { + sort: (sortFlag.value || "score").toLowerCase(), + ...(limit ? { limit: String(limit) } : {}), + }, + json, + }; + } + + if (verb === "discover") { + const topic = words.join(" ").trim(); + return { + verb, + path: "/api/discover", + query: { + ...(topic ? { topic } : {}), + provider: providerFlag.value || "offline", + horizon: String(horizonFlag.value || 2), + ...(limit ? { limit: String(limit) } : {}), + }, + json, + slow: true, + }; + } + + if (verb === "tickers") return { verb, path: "/api/tickers", query: {}, json }; + if (verb === "stats") return { verb, path: "/api/stats", query: {}, json }; + + return { error: `unknown ticker command ${JSON.stringify(String(first))}` }; +} + +/** Build the absolute URL for a translated request. */ +export function advisorUrl(request, { base = advisorBase() } = {}) { + const url = new URL((request.path || request.open || "/"), `${base}/`); + for (const [k, v] of Object.entries(request.query || {})) { + if (v != null && v !== "") url.searchParams.set(k, String(v)); + } + return url.toString(); +} + +/** + * Execute a translated request. `fetchImpl` is injectable for tests. + * + * `/api/discover` ranks candidates by running an analysis per ticker, so it can + * legitimately take a minute; every other route is a row read. One timeout for + * both would either abort discover or hang forever on a wedged connection. + */ +export async function fetchAdvisor(request, { fetchImpl = globalThis.fetch, base = advisorBase(), timeoutMs } = {}) { + const url = advisorUrl(request, { base }); + const ms = timeoutMs ?? (request.slow ? 180_000 : 45_000); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), ms); + try { + const res = await fetchImpl(url, { + signal: controller.signal, + headers: { accept: "application/json", "user-agent": "moshcode-ticker" }, + }); + const text = await res.text(); + let data; + try { data = JSON.parse(text); } catch { data = null; } + if (data == null) { + return { ok: false, status: res.status, url, error: `advis0r returned ${res.status} and not JSON` }; + } + return { ok: res.ok, status: res.status, url, data }; + } catch (e) { + const reason = e?.name === "AbortError" ? `timed out after ${Math.round(ms / 1000)}s` : (e?.message || String(e)); + return { ok: false, status: 0, url, error: `advis0r request failed: ${reason}` }; + } finally { + clearTimeout(timer); + } +} + +// ---------------------------------------------------------------- rendering + +const num = (v, digits = 2) => + v == null || !Number.isFinite(Number(v)) ? null : Number(v).toFixed(digits).replace(/\.00$/, ""); + +const money = (v) => + num(v) == null + ? "—" + : `$${Number(v).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + +function compact(v) { + const n = Number(v); + if (!Number.isFinite(n)) return null; + const units = [[1e12, "T"], [1e9, "B"], [1e6, "M"], [1e3, "K"]]; + for (const [size, suffix] of units) { + if (Math.abs(n) >= size) return `${(n / size).toFixed(2).replace(/\.?0+$/, "")}${suffix}`; + } + return String(n); +} + +const day = (v) => (v ? String(v).slice(0, 10) : "—"); + +function clip(text, width) { + const s = String(text ?? "").replace(/\s+/g, " ").trim(); + return s.length <= width ? s : `${s.slice(0, Math.max(1, width - 1))}…`; +} + +/** Direction → color, so a wall of signals is skimmable. */ +function tone(direction) { + if (direction === "positive") return acid; + if (direction === "negative") return danger; + return ash; +} + +function scoreTone(score) { + const n = Number(score); + if (!Number.isFinite(n)) return ash; + if (n >= 60) return acid; + if (n >= 40) return amber; + return danger; +} + +function reportHeader(d) { + const lines = []; + const name = d.companyName && d.companyName !== d.ticker ? ` ${bone(d.companyName)}` : ""; + lines.push(` ${acid(d.ticker)}${name}${d.exchange ? ash(` ${d.exchange}`) : ""}`); + const asOf = day(d.priceTimestamp); + const feed = [d.delayed === false ? "live" : "delayed", d.marketSource].filter(Boolean).join(" · "); + lines.push(` ${bone(money(d.lastPrice))} ${ash(`${feed} · ${asOf}`)}`); + return lines; +} + +function renderReport(d, { width }) { + const lines = ["", ...reportHeader(d), ""]; + + if (d.overallScore != null) { + const paint = scoreTone(d.overallScore); + const bits = [ + `${paint(`score ${num(d.overallScore, 1)}`)}${ash("/100")}`, + d.confidence == null ? null : ash(`confidence ${num(d.confidence, 1)}%`), + d.classification ? bone(d.classification) : null, + ].filter(Boolean); + lines.push(` ${bits.join(ash(" "))}`); + } + + const t = d.technical; + if (t) { + const parts = [ + t.rsi14 == null ? null : `rsi14 ${num(t.rsi14, 1)}`, + t.sma?.[50] == null ? null : `sma50 ${num(t.sma[50])}`, + t.sma?.[200] == null ? null : `sma200 ${num(t.sma[200])}`, + t.atr14 == null ? null : `atr ${num(t.atr14)}`, + t.relativeVolume == null ? null : `rvol ${num(t.relativeVolume)}`, + ].filter(Boolean); + if (parts.length) lines.push(` ${ash("technical")} ${parts.join(ash(" · "))}`); + } + + const f = d.facts; + if (f && f.source !== "unavailable") { + const parts = [ + f.marketCap == null ? null : `cap ${compact(f.marketCap)}`, + f.revenue == null ? null : `rev ${compact(f.revenue)}`, + f.revenueGrowth == null ? null : `growth ${num(f.revenueGrowth, 1)}%`, + f.freeCashFlow == null ? null : `fcf ${compact(f.freeCashFlow)}`, + f.totalDebt == null ? null : `debt ${compact(f.totalDebt)}`, + ].filter(Boolean); + if (parts.length) lines.push(` ${ash("fundamentals")} ${parts.join(ash(" · "))}`); + } + + // The hosted-model take when one has been paid for, the deterministic one + // otherwise — labelled either way, because "an LLM said so" and "a rule fired" + // deserve different amounts of trust. + const ai = d.aiAnalysis; + const thesis = ai?.analysis?.thesis || d.analysis?.thesis; + if (thesis) { + const label = ai ? `${ai.provider}${ai.model ? `/${ai.model}` : ""}` : "offline"; + lines.push("", ` ${ash(`thesis (${label})`)}`); + for (const line of wrapText(thesis, width - 4)) lines.push(` ${bone(line)}`); + } + + const signals = Array.isArray(d.signals) ? d.signals : []; + if (signals.length) { + const pos = signals.filter((s) => s.direction === "positive").length; + const neg = signals.filter((s) => s.direction === "negative").length; + lines.push("", ` ${ash("signals")} ${acid(`${pos} positive`)} ${ash("·")} ${danger(`${neg} negative`)} ${ash(`· ${signals.length} total`)}`); + for (const s of signals.slice(0, 5)) { + const paint = tone(s.direction); + lines.push(` ${paint("•")} ${ash(day(s.event_date))} ${bone(String(s.signal_type ?? "signal"))} ${ash(clip(s.quote, Math.max(20, width - 40)))}`); + } + } + + const sources = Array.isArray(d.sources) ? d.sources : []; + if (sources.length) { + lines.push("", ` ${ash("sources")} ${bone(String(sources.length))}`); + for (const s of sources.slice(0, 4)) { + lines.push(` ${ash(day(s.publishedAt))} ${bone(clip(s.title, Math.max(20, width - 24)))}`); + lines.push(` ${dim(clip(s.url, width - 8))}`); + } + } + + lines.push("", ` ${ash("report")} ${acid(`${advisorBase()}/ticker/${d.ticker}`)}`); + if (d.reportGeneratedAt) { + lines.push(` ${ash(`snapshot generated ${d.reportGeneratedAt}${d.cached ? " (cached)" : ""}`)}`); + } + if (d.marketError) lines.push(` ${amber(`market data unavailable: ${clip(d.marketError, width - 30)}`)}`); + if (d.factsError) lines.push(` ${amber(`fundamentals unavailable: ${clip(d.factsError, width - 30)}`)}`); + lines.push("", ...disclaimerLines(d, width)); + return lines.join("\n"); +} + +function wrapText(text, width) { + const words = String(text).replace(/\s+/g, " ").trim().split(" "); + const lines = []; + let line = ""; + for (const word of words) { + if (line && line.length + word.length + 1 > width) { lines.push(line); line = word; } + else line = line ? `${line} ${word}` : word; + } + if (line) lines.push(line); + return lines; +} + +/** + * The API ships a disclaimer with every substantive response. Printing it is + * not decoration — this surface renders scored equity research in a terminal + * next to a broker CLI that can place orders. + */ +function disclaimerLines(d, width) { + const text = d?.disclaimer; + if (!text) return []; + return wrapText(text, width - 4).map((line) => ` ${dim(line)}`); +} + +function renderSignals(d, { width }) { + const signals = Array.isArray(d.signals) ? d.signals : []; + if (!signals.length) return ` ${ash(`no signals indexed for ${d.ticker}`)}`; + const lines = ["", ` ${acid(d.ticker)} ${ash(`${signals.length} signals`)}`, ""]; + for (const s of signals.slice(0, 40)) { + const paint = tone(s.direction); + const strength = s.strength == null ? "" : ash(` ${num(s.strength)}`); + lines.push(` ${paint("•")} ${ash(day(s.event_date))} ${bone(String(s.signal_type ?? "signal"))}${strength}`); + if (s.speaker) lines.push(` ${ash(`${s.speaker}${s.speaker_title ? `, ${s.speaker_title}` : ""}`)}`); + if (s.quote) for (const line of wrapText(s.quote, width - 6)) lines.push(` ${dim(line)}`); + if (s.source_url) lines.push(` ${dim(clip(s.source_url, width - 6))}`); + lines.push(""); + } + if (signals.length > 40) lines.push(` ${ash(`… ${signals.length - 40} more`)}`, ""); + lines.push(...disclaimerLines(d, width)); + return lines.join("\n"); +} + +function renderSearch(d, { width }) { + const results = Array.isArray(d.results) ? d.results : []; + if (!results.length) return ` ${ash(`nothing indexed matches ${JSON.stringify(String(d.query ?? ""))}`)}`; + const lines = ["", ` ${ash(`${results.length} hits for`)} ${bone(String(d.query ?? ""))}`, ""]; + for (const r of results) { + const head = [r.ticker ? acid(String(r.ticker)) : null, r.speaker ? bone(String(r.speaker)) : null, ash(day(r.event_date))] + .filter(Boolean).join(ash(" · ")); + lines.push(` ${head}`); + for (const line of wrapText(r.text, width - 6)) lines.push(` ${dim(line)}`); + lines.push(""); + } + return lines.join("\n"); +} + +function renderLookup(d) { + const matches = Array.isArray(d.matches) ? d.matches : []; + if (!matches.length) return ` ${ash(`no ticker matches ${JSON.stringify(String(d.query ?? ""))}`)}`; + const lines = ["", ` ${ash("matches for")} ${bone(String(d.query ?? ""))}`, ""]; + for (const m of matches) { + const report = m.hasReport ? acid(" ✓ report") : ash(" · no report yet"); + lines.push(` ${acid(String(m.symbol).padEnd(8))}${bone(clip(m.name, 44).padEnd(46))}${ash(String(m.exchange ?? ""))}${report}`); + } + lines.push("", ` ${ash("then:")} ${bone(`moshcode ticker ${matches[0].symbol}`)}`); + return lines.join("\n"); +} + +function renderReports(d) { + const reports = Array.isArray(d.reports) ? d.reports : []; + if (!reports.length) return ` ${ash("no stored reports yet")}`; + const lines = ["", ` ${ash(`${d.total ?? reports.length} stored reports`)}`, ""]; + for (const r of reports) { + const paint = scoreTone(r.overallScore); + lines.push( + ` ${acid(String(r.ticker).padEnd(7))}${paint(String(num(r.overallScore, 1) ?? "—").padStart(5))}` + + `${ash("/100")} ${ash(clip(r.classification ?? "", 21).padEnd(22))}` + + `${bone(money(r.lastPrice).padStart(11))} ${ash(clip(r.companyName, 28).padEnd(29))}${ash(day(r.generatedAt))}`, + ); + } + lines.push("", ` ${ash("detail:")} ${bone(`moshcode ticker ${reports[0].ticker}`)}`); + return lines.join("\n"); +} + +function renderDiscover(d, { width }) { + const ranked = Array.isArray(d.candidates) ? d.candidates : Array.isArray(d.ranked) ? d.ranked : []; + if (!ranked.length) return ` ${ash("nothing ranked for that topic")}`; + const provenance = [d.topic, d.provider, d.horizonQuarters ? `${d.horizonQuarters}q horizon` : null] + .filter(Boolean).join(" · "); + const lines = ["", ` ${ash(`ranked watchlist${provenance ? ` · ${provenance}` : ""}`)}`, ""]; + for (const c of ranked) { + const score = c.overallScore ?? c.score; + lines.push( + ` ${ash(String(c.rank ?? "").padStart(2))} ${acid(String(c.ticker).padEnd(7))}` + + `${scoreTone(score)(String(num(score, 1) ?? "—").padStart(5))}${ash("/100")} ` + + `${bone(money(c.lastPrice).padStart(10))} ${ash(clip(c.classification ?? "", 22).padEnd(23))}` + + `${bone(clip(c.companyName ?? "", 28))}`, + ); + if (c.thesis) for (const line of wrapText(c.thesis, width - 8)) lines.push(` ${dim(line)}`); + if (c.mainRisk) lines.push(` ${amber("risk")} ${dim(clip(c.mainRisk, width - 12))}`); + lines.push(""); + } + lines.push("", ...disclaimerLines(d, width)); + return lines.join("\n"); +} + +function renderTickers(d) { + const rows = Array.isArray(d.tickers) ? d.tickers : []; + if (!rows.length) return ` ${ash("the index is empty")}`; + const lines = ["", ` ${ash(`${rows.length} tickers in the index`)}`, ""]; + const cells = rows.map((r) => `${acid(String(r.ticker).padEnd(7))}${ash(String(r.n ?? "").padStart(5))}`); + for (let i = 0; i < cells.length; i += 4) lines.push(` ${cells.slice(i, i + 4).join(" ")}`); + return lines.join("\n"); +} + +function renderStats(d) { + const rows = [ + ["documents", d.documents], ["news documents", d.news_documents], + ["transcripts", d.transcripts], ["signals (usable)", d.signals_usable], + ["signals (boilerplate)", d.signals_boilerplate], ["analyses", d.analyses], + ["market bars", d.market_bars], + ].filter(([, v]) => v != null); + const lines = ["", ` ${ash("advis0r index coverage")}`, ""]; + for (const [label, value] of rows) { + lines.push(` ${ash(String(label).padEnd(24))}${bone(Number(value).toLocaleString("en-US"))}`); + } + return lines.join("\n"); +} + +/** Render a decoded API response for one verb. */ +export function renderAdvisor(verb, data, { columns } = {}) { + const width = Math.max(48, Math.min(Number(columns) || 88, 100)); + switch (verb) { + case "report": return renderReport(data, { width }); + case "signals": return renderSignals(data, { width }); + case "search": return renderSearch(data, { width }); + case "lookup": return renderLookup(data, { width }); + case "reports": return renderReports(data, { width }); + case "discover": return renderDiscover(data, { width }); + case "tickers": return renderTickers(data); + case "stats": return renderStats(data); + default: return JSON.stringify(data, null, 2); + } +} + +/** + * Run a `ticker` invocation end to end. Returns a process exit code. + * + * `deps` exists so tests drive the whole command — parse, fetch, render — with + * no network and no stdout. + */ +export async function tickerCommand(argv = [], deps = {}) { + const { + out = (s) => console.log(s), + fail = (s) => console.error(s), + fetchImpl, + base = advisorBase(), + openUrl, + columns = process.stdout.columns, + } = deps; + + const request = tickerArgs(argv); + if (request.usage) { out(tickerUsage()); return 0; } + if (request.error) { fail(danger(`✗ ${request.error}`)); return 1; } + + if (request.verb === "open") { + const url = advisorUrl(request, { base }); + if (request.json) { out(JSON.stringify({ url }, null, 2)); return 0; } + const opened = openUrl ? openUrl(url) : false; + out(opened ? `${acid("✓ ")}opened ${bone(url)}` : `${ash("· ")}open this in a browser:\n ${acid(url)}`); + return 0; + } + + const res = await fetchAdvisor(request, { fetchImpl, base }); + if (res.error) { fail(danger(`✗ ${res.error}`)); return 1; } + + // The API's own error bodies are more useful than any message invented here: + // a bad symbol comes back with a didYouMean and a lookup URL. + if (!res.ok) { + const message = res.data?.error || `advis0r returned ${res.status}`; + if (request.json) { out(JSON.stringify(res.data, null, 2)); return 1; } + fail(danger(`✗ ${message}`)); + if (res.data?.didYouMean?.symbol) { + fail(` ${ash("try:")} ${bone(`moshcode ticker ${res.data.didYouMean.symbol}`)}`); + } + return 1; + } + + if (request.json) { out(JSON.stringify(res.data, null, 2)); return 0; } + out(renderAdvisor(request.verb, res.data, { columns })); + return 0; +} diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 7a46e2b..8537bfb 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -289,6 +289,48 @@ export const CORE_CLI_COMMANDS = [ seeAlso: ["tools", "install"], note: "buy/sell inject --dry-run unless --submit is present. Alpaca defaults to paper trading; live trading requires its separate --live opt-in.", }, + { + name: "ticker", + group: "tools", + description: "equity research from advis0r.com", + synopsis: [ + ["moshcode ticker ", "the stored research report for one ticker"], + ["moshcode ticker [args…]", ""], + ], + verbs: "TICKER_VERBS", + flags: [ + ["--json", "print the raw API response", ""], + ["--limit ", "cap results (search/lookup/reports/discover)", "the API's own default"], + ["--sort ", "reports order: recent | score | ticker", "score"], + ["--horizon ", "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/); +});