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.