From 0376ee0e6365e406082fb4a1961c82a7233f9e2b Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 29 Aug 2026 07:10:03 +0000 Subject: [PATCH] feat: /timer and /billing hand over to their own CLIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracking time and sending an invoice are not moshcode ideas. They are useful under any agentic CLI, and on Windows, where moshcode does not go. So both now ship standalone, published under @profullstack: npm install -g @profullstack/timer @profullstack/billing moshcode install timer billing With them installed, /timer and /billing hand the command straight to the CLI, the way /gh conducts gh. Without them the in-process implementation runs exactly as before, so upgrading changes nothing until you choose to install, and MOSHCODE_BUILTIN_BILLING=1 pins the built-in either way. Nothing is deleted here. The two are deliberately NOT kept in sync — a second copy of a billing model is a copy that drifts, and the published package is the one that gets the fixes — but removing src/timer.mjs and src/billing.mjs is a separate decision, and PRD 0012 shipped four hours ago. The standalone billing carries the same rate model this repo parses ($100/hour/agent/upto:4) and bills AGENT-HOURS, so quantity x rate equals the line amount and a client can check it by hand. `billing import` brings across a ledger that started in ~/.moshcode/business.json, shows the plan first, and never touches the originals. /client, /rate, /payments and /team stay here: the rails and the permission model are moshcode integration, which is where they belong. Two plugins join the marketplace, fronting the CLIs rather than this repo's code. Their command files carry the rules that matter — never stop somebody else's clock, never issue an invoice unprompted, always --dry-run first. SHARED_COMMANDS is now keyed by family. The flat list only ever made sense for the stocks/crypto pair: `quote` and `lookup` are market words, and requiring them of a timer would force two commands nobody would run. A new test fails if a family ever drops to one plugin, which is how that rule quietly stops testing anything. Found by running it rather than by reading it: openPassthrough resolves { ok, code, signal }, not a number, so assigning it to process.exitCode threw ERR_INVALID_ARG_TYPE after the child had already printed — a successful run ending in a stack trace. exitCodeOf() normalises it, and a signal-killed child is 1 rather than 0. Suite is 2273 passing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KVUZsx4WBZK5rnJJFGgwF5 --- .claude-plugin/marketplace.json | 24 +++++ README.md | 20 ++++ bin/moshcode.mjs | 12 +++ plugins/billing/.claude-plugin/plugin.json | 13 +++ plugins/billing/README.md | 42 ++++++++ plugins/billing/commands/hours.md | 37 +++++++ plugins/billing/commands/invoice.md | 39 +++++++ plugins/billing/commands/rate.md | 40 ++++++++ plugins/billing/commands/report.md | 28 +++++ plugins/timer/.claude-plugin/plugin.json | 13 +++ plugins/timer/README.md | 36 +++++++ plugins/timer/commands/report.md | 32 ++++++ plugins/timer/commands/start.md | 33 ++++++ plugins/timer/commands/status.md | 27 +++++ plugins/timer/commands/stop.md | 30 ++++++ src/business-delegate.mjs | 85 ++++++++++++++++ src/plugins.mjs | 46 +++++++-- src/tools.mjs | 18 ++++ src/tui.mjs | 27 ++++- test/business-delegate.test.mjs | 113 +++++++++++++++++++++ test/plugins.test.mjs | 31 ++++-- 21 files changed, 729 insertions(+), 17 deletions(-) create mode 100644 plugins/billing/.claude-plugin/plugin.json create mode 100644 plugins/billing/README.md create mode 100644 plugins/billing/commands/hours.md create mode 100644 plugins/billing/commands/invoice.md create mode 100644 plugins/billing/commands/rate.md create mode 100644 plugins/billing/commands/report.md create mode 100644 plugins/timer/.claude-plugin/plugin.json create mode 100644 plugins/timer/README.md create mode 100644 plugins/timer/commands/report.md create mode 100644 plugins/timer/commands/start.md create mode 100644 plugins/timer/commands/status.md create mode 100644 plugins/timer/commands/stop.md create mode 100644 src/business-delegate.mjs create mode 100644 test/business-delegate.test.mjs diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 8592a397..4dc81e83 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -30,6 +30,30 @@ }, "homepage": "https://github.com/moshcoder/moshcode#crypto", "keywords": ["crypto", "bitcoin", "markets", "prices", "advis0r"] + }, + { + "name": "timer", + "description": "Track time against projects from inside your engine, backed by @profullstack/timer: start and stop a clock, log time you forgot, and report billable hours with the agent count that priced them.", + "source": "./plugins/timer", + "category": "productivity", + "author": { + "name": "moshcoder", + "url": "https://moshcode.sh" + }, + "homepage": "https://github.com/profullstack/timer#readme", + "keywords": ["timer", "time-tracking", "billable", "hours", "profullstack"] + }, + { + "name": "billing", + "description": "Turn tracked hours into an invoice, backed by @profullstack/billing: clients, rate cards written as contract sentences, agent-hour arithmetic, and invoices rendered to HTML you can print to PDF.", + "source": "./plugins/billing", + "category": "productivity", + "author": { + "name": "moshcoder", + "url": "https://moshcode.sh" + }, + "homepage": "https://github.com/profullstack/billing#readme", + "keywords": ["billing", "invoice", "clients", "rates", "profullstack"] } ] } diff --git a/README.md b/README.md index c73431a5..91246208 100644 --- a/README.md +++ b/README.md @@ -949,6 +949,26 @@ Every agentic CLI helps you do the work. This one also bills for it. Six words, each useful on its own — the timer needs no client, the rate needs no gateway (PRD [0012](prd/0012-billing-baked-into-the-agent-cli.md)). +> **`/timer` and `/billing` now prefer their own CLIs.** Tracking time and +> sending an invoice are not moshcode ideas — they are useful under any agentic +> CLI, and on Windows, where moshcode does not go. So they also ship standalone: +> [`@profullstack/timer`](https://github.com/profullstack/timer) and +> [`@profullstack/billing`](https://github.com/profullstack/billing). +> +> ```sh +> moshcode install timer billing # or: npm install -g @profullstack/timer @profullstack/billing +> ``` +> +> With them installed, `/timer` and `/billing` hand the command straight to the +> CLI, the way `/gh` conducts `gh`. Without them, the in-process implementation +> below runs exactly as it always has, so upgrading changes nothing until you +> choose to install. `MOSHCODE_BUILTIN_BILLING=1` pins the built-in either way. +> +> The standalone billing carries the same rate model (`$100/hour/agent/upto:4`) +> and bills **agent-hours**, and `billing import` brings across a ledger that +> started in `~/.moshcode/`. `/client`, `/rate`, `/payments` and `/team` stay +> here: the rails and the permission model are moshcode integration. + ```sh moshcode timer on acme --task "batch payments" --agents auto # auto counts the herd moshcode timer off # → 1h 12m, $480.00 diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs index c8f0d8bf..509e42f5 100755 --- a/bin/moshcode.mjs +++ b/bin/moshcode.mjs @@ -423,8 +423,15 @@ async function main() { // the startup path of `moshcode claude`, which is what this binary is mostly // asked to do. if (cmd === "timer") { + // @profullstack/timer when it is installed, the built-in otherwise. See + // src/business-delegate.mjs for why the external one wins. + const { delegate, installHint } = await import("../src/business-delegate.mjs"); + const handed = await delegate("timer", rest, {}); + if (handed.delegated) { process.exitCode = handed.code; return; } const { timerCommand } = await import("../src/timer.mjs"); process.exitCode = (await timerCommand(rest)) || 0; + const hint = installHint("timer"); + if (hint) process.stderr.write(`${hint}\n`); return; } if (cmd === "client" || cmd === "business" || cmd === "merchant" || cmd === "customer") { @@ -443,8 +450,13 @@ async function main() { return; } if (cmd === "billing" || cmd === "invoice") { + const { delegate, installHint } = await import("../src/business-delegate.mjs"); + const handed = await delegate(cmd, rest, {}); + if (handed.delegated) { process.exitCode = handed.code; return; } const { billingCommand } = await import("../src/billing.mjs"); process.exitCode = billingCommand(rest) || 0; + const hint = installHint(cmd); + if (hint) process.stderr.write(`${hint}\n`); return; } if (cmd === "payments") { diff --git a/plugins/billing/.claude-plugin/plugin.json b/plugins/billing/.claude-plugin/plugin.json new file mode 100644 index 00000000..9d0e210a --- /dev/null +++ b/plugins/billing/.claude-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://anthropic.com/claude-code/plugin.schema.json", + "name": "billing", + "description": "Turn tracked hours into an invoice, backed by @profullstack/billing: clients, rate cards written as contract sentences, agent-hour arithmetic, and invoices rendered to HTML you can print to PDF.", + "version": "0.1.0", + "author": { + "name": "moshcoder", + "url": "https://moshcode.sh" + }, + "homepage": "https://github.com/profullstack/billing#readme", + "license": "MIT", + "keywords": ["billing", "invoice", "clients", "rates", "profullstack"] +} diff --git a/plugins/billing/README.md b/plugins/billing/README.md new file mode 100644 index 00000000..be0afa86 --- /dev/null +++ b/plugins/billing/README.md @@ -0,0 +1,42 @@ +# billing + +Slash commands for +[`@profullstack/billing`](https://github.com/profullstack/billing) — clients, +rates and invoices, built on the hours `@profullstack/timer` tracked. + +``` +/billing:rate set acme '$100/hour/agent/upto:4' +/billing:hours --client acme --month +/billing:invoice --client acme --from-timer --month +/billing:report +``` + +## Install the CLI + +```sh +npm install -g @profullstack/billing +``` + +or, inside moshcode: + +``` +moshcode install billing +``` + +## What it is for + +A rate is the sentence from the contract, parsed. `$100/hour/agent/upto:4` means +four agents cost four hundred an hour and so do six, and the invoice bills +**agent-hours** so the client can check the line by hand: `quantity × rate` +always equals `amount`. + +Two rules the shape enforces. The same hour never reaches two invoices — each +invoice records the timer entry ids it covers, so voiding one releases them. +And creating an invoice is a proposal: `--dry-run` validates the whole thing and +writes nothing, a new invoice is a draft, and nothing is ever emailed. + +## Coming from moshcode + +moshcode used to keep this layer internally. `billing import` brings it across +from `~/.moshcode/business.json` and `timers.json`, shows the plan first, and +never modifies the originals. diff --git a/plugins/billing/commands/hours.md b/plugins/billing/commands/hours.md new file mode 100644 index 00000000..eb8003df --- /dev/null +++ b/plugins/billing/commands/hours.md @@ -0,0 +1,37 @@ +--- +description: Tracked hours not yet on an invoice, priced at the client's rate. +argument-hint: --client [--month|--week] +allowed-tools: Bash(billing hours:*), Bash(billing client:*), Bash(billing rate:*) +--- + +## Task + +```bash +billing hours $ARGUMENTS --json +``` + +This is the preview of what `billing invoice new --from-timer` would bill. Same +filters, same grouping, same arithmetic — it writes nothing. + +## Reading the response + +- `items[]` — the line items, each with `description`, `quantity` (in the rate's + own billing unit), `unitPriceMajor`, `amount`, `hours` and `timerIds`. +- `unit` — what `quantity` is measured in: `hours`, or `agent-hours` when the + rate is priced per agent. +- `hours` / `units` / `subtotal` — the totals. +- `skipped` — `{ running, unbillable, alreadyBilled }`. + +## Rules + +- **Always read `skipped.running` back to the user.** Those hours are not + missing, they are on a clock that is still ticking and become billable the + moment it stops. "Nothing to bill" is misleading when the real answer is "stop + the clock first". +- Exit 3 means no unbilled hours matched, or the client does not exist. Read the + message; it distinguishes them. +- When `unit` is `agent-hours`, explain the multiplier if the user seems + surprised: 3 hours with 2 agents is 6 agent-hours. `quantity * unitPrice` + always equals `amount`. +- If there is no rate, the error says so and names the command that sets one. + Do not invent a rate to get past it. diff --git a/plugins/billing/commands/invoice.md b/plugins/billing/commands/invoice.md new file mode 100644 index 00000000..7614c897 --- /dev/null +++ b/plugins/billing/commands/invoice.md @@ -0,0 +1,39 @@ +--- +description: Draft an invoice from tracked time or fixed line items, for a person to approve. +argument-hint: --client --from-timer --month +allowed-tools: Bash(billing invoice:*), Bash(billing hours:*), Bash(billing client:*), Bash(billing rate:*) +--- + +## Task + +Propose an invoice for `$ARGUMENTS`. **Always dry-run first:** + +```bash +billing invoice new $ARGUMENTS --dry-run --json +``` + +Show the user what it would create. Only run it again without `--dry-run` when +they have said to. + +Line items come from `--from-timer` (tracked hours), from `--item +"Description|quantity|price"`, or both on the same invoice. + +## Rules + +- **Creating an invoice is a business action. Do not write one unprompted.** + `--dry-run` builds and validates the entire invoice — the rate lookup and the + double-billing check included — so a dry run that succeeds means the real one + will. There is no reason to skip it. +- A new invoice is a **draft**. `billing invoice mark sent` is a separate, + deliberate step and this tool never emails anything. Do not mark an invoice + sent or paid unless the user asked. +- Read `amounts` (decimal) when talking to a person; the bare `total` is in + minor units. +- Exit 3 on `--from-timer` means there were no unbilled hours. Check + `billing hours` and report `skipped.running` rather than concluding there is + nothing to bill. +- The same hour cannot reach two invoices: each invoice records the timer entry + ids it covers. If the user wants to re-bill something, voiding the old invoice + releases those hours. +- Prefer `billing invoice render --format html --out ` when they want + something to send: it is one self-contained file that prints to PDF. diff --git a/plugins/billing/commands/rate.md b/plugins/billing/commands/rate.md new file mode 100644 index 00000000..6383cc8d --- /dev/null +++ b/plugins/billing/commands/rate.md @@ -0,0 +1,40 @@ +--- +description: Read or set what your time costs, written the way the contract says it. +argument-hint: "[set '$100/hour/agent/upto:4']" +allowed-tools: Bash(billing rate:*), Bash(billing client:*) +--- + +## Task + +```bash +billing rate $ARGUMENTS --json +``` + +With no arguments this lists every rate. `set`, `show` and `rm` take a target, +which is either a client handle or `default`. + +## The spec + +A price, then any of these in any order: + +- a period: `hour`, `day` (8h), `week` (40h), `month` (160h), `project`, `task` +- a unit that gets multiplied: `agent`, `seat`, `person`, `team` +- `upto:N` to cap the multiplier, `min:N` for a minimum billed period + +`$100/hour/agent/upto:4` means four agents cost four hundred an hour, and so do +six. `0.5 SOL/day`, `250 USDC/task` and `$5000/project` all parse too. + +## Rules + +- **Do not set or change a rate unless the user asked.** This is the number in + somebody's contract. +- Read `describes` back to them when confirming — it is the rate as a sentence, + and it is how you catch a spec that parsed differently from how it was meant. +- Settlement (`--prefer SOL --accept fiat`) is deliberately separate from the + price. The number in the contract does not change because the rail did, so do + not "convert" a rate to a preferred ticker. +- A price given in a ticker invoices in that ticker. Do not turn `0.5 SOL` into + a dollar figure: nobody computed that number. +- If a spec is rejected, the error names the words that are allowed. Fix the + spec rather than falling back to a bare number, which would silently mean + "per hour, flat". diff --git a/plugins/billing/commands/report.md b/plugins/billing/commands/report.md new file mode 100644 index 00000000..79f9f407 --- /dev/null +++ b/plugins/billing/commands/report.md @@ -0,0 +1,28 @@ +--- +description: What has been billed, collected, and what is still owed. +argument-hint: "[--client ] [--year|--month]" +allowed-tools: Bash(billing report:*), Bash(billing invoice:*) +--- + +## Task + +```bash +billing report $ARGUMENTS --json +``` + +## Reading the response + +- `totals` — `billed`, `collected`, `outstanding`, `overdue`, `draft`, all as + decimal numbers in `currency`. +- `byClient[]` — the same figures per client, biggest outstanding first. + +## Rules + +- **`draft` is not money anybody owes you.** A draft invoice has not been + issued, so keep it out of any "you are owed X" sentence and name it + separately. +- `overdue` is derived from the due date at read time, never stored. It is a + subset of `outstanding`, not an addition to it — do not sum them. +- Windows apply to the **issue date** here, not to when the work was done. +- For the invoices behind a figure, use `billing invoice list --overdue --json` + or `--status sent`. Do not guess at which invoices make up a total. diff --git a/plugins/timer/.claude-plugin/plugin.json b/plugins/timer/.claude-plugin/plugin.json new file mode 100644 index 00000000..a56770b6 --- /dev/null +++ b/plugins/timer/.claude-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://anthropic.com/claude-code/plugin.schema.json", + "name": "timer", + "description": "Track time against projects from inside your engine, backed by @profullstack/timer: start and stop a clock, log time you forgot, and report billable hours with the agent count that priced them.", + "version": "0.1.0", + "author": { + "name": "moshcoder", + "url": "https://moshcode.sh" + }, + "homepage": "https://github.com/profullstack/timer#readme", + "license": "MIT", + "keywords": ["timer", "time-tracking", "billable", "hours", "profullstack"] +} diff --git a/plugins/timer/README.md b/plugins/timer/README.md new file mode 100644 index 00000000..83471219 --- /dev/null +++ b/plugins/timer/README.md @@ -0,0 +1,36 @@ +# timer + +Slash commands for [`@profullstack/timer`](https://github.com/profullstack/timer) +— a time tracker that runs on Linux, macOS and Windows, and answers `--json` on +every command so an agent can clock its own work. + +``` +/timer:start acme fix the login redirect +/timer:status +/timer:stop +/timer:report --week --group day +``` + +## Install the CLI + +```sh +npm install -g @profullstack/timer +``` + +or, inside moshcode: + +``` +moshcode install timer +``` + +## What it is for + +An hour of agentic work is an hour times however many engines ran in it, so an +entry carries an agent count (`--agents 4`). `@profullstack/billing` multiplies +by it when the rate says to and ignores it when the rate is flat. + +Several clocks may run at once. That is deliberate: parallel agents each track +their own work and do not stop each other. + +The timesheet is one JSON file at `~/.profullstack/timer/timesheet.json`, and +billing reads it directly. diff --git a/plugins/timer/commands/report.md b/plugins/timer/commands/report.md new file mode 100644 index 00000000..682ad3ae --- /dev/null +++ b/plugins/timer/commands/report.md @@ -0,0 +1,32 @@ +--- +description: Totals for a period, grouped by project, task, day, tag or agent. +argument-hint: "[--week|--month] [--group project|task|day|tag|agent]" +allowed-tools: Bash(timer report:*), Bash(timer log:*), Bash(timer projects:*) +--- + +## Task + +```bash +timer report $ARGUMENTS --json +``` + +Windows: `--today`, `--yesterday`, `--week` (from Monday), `--month`, `--year`, +or explicit `--since` / `--until`. Groups: `project` (default), `task`, `day`, +`tag`, `agent`, `none`. + +## Reading the response + +`rows[]` each carry `key`, `entries`, `hours` and `billableHours`. `totals` has +the same figures for the whole window. + +## Rules + +- **A window compares against the entry's start, and `--until` is exclusive.** + An entry that ran past midnight belongs to the day it began on. Say so if the + user questions a boundary rather than guessing at a bug. +- Report `hours` and `billableHours` separately whenever they differ. +- `timer log` is the command for the entries behind a number. Reach for it when + the user asks why a total looks the way it does. +- Do not convert hours into money here. The rate lives in + `@profullstack/billing`, which knows about agent multipliers and caps; a + hours-times-rate figure invented here will disagree with the invoice. diff --git a/plugins/timer/commands/start.md b/plugins/timer/commands/start.md new file mode 100644 index 00000000..ddb0a5b0 --- /dev/null +++ b/plugins/timer/commands/start.md @@ -0,0 +1,33 @@ +--- +description: Start the clock on a project, optionally recording how many agents are working. +argument-hint: [task words…] +allowed-tools: Bash(timer start:*), Bash(timer status:*), Bash(timer on:*) +--- + +## Task + +Start a clock for `$ARGUMENTS`. + +```bash +timer start $ARGUMENTS --json +``` + +The first word is the project; everything after it is the task, so no quoting is +needed. Useful flags: + +- `--agents N` — how many engines are working. An agent-priced rate multiplies + by this, so it is the difference between a $400 afternoon and a $1,600 one. +- `--at 09:15` or `--at -20m` — the clock you meant to start earlier. +- `--tag`, `--note`, `--no-billable`. + +## Rules + +- **Do not pass `--switch` unless the user asked to stop their other clocks.** + Several clocks running at once is normal here: parallel agents each track + their own work, and stopping someone else's is not recoverable from the log. +- If `started.alsoRunning` comes back non-empty, mention how many other clocks + are running. Do not stop them. +- Report the entry id — `timer stop --id ` needs it, and so does the user if + they want to correct the entry later. +- If the command exits 2, the command line was wrong; read the error and fix it + rather than retrying the same thing. diff --git a/plugins/timer/commands/status.md b/plugins/timer/commands/status.md new file mode 100644 index 00000000..fa354c1b --- /dev/null +++ b/plugins/timer/commands/status.md @@ -0,0 +1,27 @@ +--- +description: What is running right now, and what today adds up to. +allowed-tools: Bash(timer status:*), Bash(timer log:*) +--- + +## Task + +```bash +timer status --json +``` + +## Reading the response + +- `running[]` — every live clock, each with `seconds` counting up to now. +- `today` — totals for entries that *started* today: `hours` and `billableHours`. +- `dataFile` — where the timesheet lives, worth quoting if the user is surprised + by what is or is not in it. + +## Rules + +- An empty `running` array is a normal answer. Exit status is 0 either way, so + never report "nothing running" as a failure. +- `hours` and `billableHours` differ when something is marked `--no-billable`. + If they differ, give both — "how long did this take" and "what can I charge + for it" are different questions. +- A running clock's duration is still moving. Do not present it as a final + figure, and note that billing will not invoice it until it is stopped. diff --git a/plugins/timer/commands/stop.md b/plugins/timer/commands/stop.md new file mode 100644 index 00000000..63ca997c --- /dev/null +++ b/plugins/timer/commands/stop.md @@ -0,0 +1,30 @@ +--- +description: Stop a running clock and report what it came to. +argument-hint: "[id]" +allowed-tools: Bash(timer stop:*), Bash(timer off:*), Bash(timer status:*) +--- + +## Task + +Stop the clock for `$ARGUMENTS` (empty means the newest one). + +```bash +timer stop $ARGUMENTS --json +``` + +Targets: nothing (the most recently started clock), an id or an unambiguous +prefix of one, `--project

`, or `--all`. + +## Reading the response + +`stopped[]` carries `seconds`, `hours` and `agents` for each entry closed. +`hours` is the figure an invoice uses. + +## Rules + +- **`stopped: []` with exit 0 means nothing was running.** That is an answer, + not a failure — say so plainly and do not retry or treat it as an error. +- Do not pass `--all` unless the user asked to stop everything. Other clocks may + belong to other agents. +- `--at` can backdate the stop, but a stop earlier than the start is refused + rather than clamped to zero. If that happens, the entry is untouched. diff --git a/src/business-delegate.mjs b/src/business-delegate.mjs new file mode 100644 index 00000000..13c63b47 --- /dev/null +++ b/src/business-delegate.mjs @@ -0,0 +1,85 @@ +// Where /timer and /billing actually run. +// +// Both started life inside moshcode (PRD 0012) and now also exist as their own +// cross-platform CLIs — @profullstack/timer and @profullstack/billing — because +// neither is a moshcode idea: tracking time and sending an invoice are things +// you want under any agentic CLI, and on Windows, where moshcode does not go. +// +// So the rule here is: if the real CLI is installed, moshcode conducts it, the +// same way /gh conducts gh. The built-in implementation stays as the fallback +// for a machine that has not installed it yet, so nothing breaks on upgrade and +// nobody has to install anything to keep working. +// +// The two are NOT kept in sync, and that is the point of preferring the +// external one: a second copy of a billing model is a copy that drifts, and the +// published package is the one that gets the fixes. +import { isInstalled } from "./engines.mjs"; +import { TOOLS, openTool } from "./tools.mjs"; + +/** Commands that have an external CLI, and the TOOLS key that owns it. */ +export const DELEGATED = { timer: "timer", billing: "billing", invoice: "billing" }; + +/** + * Force the in-process implementation. + * + * An escape hatch rather than a setting: somebody debugging a difference + * between the two needs to run the built-in one on a box where the CLI is + * installed, and that is the whole reason this exists. + */ +export function builtinForced() { + return /^(1|true|yes)$/i.test(String(process.env.MOSHCODE_BUILTIN_BILLING || "")); +} + +/** The external CLI for a command, if this machine has it. */ +export function externalFor(cmd) { + if (builtinForced()) return null; + const key = DELEGATED[String(cmd || "").toLowerCase()]; + if (!key) return null; + const tool = TOOLS[key]; + if (!tool || !isInstalled(tool.bin, tool.binDirs)) return null; + return { key, tool }; +} + +/** + * Hand a command to its CLI, or report that there is nothing to hand it to. + * + * Returns `{ delegated: false }` when the CLI is absent so the caller can fall + * through to the built-in rather than failing — a missing optional tool is not + * an error, it is just the older path. + */ +export async function delegate(cmd, argv = [], opts = {}) { + const found = externalFor(cmd); + if (!found) return { delegated: false, code: 0 }; + // openPassthrough resolves { ok, code, signal }, not a number. Assigning the + // object straight to process.exitCode throws ERR_INVALID_ARG_TYPE *after* the + // child has already printed its output, which reads as the tool crashing when + // in fact it succeeded. + const result = await openTool(found.tool, argv, opts); + return { delegated: true, code: exitCodeOf(result) }; +} + +/** + * One number out of a passthrough result. + * + * A child killed by a signal reports `code: null`, and passing that on as 0 + * would call an interrupted invoice run a success. + */ +export function exitCodeOf(result) { + if (typeof result === "number") return result; + if (!result || typeof result !== "object") return 0; + if (Number.isInteger(result.code)) return result.code; + if (result.signal) return 1; + return result.ok === false ? 1 : 0; +} + +/** + * The one-line nudge shown after the built-in runs. + * + * Written to stderr, and only when the CLI is missing, so it never lands in the + * middle of `--json` output that something is parsing. + */ +export function installHint(cmd) { + const key = DELEGATED[String(cmd || "").toLowerCase()]; + if (!key || externalFor(cmd) || builtinForced()) return null; + return `tip: moshcode install ${key} — runs @profullstack/${key}, which also works outside moshcode`; +} diff --git a/src/plugins.mjs b/src/plugins.mjs index af1a6b2f..3a728823 100644 --- a/src/plugins.mjs +++ b/src/plugins.mjs @@ -53,6 +53,7 @@ export function marketplaceSource(env = process.env) { export const PLUGINS = [ { name: "stocks", + family: "markets", version: "0.4.0", description: "equity research slash commands backed by advis0r.com", // `list` rather than `reports`: one letter from `report` is a coin-flip at @@ -67,6 +68,7 @@ export const PLUGINS = [ }, { name: "crypto", + family: "markets", version: "0.4.0", description: "crypto market data slash commands backed by advis0r.com", commands: [ @@ -75,18 +77,48 @@ export const PLUGINS = [ ], example: "/crypto:report BTC", }, + // The two business plugins front CLIs that live outside this repo + // (@profullstack/timer, @profullstack/billing), which is why their commands + // are thin: the rules that matter — never stop somebody else's clock, never + // issue an invoice unprompted — belong in the command files, and the + // arithmetic belongs in the package. + { + name: "timer", + family: "business", + version: "0.1.0", + description: "track time against projects, backed by @profullstack/timer", + commands: ["/timer:start", "/timer:stop", "/timer:status", "/timer:report"], + example: "/timer:start acme fix the login redirect", + }, + { + name: "billing", + family: "business", + version: "0.1.0", + description: "clients, rates and invoices, backed by @profullstack/billing", + commands: ["/billing:hours", "/billing:invoice", "/billing:rate", "/billing:report"], + example: "/billing:hours --client acme --month", + }, ]; /** - * Command names both plugins are expected to share. + * Command names the plugins in a family are expected to share. + * + * Two plugins in a family can never ship the same *set* — a crypto pair has no + * earnings transcript and an equity has no order book, and a timer has no + * clients. What they can share is vocabulary: the same question is spelled the + * same way on both sides, so knowing one plugin means knowing half the other. + * A test holds this, because the natural drift is for one side to grow a + * synonym (`coin` for `lookup`, `stocks` for `report`). Both market plugins + * did, for two releases. * - * The two cover different markets, so they can never ship the same *set* — a - * crypto pair has no earnings transcript and an equity has no order book. What - * they can share is vocabulary: the same question is spelled the same way on - * both sides, so knowing one plugin means knowing half the other. A test holds - * this, because the natural drift is for one side to grow a synonym. + * Keyed by family rather than one flat list, because the list only ever made + * sense for the pair it was written for: `quote` and `lookup` are market words, + * and requiring them of a timer would force two commands nobody would run. */ -export const SHARED_COMMANDS = ["help", "report", "quote", "lookup"]; +export const SHARED_COMMANDS = { + markets: ["help", "report", "quote", "lookup"], + business: ["report"], +}; /** How Claude Code namespaces a plugin's command. */ export function pluginCommandName(plugin, file) { diff --git a/src/tools.mjs b/src/tools.mjs index 5adf2a8b..f9cfe8cd 100644 --- a/src/tools.mjs +++ b/src/tools.mjs @@ -82,6 +82,24 @@ export const TOOLS = { // where those words mean something else entirely. aliases: { cmd: "cli-tools", args: ["aliases", "--json"] }, }, + timer: { + desc: "Profullstack timer - track time against projects, for people and for agents", + bin: "timer", + // The standalone half of what /timer used to do entirely in-process. It + // lives outside moshcode because tracking time is not a moshcode idea: it + // works under any agentic CLI, on Linux, macOS and Windows, and + // @profullstack/billing reads its timesheet directly. `npm install -g` is + // idempotent, so it doubles as the upgrade path. + install: { cmd: "npm", args: ["install", "-g", "@profullstack/timer"] }, + }, + billing: { + desc: "Profullstack billing - clients, rates and invoices from tracked hours", + bin: "billing", + // The other half. It carries the rate model /rate parses ($100/hour/agent/ + // upto:4) and bills agent-hours from the timer's entries. `billing import` + // brings across a ledger that started in ~/.moshcode/business.json. + install: { cmd: "npm", args: ["install", "-g", "@profullstack/billing"] }, + }, bo: { desc: "BufferOverride — capture a failing command, redact it, and find the answer that already exists", // The product is BufferOverride and the binary is `bo`, the same split diff --git a/src/tui.mjs b/src/tui.mjs index 794ed8dd..edbd7d46 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -1112,8 +1112,21 @@ export async function tui() { // and none of these close the readline interface: they print and return, // like /ps and /cost, so the prompt never moves. if (cmd === "timer") { + // @profullstack/timer when it is installed, the built-in otherwise. The + // readline interface is closed around the external one the way /secrets + // and /payments do it: the CLI prints its own tables and has to own + // stdout while it runs. + const { delegate, externalFor, installHint } = await import("./business-delegate.mjs"); + if (externalFor("timer")) { + rl.close(); + await delegate("timer", rest, {}); + rl = mkrl(); + continue; + } const { timerCommand } = await import("./timer.mjs"); await timerCommand(rest, { write: (l) => console.log(l) }); + const hint = installHint("timer"); + if (hint) console.log(hint); continue; } if (cmd === "client" || cmd === "business" || cmd === "merchant" || cmd === "customer") { @@ -1132,12 +1145,22 @@ export async function tui() { continue; } if (cmd === "billing" || cmd === "invoice") { + // Closed and reopened around the call either way: the external CLI owns + // stdout while it runs, and the built-in's `--send --yes` hands the + // terminal to the gateway's own CLI, which may prompt. + const { delegate, externalFor, installHint } = await import("./business-delegate.mjs"); + if (externalFor(cmd)) { + rl.close(); + await delegate(cmd, rest, {}); + rl = mkrl(); + continue; + } const { billingCommand } = await import("./billing.mjs"); - // Closed and reopened around the call: `--send --yes` hands the terminal - // to the gateway's own CLI, which may prompt. rl.close(); billingCommand(rest, { write: (l) => console.log(l) }); rl = mkrl(); + const hint = installHint(cmd); + if (hint) console.log(hint); continue; } if (cmd === "payments") { diff --git a/test/business-delegate.test.mjs b/test/business-delegate.test.mjs new file mode 100644 index 00000000..14a54a3d --- /dev/null +++ b/test/business-delegate.test.mjs @@ -0,0 +1,113 @@ +// /timer and /billing prefer the published CLIs, and fall back when absent. +// +// The behaviour worth pinning is the fallback: a machine that has not installed +// @profullstack/timer must keep working exactly as it did, because upgrading +// moshcode is not consent to lose a command. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { DELEGATED, builtinForced, exitCodeOf, externalFor, installHint } from "../src/business-delegate.mjs"; +import { TOOLS } from "../src/tools.mjs"; + +/** A directory on PATH holding an executable of the given name. */ +function fakeBin(name) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moshcode-bin-")); + const win = process.platform === "win32"; + const file = path.join(dir, win ? `${name}.cmd` : name); + fs.writeFileSync(file, win ? "@echo off\r\nexit /b 0\r\n" : "#!/bin/sh\nexit 0\n"); + if (!win) fs.chmodSync(file, 0o755); + return { dir, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) }; +} + +test("both commands are published tools moshcode can install", () => { + for (const key of ["timer", "billing"]) { + assert.ok(TOOLS[key], `${key} is missing from TOOLS`); + assert.equal(TOOLS[key].bin, key); + assert.deepEqual(TOOLS[key].install.args, ["install", "-g", `@profullstack/${key}`]); + } +}); + +test("/invoice delegates to the same tool as /billing", () => { + // They are aliases in the pit, so they must not disagree about where they go. + assert.equal(DELEGATED.invoice, "billing"); + assert.equal(DELEGATED.billing, "billing"); +}); + +test("with the CLI absent, nothing is delegated and the built-in runs", (t) => { + const saved = process.env.PATH; + const empty = fs.mkdtempSync(path.join(os.tmpdir(), "moshcode-empty-")); + process.env.PATH = empty; + t.after(() => { + process.env.PATH = saved; + fs.rmSync(empty, { recursive: true, force: true }); + }); + assert.equal(externalFor("timer"), null); + assert.equal(externalFor("billing"), null); +}); + +test("with the CLI on PATH, the command is handed to it", (t) => { + const saved = process.env.PATH; + const bin = fakeBin("timer"); + process.env.PATH = bin.dir; + t.after(() => { process.env.PATH = saved; bin.cleanup(); }); + const found = externalFor("timer"); + assert.ok(found, "an installed timer should win"); + assert.equal(found.key, "timer"); +}); + +test("MOSHCODE_BUILTIN_BILLING pins the in-process implementation", (t) => { + const savedPath = process.env.PATH; + const savedFlag = process.env.MOSHCODE_BUILTIN_BILLING; + const bin = fakeBin("timer"); + process.env.PATH = bin.dir; + process.env.MOSHCODE_BUILTIN_BILLING = "1"; + t.after(() => { + process.env.PATH = savedPath; + if (savedFlag === undefined) delete process.env.MOSHCODE_BUILTIN_BILLING; + else process.env.MOSHCODE_BUILTIN_BILLING = savedFlag; + bin.cleanup(); + }); + assert.equal(builtinForced(), true); + assert.equal(externalFor("timer"), null, "the escape hatch beats an installed CLI"); +}); + +test("the install tip appears only when there is something to install", (t) => { + const saved = process.env.PATH; + const empty = fs.mkdtempSync(path.join(os.tmpdir(), "moshcode-empty-")); + process.env.PATH = empty; + t.after(() => { + process.env.PATH = saved; + fs.rmSync(empty, { recursive: true, force: true }); + }); + assert.match(installHint("timer"), /moshcode install timer/); + + const bin = fakeBin("timer"); + process.env.PATH = bin.dir; + t.after(bin.cleanup); + assert.equal(installHint("timer"), null, "no nagging once it is installed"); +}); + +test("a passthrough result becomes one exit code", () => { + // openPassthrough resolves { ok, code, signal }, not a number. Assigning that + // object straight to process.exitCode throws ERR_INVALID_ARG_TYPE *after* the + // child has printed its output, so a run that actually succeeded ends in a + // stack trace. That is exactly what happened the first time this was wired. + assert.equal(exitCodeOf({ ok: true, code: 0, signal: null }), 0); + assert.equal(exitCodeOf({ ok: false, code: 2, signal: null }), 2); + assert.equal(exitCodeOf(3), 3, "a bare number passes through"); + // A child killed by a signal reports code: null. Calling that 0 would report + // an interrupted invoice run as a success. + assert.equal(exitCodeOf({ ok: false, code: null, signal: "SIGINT" }), 1); + assert.equal(exitCodeOf({ ok: false, code: null, signal: null }), 1); + assert.equal(exitCodeOf(undefined), 0); +}); + +test("a command with no external CLI is never delegated", () => { + for (const cmd of ["team", "payments", "client", "rate", "", null]) { + assert.equal(externalFor(cmd), null, String(cmd)); + assert.equal(installHint(cmd), null, String(cmd)); + } +}); diff --git a/test/plugins.test.mjs b/test/plugins.test.mjs index b1d5eb12..5e5c6336 100644 --- a/test/plugins.test.mjs +++ b/test/plugins.test.mjs @@ -103,21 +103,36 @@ test("the default plugin resolves from nothing, and an unknown one does not", () assert.equal(resolvePlugin("nonsense"), null); }); -test("both plugins spell the shared questions the same way", () => { - // They cover different markets and can never ship the same set — a pair has - // no earnings transcript, an equity has no order book. What they share is - // vocabulary, and the natural drift is for one side to grow a synonym: - // `coin` for `lookup`, `stocks` for `report`. Both did, for two releases. +test("plugins in a family spell the shared questions the same way", () => { + // Within a family they can never ship the same set — a pair has no earnings + // transcript, an equity has no order book, a timer has no clients. What they + // share is vocabulary, and the natural drift is for one side to grow a + // synonym: `coin` for `lookup`, `stocks` for `report`. Both market plugins + // did, for two releases. for (const plugin of PLUGINS) { - for (const shared of SHARED_COMMANDS) { + assert.ok(plugin.family, `${plugin.name} declares no family`); + const shared = SHARED_COMMANDS[plugin.family]; + assert.ok(shared, `no shared vocabulary defined for the "${plugin.family}" family`); + for (const command of shared) { assert.ok( - plugin.commands.includes(`/${plugin.name}:${shared}`), - `${plugin.name} is missing the shared command "${shared}"`, + plugin.commands.includes(`/${plugin.name}:${command}`), + `${plugin.name} is missing the shared command "${command}"`, ); } } }); +test("every family has more than one plugin in it", () => { + // A family of one is a shared vocabulary nobody shares, which is how the rule + // above quietly stops testing anything. + const counts = new Map(); + for (const plugin of PLUGINS) counts.set(plugin.family, (counts.get(plugin.family) || 0) + 1); + for (const [family, count] of counts) { + assert.ok(count > 1, `the "${family}" family has only ${count} plugin`); + } + assert.deepEqual([...counts.keys()].sort(), Object.keys(SHARED_COMMANDS).sort()); +}); + test("no plugin command repeats its own plugin's name", () => { // `/crypto:crypto` reads as a stutter and was the first thing anyone asked // about. A command named after its plugin is the shape that produces it.