From e7e27df03e15d5bba7c0f377a4fe4786f32a4d7a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 4 Aug 2026 18:24:59 +0000 Subject: [PATCH 1/3] feat(tools): add Alpaca trading --- README.md | 36 +++++++ bin/moshcode.mjs | 25 +++++ src/cli-schema.mjs | 47 +++++++++ src/commands.mjs | 2 + src/completion.mjs | 40 ++++++++ src/tools.mjs | 13 +++ src/trade.mjs | 137 ++++++++++++++++++++++++++ src/tui.mjs | 21 +++- src/uninstall.mjs | 1 + test/cli.test.mjs | 6 +- test/completion.test.mjs | 12 ++- test/help.test.mjs | 4 +- test/tools.test.mjs | 41 ++++++++ test/trade.test.mjs | 112 +++++++++++++++++++++ test/uninstall.test.mjs | 7 ++ test/upgrade-install-missing.test.mjs | 15 ++- test/upgrade.test.mjs | 11 ++- 17 files changed, 522 insertions(+), 8 deletions(-) create mode 100644 src/trade.mjs create mode 100644 test/trade.test.mjs diff --git a/README.md b/README.md index e1cc1a6..9892903 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ or miss one that does. A test fails the build when it drifts. | `moshcode pwd`
`where` | system | show the current directory and git context | | `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 commands` | script | list built-in moshscript commands | | `moshcode completion` | extend | print a shell completion script | | `moshcode run` | script | run a moshscript | @@ -154,6 +155,41 @@ native setup and authentication commands. CoinPay currently requires Node.js In the TUI, use `/tools`, `/ugig [args…]`, or `/coinpay [args…]`. The native CLI owns the terminal until it exits, then MoshCode returns to the pit. +### Alpaca trading + +Alpaca is a workflow tool, not a coding engine. Install its official Go CLI, +use `alpaca` for exact native passthrough, or use `trade` for the shorter market +and order vocabulary: + +```sh +moshcode install alpaca # go install github.com/alpacahq/cli/cmd/alpaca@latest +moshcode trade login # Alpaca profile login; paper trading is the default +moshcode trade ticker AAPL # asset get --symbol-or-asset-id AAPL +moshcode trade quote AAPL # latest quote +moshcode trade analysis AAPL # quote/trade/bar snapshot for analysis +moshcode trade watch # list watchlists +moshcode trade positions # list open positions +moshcode trade orders # list open orders +``` + +`buy` and `sell` are safe previews unless `--submit` is explicit. Other Alpaca +order flags pass through, including limit prices and its separate live-trading +opt-in: + +```sh +moshcode trade buy AAPL 1 # adds --type market --dry-run +moshcode trade buy AAPL 1 --type limit --limit-price 185 +moshcode trade buy AAPL --notional 100 # preview a $100 market buy +moshcode trade buy AAPL 1 --submit # places the paper order +moshcode trade raw data news --symbol AAPL # any native Alpaca command +moshcode alpaca order submit --help # exact native passthrough +``` + +The same facade is `/trade …` in the pit and `trade(…)` in moshscript. +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. + ## Browser terminal (`moshcode console`) A real terminal in the browser — arrow keys, history, full-screen TUIs — because diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs index 32b3f73..855adea 100755 --- a/bin/moshcode.mjs +++ b/bin/moshcode.mjs @@ -15,6 +15,7 @@ import { runCmd, } from "../src/engines.mjs"; import { TOOLS, toolList, toolStatus, resolveTool, openTool } from "../src/tools.mjs"; +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"; @@ -317,6 +318,29 @@ async function main() { } return; } + if (cmd === "trade") { + const translated = tradeArgs(rest); + if (translated.usage) { + console.log(tradeUsage()); + return; + } + if (translated.error) { + console.error(`${translated.error}\n\n${tradeUsage()}`); + process.exitCode = 1; + return; + } + const tool = TOOLS.alpaca; + const r = await openTool(tool, translated.args); + if (!r.ok) { + console.error(r.error?.code === "ENOENT" + ? `alpaca isn't installed (\`${tool.bin}\`). run: moshcode install alpaca` + : `launch failed: ${r.error?.message || r.error}`); + process.exitCode = 1; + return; + } + propagateExit(r.code, r.signal); + return; + } if (cmd === "console") { const code = await consoleCommand(rest); if (code) process.exitCode = code; @@ -345,6 +369,7 @@ async function main() { const result = await runCmd(install.cmd, install.args); if (!result.ok) { console.error(`install failed: ${result.error?.message || result.error || "unknown error"}`); + if (result.error?.code === "ENOENT" && entry.installHelp) console.error(entry.installHelp); process.exitCode = 1; return; } diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index f97255a..42ddead 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -267,6 +267,21 @@ export const CORE_CLI_COMMANDS = [ flags: [["--json", "machine-readable, and suppresses the trailing note", ""]], seeAlso: ["install"], }, + { + name: "trade", + group: "tools", + description: "look up markets and trade through Alpaca", + synopsis: [["moshcode trade [args…]", "paper trading is Alpaca's default"]], + verbs: "TRADE_VERBS", + examples: [ + ["moshcode trade ticker AAPL", "asset lookup"], + ["moshcode trade analysis AAPL", "quote/trade/bar snapshot"], + ["moshcode trade buy AAPL 1", "preview a market order"], + ["moshcode trade buy AAPL 1 --submit", "place it"], + ], + 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: "commands", group: "script", @@ -395,6 +410,35 @@ export const UPGRADE_TARGETS = [ { name: "tools", description: "update all installed workflow tools" }, ]; +export const TRADE_VERBS = [ + { name: "ticker", description: "look up an asset by ticker", synopsis: [["moshcode trade ticker [flags…]", ""]] }, + { name: "quote", description: "get the latest quote", synopsis: [["moshcode trade quote [flags…]", ""]] }, + { name: "analysis", description: "get an analysis-ready market snapshot", synopsis: [["moshcode trade analysis [flags…]", ""]] }, + { + name: "buy", description: "preview or submit a buy order", + synopsis: [ + ["moshcode trade buy [alpaca flags…] [--submit]", "share quantity"], + ["moshcode trade buy --notional [--submit]", "dollar amount"], + ], + flags: [["--submit", "place the order instead of injecting --dry-run", "preview"]], + }, + { + name: "sell", description: "preview or submit a sell order", + synopsis: [ + ["moshcode trade sell [alpaca flags…] [--submit]", "share quantity"], + ["moshcode trade sell --notional [--submit]", "dollar amount"], + ], + flags: [["--submit", "place the order instead of injecting --dry-run", "preview"]], + }, + { name: "watch", description: "manage watchlists", synopsis: [["moshcode trade watch [list|create|get|add|remove|delete] [args…]", ""]] }, + { name: "positions", description: "list, inspect, or close positions", synopsis: [["moshcode trade positions [verb] [args…]", "default: list"]] }, + { name: "orders", description: "list, inspect, replace, or cancel orders", synopsis: [["moshcode trade orders [verb] [args…]", "default: list"]] }, + { name: "account", description: "show account details", synopsis: [["moshcode trade account [args…]", ""]] }, + { name: "login", description: "authenticate an Alpaca profile", synopsis: [["moshcode trade login [alpaca profile flags…]", "paper by default"]] }, + { name: "clock", description: "show market status and next open/close", synopsis: [["moshcode trade clock [args…]", ""]] }, + { name: "raw", description: "invoke the native Alpaca command tree", synopsis: [["moshcode trade raw ", ""]] }, +]; + /** * `dns` sub-verbs. * @@ -421,6 +465,7 @@ export const VERB_TABLES = { SKILL_VERBS, UPGRADE_TARGETS, DNS_VERBS, + TRADE_VERBS, }; /** @@ -443,6 +488,8 @@ export const PIT_COMMANDS = [ description: "raw launch; inject no engine arguments" }, { name: "tools", args: "[name] [args…]", cli: "tools", description: "list workflow tools, or run one" }, + { name: "trade", args: " [args…]", cli: "trade", + description: "look up markets and preview/place Alpaca orders" }, { name: "install", args: "", cli: "install", description: "install an engine or workflow tool" }, { name: "upgrade", aliases: ["update"], args: "[name…]", cli: "upgrade", diff --git a/src/commands.mjs b/src/commands.mjs index f3a0785..c87247e 100644 --- a/src/commands.mjs +++ b/src/commands.mjs @@ -248,6 +248,8 @@ const COMMANDS = [ cliVerb("doctl", "drive the DigitalOcean CLI (droplets, apps, databases)"), cliVerb("turso", "drive the Turso CLI (auth, databases, replicas)"), cliVerb("tailscale", "drive the Tailscale CLI (mesh VPN: up, status, ssh, serve)"), + cliVerb("alpaca", "drive the native Alpaca trading CLI"), + cliVerb("trade", "look up tickers, inspect markets, and preview/place Alpaca orders"), cliVerb("pwd", "print the current repo/location"), ]; diff --git a/src/completion.mjs b/src/completion.mjs index 953b7a7..3f51bc2 100644 --- a/src/completion.mjs +++ b/src/completion.mjs @@ -2,6 +2,7 @@ import { CORE_CLI_COMMANDS, MCP_VERBS, SKILL_VERBS, + TRADE_VERBS, UPGRADE_TARGETS, } from "./cli-schema.mjs"; import { ENGINES, ENGINE_ALIASES } from "./engines.mjs"; @@ -53,6 +54,16 @@ export function completionModel() { mcp: uniqueEntries(MCP_VERBS), mcpServerSpecs: uniqueEntries(MCP_VERBS.filter(({ acceptsServerSpec }) => acceptsServerSpec)), skills: uniqueEntries(SKILL_VERBS), + trade: uniqueEntries(TRADE_VERBS), + tradeOrderOptions: uniqueEntries([ + entry("--submit", "place the order instead of previewing"), + entry("--qty", "share quantity"), + entry("--notional", "dollar amount"), + entry("--type", "market, limit, stop, stop_limit, or trailing_stop"), + entry("--limit-price", "limit price"), + entry("--stop-price", "stop price"), + entry("--time-in-force", "order time in force"), + ]), skillSources: uniqueEntries(SKILL_VERBS.filter(({ acceptsSource }) => acceptsSource)), shells: COMPLETION_SHELLS.map((name) => entry(name, `generate ${name} completion`)), // `moshcode help ` accepts anything help can answer for: a command, @@ -119,6 +130,8 @@ ${powershellEntries("MoshcodeCompletionUpgrade", model.upgrade)} ${powershellEntries("MoshcodeCompletionMcp", model.mcp)} ${powershellEntries("MoshcodeCompletionMcpServerSpecs", model.mcpServerSpecs)} ${powershellEntries("MoshcodeCompletionSkills", model.skills)} +${powershellEntries("MoshcodeCompletionTrade", model.trade)} +${powershellEntries("MoshcodeCompletionTradeOrderOptions", model.tradeOrderOptions)} ${powershellEntries("MoshcodeCompletionSkillSources", model.skillSources)} ${powershellEntries("MoshcodeCompletionShells", model.shells)} ${powershellEntries("MoshcodeCompletionHelpTopics", model.helpTopics)} @@ -190,6 +203,13 @@ Register-ArgumentCompleter -Native -CommandName moshcode -ScriptBlock { $choices = $script:MoshcodeCompletionSkillOptions } } + 'trade' { + if ($argumentIndex -eq 2) { + $choices = $script:MoshcodeCompletionTrade + } elseif ($nested -in @('buy', 'sell') -and $wordToComplete.StartsWith('-')) { + $choices = $script:MoshcodeCompletionTradeOrderOptions + } + } 'login' { if ($wordToComplete.StartsWith('-')) { $choices = $script:MoshcodeCompletionLogin } } { $_ -in @('engines', 'tools', 'commands') } { if ($wordToComplete.StartsWith('-')) { $choices = $script:MoshcodeCompletionJson } @@ -282,6 +302,13 @@ _moshcode_completion() { choices="--name" fi ;; + trade) + if (( COMP_CWORD == 2 )); then + choices="${names(model.trade)}" + elif [[ "$nested" == "buy" || "$nested" == "sell" ]] && [[ "$cur" == -* ]]; then + choices="${names(model.tradeOrderOptions)}" + fi + ;; login) [[ "$cur" == -* ]] && choices="--browser -b --device -d" ;; @@ -402,6 +429,17 @@ _moshcode() { if [[ "$PREFIX" == -* ]]; then _values "skill option" --name; else _files; fi fi ;; + trade) + if (( CURRENT == 3 )); then + choices=(${zshValues(model.trade)}) + _describe "trade command" choices + elif [[ "\${words[3]}" == "buy" || "\${words[3]}" == "sell" ]] && [[ "$PREFIX" == -* ]]; then + choices=(${zshValues(model.tradeOrderOptions)}) + _describe "trade order option" choices + else + _files + fi + ;; login) _values "login option" --browser -b --device -d ;; @@ -491,6 +529,8 @@ ${fishEntries(atSecondToken("completion"), model.shells)} ${fishEntries(atSecondToken("help"), model.helpTopics)} ${fishEntries(atSecondToken("mcp"), model.mcp)} ${fishEntries(atSecondToken("skill skills"), model.skills)} +${fishEntries(atSecondToken("trade"), model.trade)} +${fishEntries("__moshcode_nested_is trade buy; or __moshcode_nested_is trade sell", model.tradeOrderOptions)} complete -c moshcode -n '__moshcode_nested_is mcp list' -l json -d 'print JSON' complete -c moshcode -n '__moshcode_nested_is skill list; or __moshcode_nested_is skills list' -l json -d 'print JSON' complete -c moshcode -n '__moshcode_command_is login' -l browser -s b -d 'use browser authentication' diff --git a/src/tools.mjs b/src/tools.mjs index 2a40918..50a6251 100644 --- a/src/tools.mjs +++ b/src/tools.mjs @@ -114,6 +114,19 @@ export const TOOLS = { // silently re-adding package repos. upgrade: { cmd: "tailscale", args: ["update"] }, }, + alpaca: { + desc: "Alpaca — paper/live trading, market data, positions, and watchlists", + bin: "alpaca", + // Official Go install documented by Alpaca. Go writes to $GOBIN when set, + // otherwise $GOPATH/bin (normally ~/go/bin); binDirs covers that default in + // an already-running shell whose PATH has not picked it up yet. + binDirs: [path.join(homedir(), "go", "bin")], + install: { + cmd: "go", + args: ["install", "github.com/alpacahq/cli/cmd/alpaca@latest"], + }, + installHelp: "Go is required to install Alpaca; install Go, then retry `moshcode install alpaca`.", + }, }; /** Resolve a name to `[key, tool]`, or null. */ diff --git a/src/trade.mjs b/src/trade.mjs new file mode 100644 index 0000000..984fa91 --- /dev/null +++ b/src/trade.mjs @@ -0,0 +1,137 @@ +// Friendly trading vocabulary over Alpaca's native CLI. +// +// Alpaca remains a workflow tool: authentication, API calls, structured output, +// and the full command tree all belong to the `alpaca` binary. This module only +// translates a small, memorable `moshcode trade` surface into native argv. +// `moshcode alpaca ...` remains the escape hatch for every command not covered +// here. + +const USAGE = `usage: moshcode trade [args…] + + ticker [flags…] look up a tradable asset + quote [flags…] get the latest quote + analysis [flags…] get an analysis-ready market snapshot + buy [flags…] preview a buy; add --submit to place it + buy --notional preview a dollar-value buy + sell [flags…] preview a sell; add --submit to place it + watch [args…] manage Alpaca watchlists + positions [verb] [args…] list/get/close positions + orders [verb] [args…] list/get/replace/cancel orders + account [args…] show account details + login [args…] authenticate an Alpaca profile (paper by default) + clock [args…] show market status and next open/close + raw invoke the native Alpaca command tree + +Orders are previews by default. --submit removes the injected --dry-run; +live trading still requires Alpaca's separate --live opt-in.`; + +export function tradeUsage() { + return USAGE; +} + +function symbolCommand(native, args) { + const [symbol, ...rest] = args; + if (!symbol || String(symbol).startsWith("-")) { + return { error: `trade ${native.label} requires a ticker symbol` }; + } + return { args: [...native.argv, "--symbol", String(symbol).toUpperCase(), ...rest] }; +} + +function optionValue(args, name) { + const exact = args.indexOf(name); + if (exact >= 0) return { value: args[exact + 1], missing: args[exact + 1] == null || String(args[exact + 1]).startsWith("-") }; + const prefix = `${name}=`; + const joined = args.find((arg) => String(arg).startsWith(prefix)); + return joined == null ? null : { value: String(joined).slice(prefix.length), missing: String(joined).slice(prefix.length) === "" }; +} + +function positiveNumber(value) { + return value != null && Number.isFinite(Number(value)) && Number(value) > 0; +} + +function orderCommand(side, args) { + const [symbol, ...afterSymbol] = args; + if (!symbol || String(symbol).startsWith("-")) { + return { error: `trade ${side} requires a ticker symbol and quantity or --notional` }; + } + + const positionalQty = afterSymbol[0] != null && !String(afterSymbol[0]).startsWith("-") + ? String(afterSymbol[0]) + : null; + const tail = positionalQty == null ? afterSymbol : afterSymbol.slice(1); + const qtyOption = optionValue(tail, "--qty"); + const notionalOption = optionValue(tail, "--notional"); + const supplied = [positionalQty != null, qtyOption != null, notionalOption != null].filter(Boolean).length; + if (supplied === 0) { + return { error: `trade ${side} requires a positive quantity or --notional amount` }; + } + if (supplied > 1) { + return { error: `trade ${side} accepts one of positional quantity, --qty, or --notional` }; + } + if (qtyOption?.missing) return { error: `trade ${side} --qty requires a positive number` }; + if (notionalOption?.missing) return { error: `trade ${side} --notional requires a positive number` }; + const amount = positionalQty ?? qtyOption?.value ?? notionalOption?.value; + if (!positiveNumber(amount)) { + return { error: `trade ${side} ${notionalOption ? "notional amount" : "quantity"} must be a positive number` }; + } + + // Placing an order is the one place where a friendly shortcut should be + // safer than its native equivalent. Alpaca deliberately has no confirmation + // prompts, so buy/sell previews unless the caller explicitly says --submit. + const submit = tail.includes("--submit"); + const nativeTail = tail.filter((arg) => arg !== "--submit"); + const hasType = nativeTail.some((arg) => arg === "--type" || String(arg).startsWith("--type=")); + const hasDryRun = nativeTail.includes("--dry-run"); + return { + args: [ + "order", "submit", + "--symbol", String(symbol).toUpperCase(), + "--side", side, + ...(positionalQty == null ? [] : ["--qty", positionalQty]), + ...(hasType ? [] : ["--type", "market"]), + ...nativeTail, + ...(!submit && !hasDryRun ? ["--dry-run"] : []), + ], + preview: !submit || hasDryRun, + }; +} + +/** Translate `trade` arguments into argv for the native `alpaca` binary. */ +export function tradeArgs(input = []) { + const [rawCommand, ...rest] = input.map(String); + const command = rawCommand?.toLowerCase(); + if (!command) return { usage: true }; + + if (command === "ticker" || command === "lookup" || command === "asset") { + const [symbol, ...tail] = rest; + if (!symbol || String(symbol).startsWith("-")) return { error: "trade ticker requires a ticker symbol" }; + // Unlike market-data commands, Alpaca asset get names its lookup argument + // --symbol-or-asset-id (verified against v0.0.13's actual command tree). + return { args: ["asset", "get", "--symbol-or-asset-id", String(symbol).toUpperCase(), ...tail] }; + } + if (command === "quote") { + return symbolCommand({ label: "quote", argv: ["data", "latest-quote"] }, rest); + } + if (command === "analysis" || command === "analyze") { + return symbolCommand({ label: "analysis", argv: ["data", "snapshot"] }, rest); + } + if (command === "buy" || command === "sell") return orderCommand(command, rest); + + if (command === "watch" || command === "watchlist") { + return { args: ["watchlist", ...(rest.length ? rest : ["list"])] }; + } + if (command === "positions" || command === "position") { + return { args: ["position", ...(rest.length ? rest : ["list"])] }; + } + if (command === "orders" || command === "order") { + return { args: ["order", ...(rest.length ? rest : ["list"])] }; + } + if (command === "account") return { args: ["account", ...(rest.length ? rest : ["get"])] }; + if (command === "login") return { args: ["profile", "login", ...rest] }; + if (command === "clock") return { args: ["clock", ...rest] }; + if (command === "raw" || command === "alpaca") { + return rest.length ? { args: rest } : { error: "trade raw requires an Alpaca command" }; + } + + return { error: `unknown trade command ${JSON.stringify(rawCommand)}` }; +} diff --git a/src/tui.mjs b/src/tui.mjs index a7032ac..bb9ba91 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -9,6 +9,7 @@ import os from "node:os"; import path from "node:path"; import { ENGINES, agentLaunchArgs, resolveEngine, engineStatus, openSession } from "./engines.mjs"; import { TOOLS, resolveTool, toolStatus, openTool } from "./tools.mjs"; +import { tradeArgs, tradeUsage } from "./trade.mjs"; import { runUpgrade } from "./upgrade.mjs"; import { locate, tilde } from "./pwd.mjs"; import { createPrd, listPrds, authoringPrompt } from "./prd.mjs"; @@ -370,7 +371,12 @@ function installTarget(key) { console.log(info(`installing ${key}: ${target.install.cmd} ${target.install.args.join(" ")}`)); console.log(hr()); const child = spawn(target.install.cmd, target.install.args, { stdio: "inherit" }); - child.on("error", (e) => { console.log(hr()); console.log(err(`install failed: ${e.message}`)); resolve(); }); + child.on("error", (e) => { + console.log(hr()); + console.log(err(`install failed: ${e.message}`)); + if (e.code === "ENOENT" && target.installHelp) console.log(info(target.installHelp)); + resolve(); + }); child.on("exit", (code) => { console.log(hr()); console.log(code === 0 ? ok(`${key} installed. 🤘`) : err(`install exited ${code}`)); resolve(); }); }); } @@ -603,6 +609,19 @@ export async function tui() { rl = mkrl(); continue; } + if (cmd === "trade") { + const translated = tradeArgs(rest); + if (translated.usage) { console.log(tradeUsage()); continue; } + if (translated.error) { console.log(err(translated.error)); continue; } + rl.close(); + const tool = TOOLS.alpaca; + await openWorkflowTool("alpaca", { + ...tool, + installed: toolStatus().find((entry) => entry.key === "alpaca")?.installed, + }, translated.args); + rl = mkrl(); + continue; + } // Bare engine name → open it. const resolved = resolveEngine(cmd); if (resolved) { diff --git a/src/uninstall.mjs b/src/uninstall.mjs index 91a90a1..215ba21 100644 --- a/src/uninstall.mjs +++ b/src/uninstall.mjs @@ -37,6 +37,7 @@ export function safePrefixes(home = homedir()) { `${home}/bin`, `${home}/.npm-global/bin`, `${home}/.volta/bin`, + `${home}/go/bin`, "/usr/local/bin", "/opt/homebrew/bin", ]; diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 9905830..b9efbdd 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -92,7 +92,7 @@ test("runMoshcode stringifies args and never spawns under dry-run", async () => test("the CLI capabilities are all registered as verbs", () => { const reg = moshVocabulary(); - for (const name of ["agents", "start", "install", "upgrade", "mcp", "skill", "prd", "ugig", "coinpay", "c0mpute", "secrets", "pwd", "ai"]) { + for (const name of ["agents", "start", "install", "upgrade", "mcp", "skill", "prd", "ugig", "coinpay", "c0mpute", "secrets", "alpaca", "trade", "pwd", "ai"]) { assert.ok(reg.has(name), `expected ${name}() in the vocabulary`); } }); @@ -114,6 +114,8 @@ const VERB_ARGV_CASES = [ { verb: "coinpay", args: ["wallet", "balance"], expect: /moshcode coinpay wallet balance/ }, { verb: "c0mpute", args: ["status"], expect: /moshcode c0mpute status/ }, { verb: "secrets", args: ["teams", "list"], expect: /moshcode secrets teams list/ }, + { verb: "alpaca", args: ["asset", "get", "--symbol-or-asset-id", "AAPL"], expect: /moshcode alpaca asset get --symbol-or-asset-id AAPL/ }, + { verb: "trade", args: ["ticker", "AAPL"], expect: /moshcode trade ticker AAPL/ }, { verb: "pwd", args: [], expect: /moshcode pwd/ }, { verb: "run", args: ["setup.mosh"], expect: /moshcode run setup\.mosh/ }, ]; @@ -131,7 +133,7 @@ for (const { verb, args, expect: pattern } of VERB_ARGV_CASES) { // Verify CLI verbs return { ok, dryRun } under dry-run (no real spawn). test("all CLI verbs return { ok: true, dryRun: true } in dry-run mode", () => { - const cliNames = ["agents", "start", "install", "upgrade", "mcp", "skill", "prd", "ugig", "coinpay", "c0mpute", "secrets", "pwd", "run"]; + const cliNames = ["agents", "start", "install", "upgrade", "mcp", "skill", "prd", "ugig", "coinpay", "c0mpute", "secrets", "alpaca", "trade", "pwd", "run"]; for (const name of cliNames) { const ctx = dryCtx(); const cmd = moshVocabulary().get(name); diff --git a/test/completion.test.mjs b/test/completion.test.mjs index a073a4c..5085c06 100644 --- a/test/completion.test.mjs +++ b/test/completion.test.mjs @@ -9,7 +9,7 @@ import { completionModel, completionScript, } from "../src/completion.mjs"; -import { MCP_VERBS, UPGRADE_TARGETS } from "../src/cli-schema.mjs"; +import { MCP_VERBS, TRADE_VERBS, UPGRADE_TARGETS } from "../src/cli-schema.mjs"; import { ENGINE_ALIASES, ENGINES } from "../src/engines.mjs"; import { TOOLS } from "../src/tools.mjs"; @@ -66,6 +66,7 @@ test("completion model derives engines, aliases, and tools from their registries new Set(names(model.mcpServerSpecs)), new Set(MCP_VERBS.filter(({ acceptsServerSpec }) => acceptsServerSpec).map(({ name }) => name)), ); + assert.deepEqual(names(model.trade), [...TRADE_VERBS].map(({ name }) => name).sort()); }); test("completion schema covers every explicitly dispatched CLI command", () => { @@ -126,6 +127,15 @@ test("bash completion respects argument depth and preserves file fallbacks", () assert.ok(bashCompletions(["moshcode", "mcp", "list", "--"]).includes("--json")); assert.deepEqual(bashCompletions(["moshcode", "mcp", "install", ""]), []); + const trade = bashCompletions(["moshcode", "trade", ""]); + assert.ok(trade.includes("ticker")); + assert.ok(trade.includes("buy")); + assert.ok(trade.includes("watch")); + const tradeOrder = bashCompletions(["moshcode", "trade", "buy", "AAPL", "1", "--"]); + assert.ok(tradeOrder.includes("--submit")); + assert.ok(tradeOrder.includes("--notional")); + assert.ok(tradeOrder.includes("--limit-price")); + assert.ok(bashCompletions(["moshcode", "skill", "list", "--"]).includes("--json")); assert.ok(bashCompletions(["moshcode", "skills", "list", "--"]).includes("--json")); diff --git a/test/help.test.mjs b/test/help.test.mjs index 7530313..6722392 100644 --- a/test/help.test.mjs +++ b/test/help.test.mjs @@ -20,7 +20,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { - CORE_CLI_COMMANDS, MCP_VERBS, SKILL_VERBS, UPGRADE_TARGETS, DNS_VERBS, + CORE_CLI_COMMANDS, MCP_VERBS, SKILL_VERBS, TRADE_VERBS, UPGRADE_TARGETS, DNS_VERBS, } from "../src/cli-schema.mjs"; import { WIDTH, findCommand, findPitCommand, helpModel, pitHelpModel, renderCommand, renderOverview, @@ -219,7 +219,7 @@ test("every dispatched command has a schema entry with a description", async () }); test("every sub-verb has a description", () => { - for (const [label, table] of [["mcp", MCP_VERBS], ["skill", SKILL_VERBS], ["upgrade", UPGRADE_TARGETS], ["dns", DNS_VERBS]]) { + for (const [label, table] of [["mcp", MCP_VERBS], ["skill", SKILL_VERBS], ["trade", TRADE_VERBS], ["upgrade", UPGRADE_TARGETS], ["dns", DNS_VERBS]]) { for (const verb of table) { assert.ok(verb.description?.length, `${label} ${verb.name} has no description`); } diff --git a/test/tools.test.mjs b/test/tools.test.mjs index df37fa4..e73bedc 100644 --- a/test/tools.test.mjs +++ b/test/tools.test.mjs @@ -107,6 +107,47 @@ test("cloud CLIs resolve and are listed as workflow tools", () => { assert.deepEqual(resolveTool("GH"), ["gh", TOOLS.gh]); }); +test("Alpaca is a workflow tool installed from its official Go command", () => { + assert.deepEqual(resolveTool("ALPACA"), ["alpaca", TOOLS.alpaca]); + assert.deepEqual(TOOLS.alpaca.install, { + cmd: "go", + args: ["install", "github.com/alpacahq/cli/cmd/alpaca@latest"], + }); + assert.deepEqual(TOOLS.alpaca.binDirs, [path.join(homedir(), "go", "bin")]); + assert.match(toolList(), /alpaca/); +}); + +test("install alpaca delegates to the official Go package", async () => { + const root = tempDir("moshcode-install-alpaca-"); + const nativeBin = path.join(root, "bin"); + const capture = path.join(root, "go-args.json"); + mkdirSync(nativeBin); + writeExecutable(nativeBin, "go", ` +import fs from "node:fs"; +fs.writeFileSync(process.env.GO_CAPTURE, JSON.stringify(process.argv.slice(2))); +`); + + const result = await run(["install", "alpaca"], { + binDir: nativeBin, + env: { GO_CAPTURE: capture }, + }); + + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(readFileSync(capture, "utf8")), [ + "install", "github.com/alpacahq/cli/cmd/alpaca@latest", + ]); +}); + +test("install alpaca explains a missing Go prerequisite", async () => { + const emptyPath = tempDir("moshcode-install-alpaca-no-go-"); + const result = await run(["install", "alpaca"], { env: { PATH: emptyPath } }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /install failed/); + assert.match(result.stderr, /Go is required to install Alpaca/); + assert.match(result.stderr, /moshcode install alpaca/); +}); + test("railway installs from the official npm package", () => { // Railway's shell installer needs bash process substitution, which does not // survive `sh -c`, so the npm package is the portable path. diff --git a/test/trade.test.mjs b/test/trade.test.mjs new file mode 100644 index 0000000..e111b43 --- /dev/null +++ b/test/trade.test.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { chmodSync, existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { tradeArgs, tradeUsage } from "../src/trade.mjs"; + +const BIN = fileURLToPath(new URL("../bin/moshcode.mjs", import.meta.url)); + +test("trade translates market lookup shortcuts to Alpaca argv", () => { + assert.deepEqual(tradeArgs(["ticker", "aapl"]), { + args: ["asset", "get", "--symbol-or-asset-id", "AAPL"], + }); + assert.deepEqual(tradeArgs(["quote", "msft", "--feed", "iex"]), { + args: ["data", "latest-quote", "--symbol", "MSFT", "--feed", "iex"], + }); + assert.deepEqual(tradeArgs(["analysis", "nvda"]), { + args: ["data", "snapshot", "--symbol", "NVDA"], + }); +}); + +test("buy and sell preview by default and submit only when explicit", () => { + assert.deepEqual(tradeArgs(["buy", "aapl", "2"]), { + args: ["order", "submit", "--symbol", "AAPL", "--side", "buy", "--qty", "2", "--type", "market", "--dry-run"], + preview: true, + }); + assert.deepEqual(tradeArgs(["sell", "tsla", "0.5", "--type", "limit", "--limit-price", "300", "--submit"]), { + args: ["order", "submit", "--symbol", "TSLA", "--side", "sell", "--qty", "0.5", "--type", "limit", "--limit-price", "300"], + preview: false, + }); + assert.equal(tradeArgs(["buy", "AAPL", "zero"]).error, "trade buy quantity must be a positive number"); +}); + +test("buy and sell accept native qty/notional forms without allowing both", () => { + assert.deepEqual(tradeArgs(["buy", "aapl", "--notional", "100"]), { + args: ["order", "submit", "--symbol", "AAPL", "--side", "buy", "--type", "market", "--notional", "100", "--dry-run"], + preview: true, + }); + assert.deepEqual(tradeArgs(["sell", "aapl", "--qty=0.25", "--submit"]), { + args: ["order", "submit", "--symbol", "AAPL", "--side", "sell", "--type", "market", "--qty=0.25"], + preview: false, + }); + assert.match(tradeArgs(["buy", "AAPL"]).error, /quantity or --notional/); + assert.match(tradeArgs(["buy", "AAPL", "1", "--notional", "100"]).error, /accepts one of/); + assert.match(tradeArgs(["buy", "AAPL", "--notional", "0"]).error, /notional amount must be a positive/); + assert.match(tradeArgs(["buy", "AAPL", "--notional"]).error, /requires a positive number/); +}); + +test("portfolio, watchlist, account, login, and raw shortcuts preserve native args", () => { + assert.deepEqual(tradeArgs(["watch"]), { args: ["watchlist", "list"] }); + assert.deepEqual(tradeArgs(["watch", "add", "--watchlist-id", "w", "--symbol", "AAPL"]), { + args: ["watchlist", "add", "--watchlist-id", "w", "--symbol", "AAPL"], + }); + assert.deepEqual(tradeArgs(["positions"]), { args: ["position", "list"] }); + assert.deepEqual(tradeArgs(["orders", "cancel-all"]), { args: ["order", "cancel-all"] }); + assert.deepEqual(tradeArgs(["account"]), { args: ["account", "get"] }); + assert.deepEqual(tradeArgs(["login", "--api-key"]), { args: ["profile", "login", "--api-key"] }); + assert.deepEqual(tradeArgs(["raw", "data", "news", "--symbol", "AAPL"]), { + args: ["data", "news", "--symbol", "AAPL"], + }); + assert.equal(tradeArgs([]).usage, true); + assert.match(tradeUsage(), /--submit/); +}); + +test("moshcode trade invokes Alpaca with translated argv and preserves output", () => { + const binDir = mkdtempSync(path.join(tmpdir(), "moshcode-trade-")); + const alpaca = path.join(binDir, "alpaca"); + writeFileSync(alpaca, "#!/bin/sh\nprintf '%s\\n' \"$@\"\n"); + chmodSync(alpaca, 0o755); + + const result = spawnSync(process.execPath, [BIN, "trade", "buy", "AAPL", "3"], { + encoding: "utf8", + env: { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH || ""}` }, + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + assert.deepEqual(result.stdout.trim().split("\n"), [ + "order", "submit", "--symbol", "AAPL", "--side", "buy", "--qty", "3", "--type", "market", "--dry-run", + ]); +}); + +const REAL_ALPACA = path.join(homedir(), "go", "bin", process.platform === "win32" ? "alpaca.exe" : "alpaca"); + +test("installed Alpaca accepts the facade's real lookup and order argv", { + skip: !existsSync(REAL_ALPACA) && "Alpaca CLI is not installed in the default Go bin directory", +}, () => { + const env = { + ...process.env, + // Non-secret placeholders satisfy Alpaca's local auth gate. Every order + // keeps the facade-injected --dry-run, so this test performs no API call. + ALPACA_API_KEY: "PK_MOSHCODE_TEST", + ALPACA_SECRET_KEY: "MOSHCODE_TEST_ONLY", + }; + const run = (...args) => spawnSync(process.execPath, [BIN, "trade", ...args], { encoding: "utf8", env }); + + for (const args of [["ticker", "AAPL", "--schema"], ["quote", "AAPL", "--schema"]]) { + const result = run(...args); + assert.equal(result.status, 0, `${args.join(" ")}: ${result.stderr || result.stdout}`); + } + + for (const args of [["buy", "AAPL", "1"], ["buy", "AAPL", "--notional", "100"]]) { + const result = run(...args); + assert.equal(result.status, 0, `${args.join(" ")}: ${result.stderr || result.stdout}`); + const body = JSON.parse(result.stdout); + assert.equal(body.symbol, "AAPL"); + assert.equal(body.side, "buy"); + assert.equal(body.type, "market"); + } +}); diff --git a/test/uninstall.test.mjs b/test/uninstall.test.mjs index e7eb4da..5f1405a 100644 --- a/test/uninstall.test.mjs +++ b/test/uninstall.test.mjs @@ -59,6 +59,13 @@ test("the places a per-user installer legitimately writes are allowed", () => { } }); +test("a Go-installed Alpaca binary can be removed from the default GOPATH", () => { + const entry = { bin: "alpaca", install: { cmd: "go", args: ["install", "github.com/alpacahq/cli/cmd/alpaca@latest"] } }; + const plan = uninstallPlan(entry, { binPath: `${HOME}/go/bin/alpaca`, home: HOME }); + assert.equal(plan.kind, "binary"); + assert.deepEqual(plan.steps, [{ kind: "remove", path: `${HOME}/go/bin/alpaca` }]); +}); + test("a tool that is not there says so rather than failing", () => { const plan = uninstallPlan(scriptEntry, { binPath: null, home: HOME }); assert.equal(plan.kind, "absent"); diff --git a/test/upgrade-install-missing.test.mjs b/test/upgrade-install-missing.test.mjs index f83f7c1..3ae1e5c 100644 --- a/test/upgrade-install-missing.test.mjs +++ b/test/upgrade-install-missing.test.mjs @@ -37,9 +37,22 @@ function withPath(names, fn) { chmodSync(file, 0o755); } const before = process.env.PATH; + const entries = [...Object.values(ENGINES), ...Object.values(TOOLS)]; + const beforeBinDirs = entries.map((entry) => [entry, entry.binDirs]); process.env.PATH = dir; + // PATH is only half the lookup contract: tools installed into a known + // user-local directory (Turso, Alpaca) deliberately remain discoverable in + // an already-running shell. Blank those explicit directories too so a real + // local install cannot leak into this fake-machine test. + for (const entry of entries) entry.binDirs = []; try { return fn(); } - finally { process.env.PATH = before; } + finally { + process.env.PATH = before; + for (const [entry, binDirs] of beforeBinDirs) { + if (binDirs === undefined) delete entry.binDirs; + else entry.binDirs = binDirs; + } + } } const specOf = (target) => withPath([], () => planUpgrade([target]).items[0]); diff --git a/test/upgrade.test.mjs b/test/upgrade.test.mjs index 483f3f1..6af20dd 100644 --- a/test/upgrade.test.mjs +++ b/test/upgrade.test.mjs @@ -6,6 +6,7 @@ import test from "node:test"; import { ENGINES, upgradeSpec } from "../src/engines.mjs"; import { planUpgrade, runUpgrade, selfSpec } from "../src/upgrade.mjs"; +import { TOOLS } from "../src/tools.mjs"; // Same trick as withFakeTools, for the cases that turn on a target being // installed: only an installed target is asked to update itself, so the @@ -31,13 +32,21 @@ function withFakeTools(fn) { chmodSync(file, 0o755); } const before = process.env.PATH; + const beforeBinDirs = Object.values(TOOLS).map((tool) => [tool, tool.binDirs]); // REPLACE the PATH rather than prepending to it: "which tools are installed" // is the thing under test, so any real CLI on the developer's machine (gh, // doctl, tailscale…) would otherwise leak into the plan and make the // assertions depend on the host. process.env.PATH = dir; + for (const tool of Object.values(TOOLS)) tool.binDirs = []; try { return fn(); } - finally { process.env.PATH = before; } + finally { + process.env.PATH = before; + for (const [tool, binDirs] of beforeBinDirs) { + if (binDirs === undefined) delete tool.binDirs; + else tool.binDirs = binDirs; + } + } } test("upgrade tools selects installed workflow tools without self or engines", () => { From 010b8f0da42f9f4a4a3079c562f9a9262715aa10 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 4 Aug 2026 19:02:32 +0000 Subject: [PATCH 2/3] feat(socials): post to Bluesky and Nostr from the pit --- README.md | 18 +++++ apps/pwa/src/routes/socials.mjs | 124 ++++++++++++++++++++++++++++++++ apps/pwa/src/server.mjs | 2 + apps/pwa/test/socials.test.mjs | 24 +++++++ src/cli-schema.mjs | 4 ++ src/socials.mjs | 72 +++++++++++++++++++ src/tui.mjs | 25 +++++++ test/socials.test.mjs | 49 +++++++++++++ 8 files changed, 318 insertions(+) create mode 100644 apps/pwa/src/routes/socials.mjs create mode 100644 apps/pwa/test/socials.test.mjs create mode 100644 src/socials.mjs create mode 100644 test/socials.test.mjs diff --git a/README.md b/README.md index 9892903..b2874de 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,24 @@ 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. +### Social posting from the pit + +The pit can hand a prepared post to Bluesky or Nostr without storing either +account's credentials in MoshCode: + +```text +/socials +/post bsky "shipped it 🤘" +/post nostr "shipped it 🤘" +``` + +Bluesky opens its official compose intent. Nostr opens the MoshCode composer, +connects to a NIP-07 browser signer (or a NIP-46 bunker through +[`window.nostr.js`](https://github.com/fiatjaf/window.nostr.js)), signs a kind-1 +event, and publishes it to the displayed relays. Both flows leave the final +confirmation in the browser. If the pit is remote or headless, `/post` prints +the composer URL instead. + ## Browser terminal (`moshcode console`) A real terminal in the browser — arrow keys, history, full-screen TUIs — because diff --git a/apps/pwa/src/routes/socials.mjs b/apps/pwa/src/routes/socials.mjs new file mode 100644 index 0000000..760ca1b --- /dev/null +++ b/apps/pwa/src/routes/socials.mjs @@ -0,0 +1,124 @@ +// Browser-side social composers. The CLI only hands a draft to these pages; +// account authorization and the final publish stay in the user's browser. +import { Router } from "express"; +import { page, footer } from "../lib/html.mjs"; + +export const socialsRouter = Router(); + +export const NOSTR_RELAYS = [ + "wss://relay.damus.io", + "wss://nos.lol", + "wss://relay.primal.net", +]; + +export function nostrComposerPage() { + const relays = JSON.stringify(NOSTR_RELAYS); + const body = ` +
+
+
NOSTR · KIND 1
+

