diff --git a/README.md b/README.md index 88394e7..b2a0480 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ 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 stocks`
`advisor` | tools | equity research from advis0r.com | | `moshcode crypto`
`coins` | tools | crypto market data from advis0r.com | | `moshcode plugin`
`plugins` | extend | install moshcode's slash commands into Claude Code | | `moshcode commands` | script | list built-in moshscript commands | @@ -214,34 +214,34 @@ 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`) +### Equity research (`moshcode stocks`) -Where `trade` is Alpaca's order book, `ticker` is the research desk: +Where `trade` is Alpaca's order book, `stocks` 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 +moshcode stocks NVDA # score, technicals, fundamentals, thesis, signals +moshcode stocks lookup rivian # company name → RIVN +moshcode stocks signals AAPL # what was said, quoted and sourced +moshcode stocks search "data center" # across every indexed transcript +moshcode stocks reports --limit 10 # the stored index, best score first +moshcode stocks discover fusion # a ranked watchlist (slow — analyzes each candidate) +moshcode stocks open NVDA # the shareable report page ``` -Add `--json` to any of them for the raw response. The same facade is `/ticker …` +Add `--json` to any of them for the raw response. The same facade is `/stocks …` 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. +nothing under `stocks` can place an order. ### Crypto market data (`moshcode crypto`) -`crypto` is `ticker`'s sibling on the same host: advis0r's read-only crypto +`crypto` is `stocks`'s sibling on the same host: advis0r's read-only crypto routes over Alpaca's US crypto venue, which trades 24/7 and needs no extra subscription. @@ -260,7 +260,7 @@ Pairs are accepted as `BTC`, `BTC-USD`, `BTC/USD` or `BTCUSD` — a bare asset resolves to that asset's USD pair. `--json` gives the raw response, `/crypto …` is the same facade in the pit, and `MOSHCODE_ADVISOR_URL` points it elsewhere. -Unlike a `ticker` report, this is a **live venue read**, not a stored snapshot — +Unlike a `stocks` report, this is a **live venue read**, not a stored snapshot — there are no transcripts, no filings and no signals behind a crypto pair, and the failure mode runs the other way: the price is accurate to the second and stale by the time you act on it. Every response stamps when it was fetched. @@ -268,7 +268,7 @@ stale by the time you act on it. Every response stamps when it was fetched. The technical score counts venue-local liquidity, so it is **not comparable** to an equity's score, and each response ships the `caveats` that say so. Prices are Alpaca's US venue alone and can differ materially from other exchanges. Research -aid, not advice — and like `ticker`, nothing under `crypto` can place an order. +aid, not advice — and like `stocks`, nothing under `crypto` can place an order. ### Social posting from the pit @@ -376,7 +376,7 @@ moshcode plugin install crypto # add the marketplace + install `crypto` moshcode plugin remove ticker # take it back off ``` -`ticker@moshcode` adds `/ticker`, `/signals`, `/research`, `/lookup`, +`ticker@moshcode` adds `/stocks`, `/signals`, `/research`, `/lookup`, `/reports`, and `/discover` — the same advis0r research surface described above, driven from inside a coding session. diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs index b06d8bb..2c864ac 100755 --- a/bin/moshcode.mjs +++ b/bin/moshcode.mjs @@ -20,7 +20,7 @@ import { runUpgrade } from "../src/upgrade.mjs"; import { selfUpdateCommand } from "../src/selfupdate.mjs"; import { describeUninstall, uninstallPlan } from "../src/uninstall.mjs"; import { mcpCommand, pluginCommand, skillCommand } from "../src/integrations.mjs"; -import { tickerCommand } from "../src/advisor.mjs"; +import { stocksCommand } from "../src/advisor.mjs"; import { cryptoCommand } from "../src/crypto.mjs"; import { canOpenBrowser, openBrowser } from "../src/open-url.mjs"; import { locate, tilde } from "../src/pwd.mjs"; @@ -347,8 +347,8 @@ async function main() { propagateExit(r.code, r.signal); return; } - if (cmd === "ticker" || cmd === "advisor") { - const code = await tickerCommand(rest, { + if (cmd === "stocks" || cmd === "advisor") { + const code = await stocksCommand(rest, { openUrl: (url) => canOpenBrowser() && openBrowser(url), }); if (code) process.exitCode = code; diff --git a/plugins/crypto/README.md b/plugins/crypto/README.md index c1af924..8a7b818 100644 --- a/plugins/crypto/README.md +++ b/plugins/crypto/README.md @@ -41,10 +41,10 @@ command falls back to `curl` against the same endpoints. Point the commands at another instance with `MOSHCODE_ADVISOR_URL`. -## Why this is separate from `ticker` +## Why this is separate from `stocks` They answer different questions from different data, and share only a hostname. -A `/ticker` report is a **stored snapshot** built from transcripts, SEC +A `/stocks` report is a **stored snapshot** built from transcripts, SEC fundamentals and extracted signals — its risk is a stale price read as a live one. A `/crypto` report is a **live venue read** with no transcripts, no filings and no signals — its risk is the opposite: a price that is accurate to diff --git a/plugins/crypto/commands/crypto.md b/plugins/crypto/commands/crypto.md index d8197ea..aa3b678 100644 --- a/plugins/crypto/commands/crypto.md +++ b/plugins/crypto/commands/crypto.md @@ -42,9 +42,9 @@ resolves to that asset's USD pair. - Crypto trades 24/7 with no circuit breakers and no market close. Never describe a crypto price as "at the close" or "premarket". - State `generatedAt` / `fetchedAt`. This is a live read, so it goes stale in - seconds, not days — the opposite failure mode from a stored `/ticker` report. + seconds, not days — the opposite failure mode from a stored `/stocks` report. - The score's liquidity component counts venue-local volume only, so it is - **not comparable** to an equity's score from `/ticker`. Do not rank the two + **not comparable** to an equity's score from `/stocks`. Do not rank the two against each other. - End with the response's own `disclaimer`. This is research, not advice. - Link the shareable page: `https://advis0r.com/crypto/`. diff --git a/plugins/ticker/README.md b/plugins/ticker/README.md index a4a25dc..6853767 100644 --- a/plugins/ticker/README.md +++ b/plugins/ticker/README.md @@ -6,13 +6,19 @@ and ranked watchlists. | command | what it does | | --- | --- | -| `/ticker NVDA` | score, technicals, fundamentals, thesis, signals, sources | +| `/stocks 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) | +The headline command is `/stocks`, not `/ticker` — it was renamed so the verb +says what it covers, now that `/crypto` sits beside it. **The plugin itself is +still `ticker@moshcode`**, deliberately: renaming the plugin would force +everyone who already installed it to uninstall and reinstall, and an id is not +worth that. Existing installs pick `/stocks` up on the next plugin update. + ## Install ```bash @@ -31,7 +37,7 @@ session that is already running. ## How it works -Each command shells out to `moshcode ticker …`, which calls advis0r's public, +Each command shells out to `moshcode stocks …`, 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. diff --git a/plugins/ticker/commands/discover.md b/plugins/ticker/commands/discover.md index 451f51d..3e18b1a 100644 --- a/plugins/ticker/commands/discover.md +++ b/plugins/ticker/commands/discover.md @@ -1,7 +1,7 @@ --- 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/:*) +allowed-tools: Bash(moshcode stocks:*), Bash(curl -sS https://advis0r.com/api/:*) --- ## Task @@ -9,7 +9,7 @@ allowed-tools: Bash(moshcode ticker:*), Bash(curl -sS https://advis0r.com/api/:* Rank candidates for `$ARGUMENTS` (no topic → the default watchlist). ```bash -moshcode ticker discover $ARGUMENTS --limit 10 --json +moshcode stocks discover $ARGUMENTS --limit 10 --json ``` Fallback: `curl -sS --max-time 180 "https://advis0r.com/api/discover?topic=&provider=offline&horizon=2&limit=10"` diff --git a/plugins/ticker/commands/lookup.md b/plugins/ticker/commands/lookup.md index 9a200f8..93dec82 100644 --- a/plugins/ticker/commands/lookup.md +++ b/plugins/ticker/commands/lookup.md @@ -1,7 +1,7 @@ --- description: Find a ticker symbol by company name (rivian → RIVN). argument-hint: -allowed-tools: Bash(moshcode ticker:*), Bash(curl -sS https://advis0r.com/api/:*) +allowed-tools: Bash(moshcode stocks:*), Bash(curl -sS https://advis0r.com/api/:*) --- ## Task @@ -9,7 +9,7 @@ allowed-tools: Bash(moshcode ticker:*), Bash(curl -sS https://advis0r.com/api/:* Resolve `$ARGUMENTS` to a ticker symbol. ```bash -moshcode ticker lookup $ARGUMENTS --limit 10 --json +moshcode stocks lookup $ARGUMENTS --limit 10 --json ``` Fallback: `curl -sS "https://advis0r.com/api/lookup?q=&limit=10"` @@ -22,6 +22,6 @@ Fallback: `curl -sS "https://advis0r.com/api/lookup?q=&limit=10"` ## 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. +- Mark which ones have a report, and offer `/stocks ` 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 index fab7476..862bca0 100644 --- a/plugins/ticker/commands/reports.md +++ b/plugins/ticker/commands/reports.md @@ -1,7 +1,7 @@ --- 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/:*) +allowed-tools: Bash(moshcode stocks:*), Bash(curl -sS https://advis0r.com/api/:*) --- ## Task @@ -9,7 +9,7 @@ allowed-tools: Bash(moshcode ticker:*), Bash(curl -sS https://advis0r.com/api/:* List the stored reports. ```bash -moshcode ticker reports $ARGUMENTS --json +moshcode stocks reports $ARGUMENTS --json ``` Fallback: `curl -sS "https://advis0r.com/api/reports?sort=score&limit=25"` @@ -26,5 +26,5 @@ generatedAt }`, and `total` is how many exist. - **`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. +- Offer `/stocks ` 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 index 41d7f40..3abd48e 100644 --- a/plugins/ticker/commands/research.md +++ b/plugins/ticker/commands/research.md @@ -1,7 +1,7 @@ --- 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/:*) +allowed-tools: Bash(moshcode stocks:*), Bash(curl -sS https://advis0r.com/api/:*) --- ## Task @@ -9,7 +9,7 @@ allowed-tools: Bash(moshcode ticker:*), Bash(curl -sS https://advis0r.com/api/:* Search the transcript index for `$ARGUMENTS`. ```bash -moshcode ticker search $ARGUMENTS --limit 20 --json +moshcode stocks search $ARGUMENTS --limit 20 --json ``` Fallback: `curl -sS "https://advis0r.com/api/search?q=&limit=20"` diff --git a/plugins/ticker/commands/signals.md b/plugins/ticker/commands/signals.md index 182deeb..a84d967 100644 --- a/plugins/ticker/commands/signals.md +++ b/plugins/ticker/commands/signals.md @@ -1,7 +1,7 @@ --- 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/:*) +allowed-tools: Bash(moshcode stocks:*), Bash(curl -sS https://advis0r.com/api/:*) --- ## Task @@ -9,7 +9,7 @@ allowed-tools: Bash(moshcode ticker:*), Bash(curl -sS https://advis0r.com/api/:* List the extracted signals for `$ARGUMENTS`. ```bash -moshcode ticker signals $ARGUMENTS --json +moshcode stocks signals $ARGUMENTS --json ``` Fallback: `curl -sS "https://advis0r.com/api/signals?ticker=$ARGUMENTS"` diff --git a/plugins/ticker/commands/ticker.md b/plugins/ticker/commands/stocks.md similarity index 93% rename from plugins/ticker/commands/ticker.md rename to plugins/ticker/commands/stocks.md index 657499f..bc57575 100644 --- a/plugins/ticker/commands/ticker.md +++ b/plugins/ticker/commands/stocks.md @@ -1,7 +1,7 @@ --- 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/:*) +allowed-tools: Bash(moshcode stocks:*), Bash(curl -sS https://advis0r.com/api/:*) --- ## Task @@ -11,7 +11,7 @@ Pull the stored research report for `$ARGUMENTS` and summarize it for the user. Run: ```bash -moshcode ticker $ARGUMENTS --json +moshcode stocks $ARGUMENTS --json ``` If `moshcode` is not installed, fall back to the API directly: diff --git a/src/advisor.mjs b/src/advisor.mjs index 7e09f9b..c439d69 100644 --- a/src/advisor.mjs +++ b/src/advisor.mjs @@ -1,4 +1,4 @@ -// `moshcode ticker` — equity research from advis0r.com, in the pit. +// `moshcode stocks` — 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. @@ -18,7 +18,7 @@ export function advisorBase(env = process.env) { return (raw || DEFAULT_ADVISOR_URL).replace(/\/+$/, ""); } -const USAGE = `usage: moshcode ticker [args…] +const USAGE = `usage: moshcode stocks [args…] the stored research report for one ticker report same thing, when a symbol looks like a verb @@ -40,17 +40,17 @@ const USAGE = `usage: moshcode ticker [args…] 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() { +export function stocksUsage() { return USAGE; } -/** Verb names, in help order. cli-schema's TICKER_VERBS must match (drift test). */ -export const TICKER_VERB_NAMES = [ +/** Verb names, in help order. cli-schema's STOCKS_VERBS must match (drift test). */ +export const STOCKS_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. +// Aliases exist because muscle memory differs: `/stocks news AAPL` and +// `/stocks quotes AAPL` should not be errors when the intent is obvious. const VERB_ALIASES = { signal: "signals", news: "signals", find: "search", grep: "search", q: "search", @@ -66,7 +66,7 @@ const VERB_ALIASES = { /** 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; + if (STOCKS_VERB_NAMES.includes(key)) return key; return VERB_ALIASES[key] ?? null; } @@ -114,13 +114,13 @@ function positiveInt(value, { max }) { const SORTS = ["recent", "score", "ticker"]; /** - * Translate `ticker` arguments into a request the caller can execute. + * Translate `stocks` 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 = []) { +export function stocksArgs(input = []) { const args = input.map(String); const jsonFlag = takeFlag(args, "--json", { boolean: true }); let rest = jsonFlag.rest; @@ -131,26 +131,26 @@ export function tickerArgs(input = []) { 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" }; + if (limitFlag.missing) return { error: "stocks --limit requires a positive number" }; + if (sortFlag.missing) return { error: `stocks --sort requires one of ${SORTS.join(", ")}` }; + if (horizonFlag.missing) return { error: "stocks --horizon requires 1 or 2" }; + if (providerFlag.missing) return { error: "stocks --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" }; + return { error: "stocks --limit requires a positive number" }; } if (sortFlag.value != null && !SORTS.includes(sortFlag.value.toLowerCase())) { - return { error: `ticker --sort must be one of ${SORTS.join(", ")}` }; + return { error: `stocks --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" }; + return { error: "stocks --horizon must be 1 or 2" }; } const stray = rest.find((arg) => arg.startsWith("-") && arg !== "-"); - if (stray) return { error: `unknown ticker flag ${JSON.stringify(stray)}` }; + if (stray) return { error: `unknown stocks flag ${JSON.stringify(stray)}` }; const [first, ...tail] = rest; if (!first) return { usage: true }; @@ -158,16 +158,16 @@ export function tickerArgs(input = []) { const verb = resolveVerb(first); const words = verb ? tail : rest; - // No verb → the first word is the ticker. `/ticker AAPL` is the headline + // No verb → the first word is the ticker. `/stocks 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` }; + if (!raw) return { error: `stocks ${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)}`, + error: `${JSON.stringify(String(raw))} is not a ticker symbol — try: moshcode stocks lookup ${String(raw)}`, }; } if (verb === "open") return { verb: "open", symbol, open: `/ticker/${encodeURIComponent(symbol)}`, json }; @@ -176,16 +176,16 @@ export function tickerArgs(input = []) { if (verb === "signals") { const symbol = normalizeSymbol(words[0]); - if (!words[0]) return { error: "ticker signals requires a ticker symbol" }; + if (!words[0]) return { error: "stocks 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 { error: `${JSON.stringify(String(words[0]))} is not a ticker symbol — try: moshcode stocks 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` }; + if (!q) return { error: `stocks ${verb} requires something to look for` }; const path = verb === "search" ? "/api/search" : "/api/lookup"; return { verb, path, query: { q, ...(limit ? { limit: String(limit) } : {}) }, json }; } @@ -221,7 +221,7 @@ export function tickerArgs(input = []) { 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))}` }; + return { error: `unknown stocks command ${JSON.stringify(String(first))}` }; } /** Build the absolute URL for a translated request. */ @@ -248,7 +248,7 @@ export async function fetchAdvisor(request, { fetchImpl = globalThis.fetch, base try { const res = await fetchImpl(url, { signal: controller.signal, - headers: { accept: "application/json", "user-agent": "moshcode-ticker" }, + headers: { accept: "application/json", "user-agent": "moshcode-stocks" }, }); const text = await res.text(); let data; @@ -468,7 +468,7 @@ function renderLookup(d) { 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}`)}`); + lines.push("", ` ${ash("then:")} ${bone(`moshcode stocks ${matches[0].symbol}`)}`); return lines.join("\n"); } @@ -484,7 +484,7 @@ function renderReports(d) { `${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}`)}`); + lines.push("", ` ${ash("detail:")} ${bone(`moshcode stocks ${reports[0].ticker}`)}`); return lines.join("\n"); } @@ -550,12 +550,12 @@ export function renderAdvisor(verb, data, { columns } = {}) { } /** - * Run a `ticker` invocation end to end. Returns a process exit code. + * Run a `stocks` 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 = {}) { +export async function stocksCommand(argv = [], deps = {}) { const { out = (s) => console.log(s), fail = (s) => console.error(s), @@ -565,8 +565,8 @@ export async function tickerCommand(argv = [], deps = {}) { columns = process.stdout.columns, } = deps; - const request = tickerArgs(argv); - if (request.usage) { out(tickerUsage()); return 0; } + const request = stocksArgs(argv); + if (request.usage) { out(stocksUsage()); return 0; } if (request.error) { fail(danger(`✗ ${request.error}`)); return 1; } if (request.verb === "open") { @@ -587,7 +587,7 @@ export async function tickerCommand(argv = [], deps = {}) { 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}`)}`); + fail(` ${ash("try:")} ${bone(`moshcode stocks ${res.data.didYouMean.symbol}`)}`); } return 1; } diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 7af2c26..344439a 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -290,14 +290,14 @@ export const CORE_CLI_COMMANDS = [ 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", + name: "stocks", group: "tools", description: "equity research from advis0r.com", synopsis: [ - ["moshcode ticker ", "the stored research report for one ticker"], - ["moshcode ticker [args…]", ""], + ["moshcode stocks ", "the stored research report for one ticker"], + ["moshcode stocks [args…]", ""], ], - verbs: "TICKER_VERBS", + verbs: "STOCKS_VERBS", flags: [ ["--json", "print the raw API response", ""], ["--limit ", "cap results (search/lookup/reports/discover)", "the API's own default"], @@ -306,16 +306,16 @@ export const CORE_CLI_COMMANDS = [ ["--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"], + ["moshcode stocks NVDA", "score, technicals, thesis, signals, sources"], + ["moshcode stocks lookup rivian", "company name → RIVN"], + ["moshcode stocks signals AAPL", "what was actually said, with sources"], + ["moshcode stocks search 'data center'", "across every indexed transcript"], + ["moshcode stocks 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: "advisor", aliasOf: "stocks", description: "alias for stocks" }, { name: "crypto", group: "tools", @@ -342,7 +342,7 @@ export const CORE_CLI_COMMANDS = [ ["moshcode crypto bars ETH --timeframe 1Hour", "historical OHLCV"], ["moshcode crypto book BTC-USD --depth 5", "top of book, both sides"], ], - seeAlso: ["ticker", "trade", "plugin"], + seeAlso: ["stocks", "trade", "plugin"], note: "research aid, not advice — prices are Alpaca's US crypto venue alone and can differ materially from other exchanges. Crypto trades 24/7 with no circuit breakers. Set MOSHCODE_ADVISOR_URL to point at another instance.", }, { name: "coins", aliasOf: "crypto", description: "alias for crypto" }, @@ -357,7 +357,7 @@ export const CORE_CLI_COMMANDS = [ ["moshcode plugin install", "add the marketplace and install ticker"], ["moshcode plugin list", "what this marketplace ships, and what is installed"], ], - seeAlso: ["skill", "mcp", "ticker"], + seeAlso: ["skill", "mcp", "stocks"], 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" }, @@ -539,42 +539,42 @@ export const DNS_VERBS = [ ]; /** - * `ticker`'s verbs. + * `stocks`'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 ", ""]] }, +export const STOCKS_VERBS = [ + { name: "report", description: "the stored research report for one ticker", synopsis: [["moshcode stocks report ", "same as `moshcode stocks `"]] }, + { name: "signals", description: "every extracted signal for a ticker", synopsis: [["moshcode stocks signals ", ""]] }, { name: "search", description: "full-text search across indexed transcripts", - synopsis: [["moshcode ticker search [--limit n]", ""]], + synopsis: [["moshcode stocks search [--limit n]", ""]], }, { name: "lookup", description: "find a ticker by company name", - synopsis: [["moshcode ticker lookup [--limit n]", "rivian → RIVN"]], + synopsis: [["moshcode stocks lookup [--limit n]", "rivian → RIVN"]], }, { name: "reports", description: "every stored report", - synopsis: [["moshcode ticker reports [--sort recent|score|ticker] [--limit n]", ""]], + synopsis: [["moshcode stocks 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]", ""]], + synopsis: [["moshcode stocks 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 ", ""]] }, + { name: "tickers", description: "every ticker present in the index", synopsis: [["moshcode stocks tickers", ""]] }, + { name: "stats", description: "index coverage counts", synopsis: [["moshcode stocks stats", ""]] }, + { name: "open", description: "open the shareable report page in a browser", synopsis: [["moshcode stocks open ", ""]] }, ]; /** * `crypto`'s verbs. * - * `report` earns its place for the same reason ticker's does — a bare pair is + * `report` earns its place for the same reason stocks's does — a bare pair is * the shortcut, so a pair that collides with a verb name needs a spelling that * cannot be mistaken for one. src/crypto.mjs owns the parser and * test/crypto.test.mjs fails when the two lists disagree. @@ -626,7 +626,7 @@ export const VERB_TABLES = { UPGRADE_TARGETS, DNS_VERBS, TRADE_VERBS, - TICKER_VERBS, + STOCKS_VERBS, CRYPTO_VERBS, PLUGIN_VERBS, }; @@ -655,7 +655,7 @@ 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", + { name: "stocks", aliases: ["advisor"], args: " [args…]", cli: "stocks", description: "equity research from advis0r.com" }, { name: "crypto", aliases: ["coins"], args: " [args…]", cli: "crypto", description: "crypto market data from advis0r.com" }, diff --git a/src/crypto.mjs b/src/crypto.mjs index 4649fc0..4d7ff87 100644 --- a/src/crypto.mjs +++ b/src/crypto.mjs @@ -5,8 +5,8 @@ // function of the decoded JSON. Every route is public and read-only, so there // is no login verb and no write verb — `moshcode trade` is where orders live. // -// This is a *sibling* of `ticker`, not a mode of it, because the two answer -// different questions from different data. A ticker report is a stored snapshot +// This is a *sibling* of `stocks`, not a mode of it, because the two answer +// different questions from different data. A stocks report is a stored snapshot // built from transcripts, SEC fundamentals and extracted signals. A crypto // report is a live read of Alpaca's US crypto venue: no transcripts, no // filings, no signals, and a `fetchedAt` measured in seconds rather than days. @@ -50,7 +50,7 @@ export const CRYPTO_VERB_NAMES = [ "report", "quote", "snapshot", "technicals", "bars", "book", "spark", "assets", "lookup", "open", ]; -// The same reasoning as ticker's alias table: `/crypto price BTC` and +// The same reasoning as stocks's alias table: `/crypto price BTC` and // `/crypto candles BTC` should not be errors when the intent is obvious. // `search` maps to lookup rather than erroring — crypto has no transcript // index to search, and a directory lookup is what the word means here. @@ -83,7 +83,7 @@ export function resolveVerb(word) { * the API resolves it to that asset's USD pair, and inventing the `-USD` here * would silently break the day a base has no USD pair. * - * Deliberately narrow, like ticker's: the whole job of the check is to tell + * Deliberately narrow, like stocks's: the whole job of the check is to tell * `BTC` from `bitcoin` and send the second one to lookup with a useful message * instead of a 400. Bases run to five characters (SUSHI, MATIC, TRUMP), so six * leaves room for the concatenated `BTCUSD` spelling without swallowing words. @@ -293,7 +293,7 @@ export function cryptoUrl(request, { base = advisorBase() } = {}) { * Execute a translated request. `fetchImpl` is injectable for tests. * * Every crypto route is a live venue read, so one timeout fits all of them — - * unlike ticker, which has to budget separately for `discover`'s per-candidate + * unlike stocks, which has to budget separately for `discover`'s per-candidate * analysis. */ export async function fetchCrypto(request, { fetchImpl = globalThis.fetch, base = advisorBase(), timeoutMs = 45_000 } = {}) { diff --git a/src/help.mjs b/src/help.mjs index 1780c6b..7fdad39 100644 --- a/src/help.mjs +++ b/src/help.mjs @@ -103,6 +103,18 @@ function distance(a, b) { return prev[b.length]; } +/** + * Commands that were renamed, and what they became. + * + * Edit distance cannot help here — `ticker` is nowhere near `stocks` — so a + * rename that isn't recorded leaves people with a bare "unknown command" for a + * verb that worked yesterday. This is deliberately *not* an alias table: the + * old name stays dead, it just says where its replacement went. + */ +export const RENAMED_COMMANDS = { + ticker: "stocks", +}; + /** * The nearest command to something that is not one, or null. * @@ -114,6 +126,8 @@ function distance(a, b) { export function suggest(input, extra = []) { const typed = String(input ?? "").toLowerCase(); if (!typed) return null; + // A known rename beats any distance match — it is not a guess. + if (RENAMED_COMMANDS[typed]) return RENAMED_COMMANDS[typed]; const candidates = [...CORE_CLI_COMMANDS.map((c) => c.name), ...extra] .filter((n) => !n.startsWith("-")); diff --git a/src/plugins.mjs b/src/plugins.mjs index 1b2ab3d..ccd392b 100644 --- a/src/plugins.mjs +++ b/src/plugins.mjs @@ -31,7 +31,7 @@ export const PLUGINS = [ { name: "ticker", description: "equity research slash commands backed by advis0r.com", - commands: ["/ticker", "/signals", "/research", "/lookup", "/reports", "/discover"], + commands: ["/stocks", "/signals", "/research", "/lookup", "/reports", "/discover"], }, { name: "crypto", diff --git a/src/tui.mjs b/src/tui.mjs index a95d37a..5e716ef 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -20,12 +20,12 @@ import { fetchMotdAd } from "./ads.mjs"; import { runScript } from "./runtime.mjs"; import { moshVocabulary } from "./commands.mjs"; import { mcpCommand, pluginCommand, skillCommand } from "./integrations.mjs"; -import { tickerCommand } from "./advisor.mjs"; +import { stocksCommand } from "./advisor.mjs"; import { cryptoCommand } from "./crypto.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"; +import { RENAMED_COMMANDS, findPitCommand, pitHelpModel, renderPitCommand, suggest, wantsHelp } from "./help.mjs"; import { openNewTab } from "./tabs.mjs"; const PROMPT = () => acid("mosh ") + dim("▸ "); @@ -673,13 +673,13 @@ export async function tui() { rl = mkrl(); continue; } - // `/ticker` renders in the pit rather than handing the terminal to a tool: + // `/stocks` 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) }); + if (cmd === "stocks" || cmd === "advisor") { + await stocksCommand(rest, { openUrl: (url) => canOpenBrowser() && openBrowser(url) }); continue; } - // `/crypto` renders in the pit for the same reason `/ticker` does: there is + // `/crypto` renders in the pit for the same reason `/stocks` does: there is // no crypto binary to hand the terminal to, only a public read-only API. if (cmd === "crypto" || cmd === "coins") { await cryptoCommand(rest, { openUrl: (url) => canOpenBrowser() && openBrowser(url) }); @@ -722,7 +722,12 @@ export async function tui() { rl = mkrl(); continue; } - console.log(err(`unknown command "${line}". /help for the list.`)); + // A renamed verb gets pointed at its replacement; `/ticker` was a pit + // command for a release, so a bare "unknown command" is a dead end here. + const renamed = RENAMED_COMMANDS[cmd]; + console.log(err(renamed + ? `unknown command "${line}" — /${cmd} is now /${renamed}.` + : `unknown command "${line}". /help for the list.`)); } try { rl.close(); } catch { /* noop */ } diff --git a/test/advisor.test.mjs b/test/advisor.test.mjs index 413f206..05ca382 100644 --- a/test/advisor.test.mjs +++ b/test/advisor.test.mjs @@ -1,35 +1,35 @@ -// `moshcode ticker` — argument translation, request building, and the two +// `moshcode stocks` — 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, + DEFAULT_ADVISOR_URL, STOCKS_VERB_NAMES, advisorBase, advisorUrl, fetchAdvisor, + normalizeSymbol, renderAdvisor, resolveVerb, stocksArgs, stocksCommand, stocksUsage, } from "../src/advisor.mjs"; -import { TICKER_VERBS } from "../src/cli-schema.mjs"; +import { STOCKS_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"]), { + assert.deepEqual(stocksArgs(["nvda"]), { verb: "report", symbol: "NVDA", path: "/api/ticker", query: { symbol: "NVDA" }, json: false, }); - assert.deepEqual(tickerArgs(["report", "brk.b"]), { + assert.deepEqual(stocksArgs(["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/); + assert.deepEqual(stocksArgs([]), { usage: true }); + assert.match(stocksUsage(), /usage: moshcode stocks/); }); test("a company name is refused with the lookup that resolves it", () => { - const result = tickerArgs(["some very long company name"]); + const result = stocksArgs(["some very long company name"]); assert.match(result.error, /is not a ticker symbol/); - assert.match(result.error, /moshcode ticker lookup/); + assert.match(result.error, /moshcode stocks lookup/); }); // --- verbs ------------------------------------------------------------------- @@ -37,8 +37,8 @@ test("a company name is refused with the lookup that resolves it", () => { 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`); + assert.deepEqual(STOCKS_VERBS.map(({ name }) => name).sort(), [...STOCKS_VERB_NAMES].sort()); + for (const { name } of STOCKS_VERBS) assert.equal(resolveVerb(name), name, `${name} does not resolve`); }); test("aliases resolve, and anything else is treated as a symbol", () => { @@ -49,18 +49,18 @@ test("aliases resolve, and anything else is treated as a symbol", () => { }); test("search and lookup take words, not symbols", () => { - assert.deepEqual(tickerArgs(["search", "data", "center", "--limit", "5"]), { + assert.deepEqual(stocksArgs(["search", "data", "center", "--limit", "5"]), { verb: "search", path: "/api/search", query: { q: "data center", limit: "5" }, json: false, }); - assert.deepEqual(tickerArgs(["lookup", "rivian"]), { + assert.deepEqual(stocksArgs(["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, { + assert.deepEqual(stocksArgs(["reports"]).query, { sort: "score" }); + assert.deepEqual(stocksArgs(["reports", "--sort", "recent"]).query, { sort: "recent" }); + assert.deepEqual(stocksArgs(["discover", "fusion"]).query, { topic: "fusion", provider: "offline", horizon: "2", }); }); @@ -68,28 +68,28 @@ test("reports defaults to score order and discover to the offline provider", () 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); + assert.equal(stocksArgs(["discover"]).slow, true); + assert.equal(stocksArgs(["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/); + assert.match(stocksArgs(["reports", "--limit", "0"]).error, /positive number/); + assert.match(stocksArgs(["reports", "--limit"]).error, /positive number/); + assert.match(stocksArgs(["reports", "--sort", "sideways"]).error, /recent, score, ticker/); + assert.match(stocksArgs(["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/); + assert.match(stocksArgs(["search", "--depth", "3"]).error, /unknown stocks flag/); }); test("--json survives anywhere in the argument list", () => { - assert.equal(tickerArgs(["--json", "nvda"]).json, true); - assert.equal(tickerArgs(["nvda", "--json"]).json, true); + assert.equal(stocksArgs(["--json", "nvda"]).json, true); + assert.equal(stocksArgs(["nvda", "--json"]).json, true); }); // --- symbols ----------------------------------------------------------------- @@ -109,12 +109,12 @@ test("the base URL is overridable, so a local instance is testable", () => { }); test("query values are encoded, not concatenated", () => { - const url = advisorUrl(tickerArgs(["search", "data center & more"]), { base: "https://example.test" }); + const url = advisorUrl(stocksArgs(["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"]); + const request = stocksArgs(["open", "nvda"]); assert.equal(request.path, undefined); assert.equal(advisorUrl(request, { base: "https://example.test" }), "https://example.test/ticker/NVDA"); }); @@ -126,7 +126,7 @@ const jsonResponse = (body, { ok = true, status = 200 } = {}) => ({ }); test("a non-JSON body is a failure, not a silent empty render", () => { - return fetchAdvisor(tickerArgs(["stats"]), { + return fetchAdvisor(stocksArgs(["stats"]), { fetchImpl: async () => ({ ok: false, status: 502, text: async () => "bad gateway" }), }).then((res) => { assert.equal(res.ok, false); @@ -135,7 +135,7 @@ test("a non-JSON body is a failure, not a silent empty render", () => { }); test("a transport failure is reported, never thrown at the caller", async () => { - const res = await fetchAdvisor(tickerArgs(["stats"]), { + const res = await fetchAdvisor(stocksArgs(["stats"]), { fetchImpl: async () => { throw new Error("ECONNREFUSED"); }, }); assert.equal(res.ok, false); @@ -214,7 +214,7 @@ function capture() { test("--json prints the response verbatim and renders nothing", async () => { const io = capture(); - const code = await tickerCommand(["stats", "--json"], { + const code = await stocksCommand(["stats", "--json"], { ...io, fetchImpl: async () => jsonResponse({ documents: 3 }), }); assert.equal(code, 0); @@ -223,7 +223,7 @@ test("--json prints the response verbatim and renders nothing", async () => { test("the API's own error is surfaced, with its did-you-mean", async () => { const io = capture(); - const code = await tickerCommand(["RIVIAN"], { + const code = await stocksCommand(["RIVIAN"], { ...io, fetchImpl: async () => jsonResponse( { error: '"RIVIAN" is not a ticker — did you mean RIVN?', didYouMean: { symbol: "RIVN", name: "Rivian" } }, @@ -233,13 +233,13 @@ test("the API's own error is surfaced, with its did-you-mean", async () => { 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/); + assert.match(output, /moshcode stocks 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"], { + const code = await stocksCommand(["reports", "--sort", "sideways"], { ...io, fetchImpl: async () => { called = true; return jsonResponse({}); }, }); assert.equal(code, 1); @@ -249,7 +249,7 @@ test("a bad argument exits non-zero without touching the network", async () => { 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"], { + const code = await stocksCommand(["open", "nvda"], { ...io, base: "https://example.test", openUrl: () => false, diff --git a/test/help.test.mjs b/test/help.test.mjs index 6722392..4510054 100644 --- a/test/help.test.mjs +++ b/test/help.test.mjs @@ -24,7 +24,7 @@ import { } from "../src/cli-schema.mjs"; import { WIDTH, findCommand, findPitCommand, helpModel, pitHelpModel, renderCommand, renderOverview, - renderPitCommand, renderScriptVerb, README_START, README_END, suggest, wantsHelp, + renderPitCommand, renderScriptVerb, README_START, README_END, RENAMED_COMMANDS, suggest, wantsHelp, withCommandTable, wrap, } from "../src/help.mjs"; import { ENGINES } from "../src/engines.mjs"; @@ -274,6 +274,16 @@ test("suggest is tight enough to stay useful", () => { assert.equal(suggest(""), null); }); +test("a renamed command points at its replacement, and stays gone", () => { + // Edit distance cannot reach `stocks` from `ticker`, so without the rename + // table a verb that worked one release ago gets a bare "unknown command". + assert.equal(suggest("ticker"), "stocks"); + for (const [old, now] of Object.entries(RENAMED_COMMANDS)) { + assert.equal(findCommand(old), null, `${old} was renamed — it must not still dispatch`); + assert.ok(findCommand(now), `${old} points at ${now}, which must exist`); + } +}); + test("findCommand resolves aliases and rejects strangers", () => { assert.equal(findCommand("where").name, "pwd"); assert.equal(findCommand("update").name, "upgrade");