Post from the pit.

+

Your draft stayed in the URL fragment—it was never sent to MoshCode. Connect a browser signer, review the text, then publish it to the relays below.

+ +
+
Draft0 chars
+
+ +
+ + nothing is posted until you click +
+
+
+ +
+
Relayspublish to any that accept
+
+ ${NOSTR_RELAYS.map((relay) => `
${relay}
`).join("")} +
+
+
${footer} + + + + `; + + return page({ title: "moshcode ▸ post to Nostr", body }); +} + +socialsRouter.get("/socials/nostr", (_req, res) => { + res.type("html").send(nostrComposerPage()); +}); diff --git a/apps/pwa/src/server.mjs b/apps/pwa/src/server.mjs index 7b94e90..9cc8dd7 100644 --- a/apps/pwa/src/server.mjs +++ b/apps/pwa/src/server.mjs @@ -14,6 +14,7 @@ import { cliRouter } from "./routes/cli.mjs"; import { sessionsRouter } from "./routes/sessions.mjs"; import { pagesRouter } from "./routes/pages.mjs"; import { moshpitRouter } from "./routes/moshpit.mjs"; +import { socialsRouter } from "./routes/socials.mjs"; const app = express(); app.disable("x-powered-by"); @@ -54,6 +55,7 @@ app.use(creditsRouter); app.use(cliRouter); // /cli/authorize, /cli/token, /api/me app.use(sessionsRouter); // /sessions (live CLI mirror) + /api/sessions app.use(pagesRouter); // /app, /settings +app.use(socialsRouter); // public browser composers used by /post app.use(moshpitRouter); // /pit + /api/moshpit/* — the namespace app.use((req, res) => res.status(404).type("html").send( diff --git a/apps/pwa/test/socials.test.mjs b/apps/pwa/test/socials.test.mjs new file mode 100644 index 0000000..1cf36bd --- /dev/null +++ b/apps/pwa/test/socials.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { NOSTR_RELAYS, nostrComposerPage } from "../src/routes/socials.mjs"; + +test("Nostr composer loads the pinned NIP-07/NIP-46 bridge", () => { + const html = nostrComposerPage(); + assert.match(html, /window\.nostr\.js@0\.5\.0\/dist\/window\.nostr\.min\.js/); + assert.match(html, /window\.nostr\.getPublicKey\(\)/); + assert.match(html, /window\.nostr\.signEvent\(/); +}); + +test("Nostr composer creates kind-1 events and publishes to every named relay", () => { + const html = nostrComposerPage(); + assert.match(html, /kind: 1/); + assert.match(html, /\["EVENT", event\]/); + for (const relay of NOSTR_RELAYS) assert.ok(html.includes(relay), `${relay} is not rendered`); +}); + +test("Nostr composer reads the draft from the fragment", () => { + const html = nostrComposerPage(); + assert.match(html, /location\.hash\.slice\(1\)/); + assert.doesNotMatch(html, /location\.search/); +}); diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 42ddead..d9c9914 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -490,6 +490,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: "socials", aliases: ["social"], pitOnly: true, + description: "list social networks available for posting" }, + { name: "post", args: ' "message"', pitOnly: true, + description: "open a social composer with a prepared post" }, { name: "install", args: "", cli: "install", description: "install an engine or workflow tool" }, { name: "upgrade", aliases: ["update"], args: "[name…]", cli: "upgrade", diff --git a/src/socials.mjs b/src/socials.mjs new file mode 100644 index 0000000..d8b4337 --- /dev/null +++ b/src/socials.mjs @@ -0,0 +1,72 @@ +import { canOpenBrowser, openBrowser } from "./open-url.mjs"; + +const DEFAULT_APP = "https://app.moshcode.sh"; + +export const SOCIALS = [ + { + name: "bluesky", + aliases: ["bsky"], + description: "official Bluesky browser composer", + }, + { + name: "nostr", + aliases: [], + description: "NIP-07/NIP-46 browser signer + relay publish", + }, +]; + +export function resolveSocial(name) { + const wanted = String(name ?? "").trim().toLowerCase(); + return SOCIALS.find((social) => + social.name === wanted || social.aliases.includes(wanted)) ?? null; +} + +function appOrigin(env = process.env) { + return String(env.MOSHCODE_API || DEFAULT_APP).replace(/\/+$/, ""); +} + +/** + * Build the browser hand-off without opening anything. Nostr keeps the draft + * in the fragment so it never reaches app.moshcode.sh access logs or Referer + * headers; the composer reads it entirely in the browser. + */ +export function socialPostUrl(name, message, { env = process.env } = {}) { + const social = resolveSocial(name); + if (!social) return null; + const text = String(message ?? ""); + if (social.name === "bluesky") { + return `https://bsky.app/intent/compose?${new URLSearchParams({ text })}`; + } + return `${appOrigin(env)}/socials/nostr#${new URLSearchParams({ text })}`; +} + +export function socialRoster() { + return SOCIALS.map((social) => ({ ...social, aliases: [...social.aliases] })); +} + +/** + * Open a provider composer. Posting remains an explicit browser confirmation: + * Bluesky requires it, and Nostr asks the browser signer before relay publish. + */ +export function postSocial(args, { + env = process.env, + canOpen = canOpenBrowser, + open = openBrowser, +} = {}) { + const [requested, ...words] = Array.isArray(args) ? args : []; + const social = resolveSocial(requested); + if (!requested) return { ok: false, error: 'usage: /post "message"' }; + if (!social) { + return { + ok: false, + error: `unknown social "${requested}". try: ${SOCIALS.map((entry) => entry.name).join(", ")}`, + }; + } + + const message = words.join(" ").trim(); + if (!message) return { ok: false, error: 'usage: /post "message"' }; + + const url = socialPostUrl(social.name, message, { env }); + const opened = Boolean(canOpen() && open(url)); + return { ok: true, social: social.name, message, url, opened }; +} diff --git a/src/tui.mjs b/src/tui.mjs index bb9ba91..fbf337d 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -10,6 +10,7 @@ import path from "node:path"; import { ENGINES, agentLaunchArgs, resolveEngine, engineStatus, openSession } from "./engines.mjs"; import { TOOLS, resolveTool, toolStatus, openTool } from "./tools.mjs"; import { tradeArgs, tradeUsage } from "./trade.mjs"; +import { postSocial, socialRoster } from "./socials.mjs"; import { runUpgrade } from "./upgrade.mjs"; import { locate, tilde } from "./pwd.mjs"; import { createPrd, listPrds, authoringPrompt } from "./prd.mjs"; @@ -146,6 +147,15 @@ function printTools() { console.log(ash(" → ") + acid("https://dev.profullstack.com/")); } +function printSocials() { + console.log(bone(" socials") + ash(" — compose with ") + acid('/post "message"')); + for (const social of socialRoster()) { + const aliases = social.aliases.length ? ` (${social.aliases.join(", ")})` : ""; + console.log(` ${acid("●")} ${bone(social.name.padEnd(9))} ${ash(social.description + aliases)}`); + } + console.log(ash(" the browser always asks you to confirm before anything is published")); +} + /** * The moshscript vocabulary, split the way the CLI's help splits it. * @@ -622,6 +632,21 @@ export async function tui() { rl = mkrl(); continue; } + if (cmd === "socials" || cmd === "social") { + printSocials(); + continue; + } + if (cmd === "post") { + const result = postSocial(rest); + if (!result.ok) { console.log(err(result.error)); continue; } + if (result.opened) { + console.log(ok(`opened the ${result.social} composer — confirm the post in your browser 🤘`)); + } else { + console.log(info(`open this ${result.social} composer in a browser:`)); + console.log(` ${result.url}`); + } + continue; + } // Bare engine name → open it. const resolved = resolveEngine(cmd); if (resolved) { diff --git a/test/socials.test.mjs b/test/socials.test.mjs new file mode 100644 index 0000000..99b8a51 --- /dev/null +++ b/test/socials.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { postSocial, resolveSocial, socialPostUrl, socialRoster } from "../src/socials.mjs"; + +test("social roster includes Bluesky and Nostr with aliases", () => { + assert.deepEqual(socialRoster().map((social) => social.name), ["bluesky", "nostr"]); + assert.equal(resolveSocial("bsky")?.name, "bluesky"); + assert.equal(resolveSocial("NOSTR")?.name, "nostr"); + assert.equal(resolveSocial("twitter"), null); +}); + +test("Bluesky posts use the official compose intent", () => { + const url = new URL(socialPostUrl("bluesky", "hello & goodbye")); + assert.equal(url.origin + url.pathname, "https://bsky.app/intent/compose"); + assert.equal(url.searchParams.get("text"), "hello & goodbye"); +}); + +test("Nostr drafts stay in the URL fragment and honor a self-hosted app", () => { + const url = new URL(socialPostUrl("nostr", "draft #1", { + env: { MOSHCODE_API: "https://mosh.example/" }, + })); + assert.equal(url.origin + url.pathname, "https://mosh.example/socials/nostr"); + assert.equal(url.search, ""); + assert.equal(new URLSearchParams(url.hash.slice(1)).get("text"), "draft #1"); +}); + +test("postSocial opens a prepared composer when a browser is available", () => { + let opened = ""; + const result = postSocial(["bsky", "two", "words"], { + canOpen: () => true, + open: (url) => { opened = url; return true; }, + }); + + assert.equal(result.ok, true); + assert.equal(result.social, "bluesky"); + assert.equal(result.message, "two words"); + assert.equal(result.opened, true); + assert.equal(opened, result.url); +}); + +test("postSocial reports missing messages and unknown networks without opening", () => { + let opens = 0; + const options = { canOpen: () => true, open: () => { opens++; return true; } }; + assert.match(postSocial([], options).error, /usage: \/post/); + assert.match(postSocial(["nostr"], options).error, /usage: \/post/); + assert.match(postSocial(["twitter", "hello"], options).error, /unknown social/); + assert.equal(opens, 0); +}); From e3d5b474254ff5cbd7ed203404e9285c91a9f1af Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 4 Aug 2026 19:04:59 +0000 Subject: [PATCH 3/3] feat(dns): resolve third-level names through owner wildcards --- apps/pwa/src/lib/moshpit-name.mjs | 62 ++++++++++++++ apps/pwa/src/moshpit.mjs | 110 +++++++++++++++++++++--- apps/pwa/src/routes/moshpit.mjs | 19 +++-- apps/pwa/test/moshpit-name.test.mjs | 36 +++++++- apps/pwa/test/moshpit-records.test.mjs | 102 +++++++++++++++++++++++ docs/hosting-a-moshpit-name.md | 8 +- src/dns.mjs | 77 ++++++++++++++--- test/dns-address-answer.test.mjs | 72 ++++++++++++++++ test/dns-nodata.test.mjs | 4 +- test/dns-records.test.mjs | 111 +++++++++++++++++++++++-- test/dns-resolve-json.test.mjs | 6 +- test/dns.test.mjs | 10 ++- test/doh.test.mjs | 46 ++++++++++ test/templates.test.mjs | 4 +- 14 files changed, 621 insertions(+), 46 deletions(-) diff --git a/apps/pwa/src/lib/moshpit-name.mjs b/apps/pwa/src/lib/moshpit-name.mjs index f7be06c..7b52d1a 100644 --- a/apps/pwa/src/lib/moshpit-name.mjs +++ b/apps/pwa/src/lib/moshpit-name.mjs @@ -95,6 +95,68 @@ export function parseMoshpitName(input) { return { label: normalizedLabel, tld: normalizedTld }; } +/** + * A label as the records table may hold it: an ordinary label, or the wildcard + * form `*.