@ucsandman/legcli 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.0 (2026-09-18)
4
+
5
+ The 14-day trial is back.
6
+
7
+ - **Free for 14 days, no key, no card.** The first `leg <agent>` on a machine
8
+ starts a 14-day trial with every gate open, Team's `leg share` included, and
9
+ records it in `$LEG_HOME/trial.json`. `leg license status` shows the days
10
+ left without starting the clock. After the trial, or with a refused key once
11
+ it is over, a session exits 4 with the price and the 30-day guarantee, which
12
+ stays as the second net after buying. Deleting the file resets the clock,
13
+ which is the known limit of an offline trial and is accepted. The license
14
+ agreement gains the trial clause; the site, README, support page and
15
+ `llms.txt` say "free for 14 days" where they said "no trial".
16
+ - **The README stops calling the repository private.** It has been public at
17
+ github.com/ucsandman/legcli; two sentences still said otherwise.
18
+
19
+ ## 0.13.1 (2026-09-18)
20
+
21
+ A one-line fix in the terminal and the marketing site brought up to what 0.13.0 does.
22
+
23
+ - **The terminal's opening line names rungs.** `next: claude/opus →
24
+ claude/sonnet → codex → agy`, through the same `rungLabel` every other
25
+ surface uses; it read `next: claude → claude → codex → agy` on 0.12.0.
26
+ - **The site says what is underneath the handoff.** legcli.com is reorganised
27
+ around the product as it is now: the sample transcript shows a Fable wall
28
+ answered by Opus keeping the conversation and a login wall answered by Codex
29
+ from the bundle; new sections for the ladder, background cards (End as a
30
+ card, Run in the background, Take over), `leg history`, the portable
31
+ harness and custom adapters in one compact group, and a **Never spent
32
+ without you** column beside what is never touched. The structured data's
33
+ `softwareVersion` had been stuck at 0.8.0; the OG image gains Grok; the FAQ
34
+ no longer says a shared board has no TLS.
35
+
3
36
  ## 0.13.0 (2026-09-18)
4
37
 
5
38
  The first day on 0.12.0 with three real terminals found five things. Usage
package/LICENSE CHANGED
@@ -9,6 +9,8 @@ install or use Leg.
9
9
 
10
10
  1. License. When you buy a license we grant you a non-exclusive,
11
11
  non-transferable license to install and use Leg as follows.
12
+ Trial: before buying, one person may install and use Leg to evaluate it
13
+ for 14 days from its first use on a machine, on the Team terms below.
12
14
  Personal: one named person, on any number of machines that person uses.
13
15
  Team: one named person per seat purchased. A Team license also covers use
14
16
  of the shared board (leg share) by the people holding seats. A Personal
package/README.md CHANGED
@@ -23,7 +23,7 @@ files and regions). `leg claude --model opus` is
23
23
  1. **A board.** Opened once in your browser, reused after that. Every Leg
24
24
  session in every terminal is a row on it, and the row's register reads
25
25
  status, repo on branch, uncommitted and unpushed counts, agent and model,
26
- and how long it has been quiet: `waiting on you baton on main dirty 3
26
+ and how long it has been quiet: `waiting on you leg on main dirty 3
27
27
  ahead 2 claude/fable`. Under that come the prompt, the one thing worth
28
28
  knowing, the files it is touching and the usage bucket that will stop it.
29
29
  Capacity is one strip at the top rather than a region, with the login
@@ -138,8 +138,8 @@ agent name passes straight through (`leg codex -m gpt-5.3-codex-spark`,
138
138
  through unchanged, and your settings file is never edited: Leg's hooks ride
139
139
  in a separate per-session `--settings` file.
140
140
 
141
- The source repository is private. Leg is commercial, source-available
142
- software: every `.mjs` file that runs is in the package you just installed, at
141
+ Leg is commercial, source-available software, and the source is on
142
+ [GitHub](https://github.com/ucsandman/legcli): every `.mjs` file that runs is in the package you just installed, at
143
143
  `$(npm root -g)/legcli/src`, and the license lets you read it and modify
144
144
  your own copy. There is nothing compiled, minified or bundled to see through.
145
145
 
@@ -697,8 +697,8 @@ drift. CI waits for the Ubuntu and Windows test matrix, validates both
697
697
  when that version is missing and newer than the stable `latest`. Existing
698
698
  versions skip cleanly; older, prerelease, lockstep, and registry-error cases
699
699
  fail the job. The npm trusted publisher is bound to `ucsandman/legcli` and
700
- `.github/workflows/ci.yml` (bind `leg-agents` the same way). The repository
701
- stays private, so publication uses `--provenance=false`.
700
+ `.github/workflows/ci.yml` (bind `leg-agents` the same way). Publication
701
+ uses `--provenance=false`.
702
702
 
703
703
  ## Privacy and attribution
704
704
 
@@ -714,17 +714,19 @@ Leg is commercial software under the [Leg License Agreement](LICENSE).
714
714
  It ships as readable JavaScript so you can see what it does on your machine,
715
715
  and you may modify it for your own use, but not redistribute it or work
716
716
  around the license check. Versions 0.2.0 and 0.3.0 were published under MIT
717
- and remain available. The version in this source tree is 0.13.0; see
717
+ and remain available. The version in this source tree is 0.14.0; see
718
718
  [npm](https://www.npmjs.com/package/legcli) for published versions and
719
719
  [CHANGELOG.md](CHANGELOG.md) for release notes.
720
720
 
721
- Using it needs a license: **Personal, $79 once**, one human on any number of
721
+ The first 14 days are a **free trial**: install it, type `leg claude`, and
722
+ everything in both plans is on, with no key and no card. The clock starts on
723
+ your first session and `leg license status` shows what is left. After that
724
+ it needs a license: **Personal, $79 once**, one human on any number of
722
725
  machines, every release for 12 months and the version you have keeps working
723
726
  after that; **Team, $12 per seat per month**, Personal plus `leg share` for
724
727
  more than one human on the board. Buy at the site, then
725
- `leg license activate <key>`. There is no trial; there is a **30-day
726
- money-back guarantee**, so the way to evaluate Leg is to use it on real work
727
- and ask for a refund if it does not earn its place. A key is a signed token
728
+ `leg license activate <key>`. There is also a **30-day money-back
729
+ guarantee** after buying, no reason required. A key is a signed token
728
730
  checked offline with the public key in `src/license.mjs`; only a Team key
729
731
  renewal talks to the site. The bare agent CLIs are never affected by any of
730
732
  this; only what Leg adds is licensed.
package/bin/leg.mjs CHANGED
@@ -493,10 +493,11 @@ async function main() {
493
493
  // The paid gate. Keys verify offline against the public key in
494
494
  // src/license.mjs; nothing here talks to the network except refresh.
495
495
  if (!cmd || cmd === 'status') {
496
- const ent = entitlement()
496
+ // looking does not start the trial clock; the first session does
497
+ const ent = entitlement({ startTrial: false })
497
498
  out(describeLicense(ent))
498
499
  if (ent.source === 'license') out(`stored at ${licensePath()}`)
499
- if (!ent.ok) out(`Buy: ${BUY_URL} then: leg license activate <key>`)
500
+ if (!ent.ok || ent.source === 'trial') out(`Buy: ${BUY_URL} then: leg license activate <key>`)
500
501
  return
501
502
  }
502
503
  if (cmd === 'activate') {
@@ -508,7 +509,7 @@ async function main() {
508
509
  } catch (err) { die(2, err.message) }
509
510
  return
510
511
  }
511
- if (cmd === 'deactivate') return out(deactivateLicense() ? `removed ${licensePath()}; Leg needs a key again before it will run` : 'no license was stored')
512
+ if (cmd === 'deactivate') return out(deactivateLicense() ? `removed ${licensePath()}; Leg is back on the trial if it has days left, otherwise it needs a key` : 'no license was stored')
512
513
  if (cmd === 'refresh') {
513
514
  try { const p = await refreshLicense(); out(`renewed ${p.plan} license ${p.id}, valid through ${p.expires}`) } catch (err) { die(2, err.message) }
514
515
  return
package/docs/DECISIONS.md CHANGED
@@ -100,7 +100,7 @@ The repository stays private (see the 2026-09-11 license decision). That left ev
100
100
  ## 2026-09-11: Leg is a commercial product; the site stays on the free Vercel address
101
101
 
102
102
  - **License.** Wes: "if we're trying to sell this thing it shouldn't be open source and MIT." The repo stays private and the package ships under the Leg License Agreement (LICENSE): commercial, source readable in the package for inspection and own-use modification, no redistribution, no working around the license check. The FSL option from the pricing research was dropped for the same reason. Versions 0.2.0 and 0.3.0 remain available under MIT.
103
- - **Pricing.** Personal $79 once with 12 months of releases (Sublime shape); Team $12 per seat per month (adds `leg share`); no trial, and a 30-day money-back guarantee as the risk reversal instead (2026-09-15: a trial suits daily-habit products, and Leg's value is bursty, it pays off in the moment a limit lands, which a fortnight of evaluation does not reliably contain; a buyer already paying for two or three agent subscriptions is not price-sensitive at $79, they are trust-sensitive, and a trial does not answer trust). Keys are Ed25519 tokens signed with a private key that lives only in the seller's `.env` and the site's Vercel env; the public key is in `src/license.mjs`. A Personal key is a window over `RELEASE_DATE`, so every release bumps that constant.
103
+ - **Pricing.** Personal $79 once with 12 months of releases (Sublime shape); Team $12 per seat per month (adds `leg share`); ~~no trial, and a 30-day money-back guarantee as the risk reversal instead~~ superseded 2026-09-18 (2026-09-15: a trial suits daily-habit products, and Leg's value is bursty, it pays off in the moment a limit lands, which a fortnight of evaluation does not reliably contain; a buyer already paying for two or three agent subscriptions is not price-sensitive at $79, they are trust-sensitive, and a trial does not answer trust). 2026-09-18, Wes, ahead of the Hacker News post: the 14-day trial from 0.4.0 is back, started by the first session and recorded in `$LEG_HOME/trial.json` with every gate open, and the 30-day guarantee stays as the second net after buying. A launch audience installs to look, and "buy first" on a repo they found ten minutes ago is a wall the guarantee does not lower. Keys are Ed25519 tokens signed with a private key that lives only in the seller's `.env` and the site's Vercel env; the public key is in `src/license.mjs`. A Personal key is a window over `RELEASE_DATE`, so every release bumps that constant.
104
104
  - **Checkout.** Stripe payment links (live) with automatic tax, `site/api/key` and `site/api/webhook` on Vercel functions, Resend from `legcli@practicalsystems.io`. `scripts/stripe-setup.mjs` is idempotent per site origin; a test-mode purchase was run end to end on 2026-09-11 (checkout, thanks page, key activated in the CLI, webhook 200 twice).
105
105
  - **Domain.** A `legcli.com` purchase ($11.25) was started and cancelled at Wes's "just deploy it to a free vercel site"; nothing was bought. The site is https://legcli.com/.
106
106
  - **Not a lawyer.** The license text was drafted in-session; a review before the first sale outside the US is Wes's call.
@@ -116,6 +116,7 @@ The repository stays private (see the 2026-09-11 license decision). That left ev
116
116
  - **Root Directory had to change first.** It was `.`, which is correct when deploying from inside `site/` but would have published the repo root on a git build: no `index.html`, and `api/key` and `api/webhook` gone, so a purchase in flight would not have received its key.
117
117
  - ~~**Not every push.** `site/vercel.json` carries `ignoreCommand: git diff --quiet HEAD^ HEAD .`, which Vercel maps to the Ignored Build Step. A commit that touches nothing under `site/` cancels the build. The command failing (a shallow clone with no `HEAD^`) exits non-zero, which builds, so the failure mode is a redundant deploy rather than a missed one.~~ **Superseded 2026-09-15: the failure mode was a MISSED deploy.** `HEAD^ HEAD` compares one commit, not the push. Fast-forwarding five commits to main where only the first touched `site/` left the tip with no `site/` change, so Vercel cancelled the build and the redesigned site never went out while the repo said it had. The `ignoreCommand` is removed: every push to `main` deploys the site. Vercel's own "Skip deployments when there are no changes to the root directory" toggle is the safe version of this idea, because it compares against the last deployment rather than against `HEAD^`.
118
118
  - **Agent sessions cannot do this part.** `vercel --prod` and `vercel git connect` are both denied by the harness classifier as production deploys, and the CLI auth token cannot be read. Wes ran both.
119
+ - **2026-09-18: the git deploy did not fire, and the manual fallback has two traps.** The push of 0.13.1 (`19667ba`) produced no `vercel[bot]` deployment on GitHub (commit status stayed `pending`, no statuses), while the pushes an hour earlier had; the account had an overdue invoice at the time, which is the likeliest cause, and the git integration should be re-checked in the Vercel dashboard after a rename (the GitHub repository was renamed since the connection was made; `git remote -v` has the current name and the project still says `baton`). The fallback that works, run from the **repo root**, not `site/`: a root `.vercel/project.json` copied from `site/.vercel/` (gitignored), a root `.vercelignore` of `*`, `!site`, `!site/**`, `site/.vercel`, `site/.env*`, then `vercel --prod --yes --archive=tgz`. Trap one: `vercel --prod --yes` from inside `site/` uploads `site/` as the root, Vercel applies Root Directory `site` on top of it, and the deployment errors with an empty message. Trap two: that same `--yes` auto-linked the repo root to a **new** Vercel project named `leg` (empty, no domain); it is junk and `vercel project rm leg` removes it. Trap three: with no `.vercelignore` the CLI uploads the untracked probe debris too and refuses at 15,000 files.
119
120
 
120
121
 
121
122
  ## 2026-09-15: the board is a dark product surface, and DESIGN.md was the defect
package/docs/ERRORS.md CHANGED
@@ -3,6 +3,43 @@
3
3
  What broke, why, and what fixed it. One entry per failure, newest first. A first
4
4
  occurrence has to be written down or a repeat is never countable.
5
5
 
6
+ ## 2026-09-18: 0.14.0 pushed, CI green everywhere, and npm still served 0.13.1
7
+
8
+ **Fixed by putting the `repository` block back into the lockfile root
9
+ (`packages[""]`) and pushing again.** `scripts/npm-publish-gate.mjs` requires
10
+ `package-lock.json`'s root `repository.url` to equal the trusted-publisher
11
+ binding, and npm 10.9 (the local install) does not write that block; npm 11
12
+ does, which is where the 0.13.1 lockfile got it. `npm version 0.14.0` on npm
13
+ 10 rewrote the lockfile without it, the test matrix and the site deploy passed,
14
+ and only the `publish-npm` job failed, on the gate, before publishing anything.
15
+ The lesson: a lockfile touched by a different npm major is a release change,
16
+ and "CI green" after a push means the run, not the publish; `npm view
17
+ @ucsandman/legcli version` is the check.
18
+
19
+ ## 2026-09-18: `vercel --prod --yes` from `site/` failed the deploy and created a stray Vercel project
20
+
21
+ **Fixed by deploying from the repo root with a root `.vercelignore` and
22
+ `--archive=tgz`; the recipe is in DECISIONS.md under "the site deploys itself
23
+ from git".** The `--yes` also auto-created an empty Vercel project named `leg`
24
+ that still needs `vercel project rm leg`. The lesson: on a project with a Root
25
+ Directory set, the CLI must run from the repo root, and `--yes` is a consent to
26
+ create projects, not only to skip a confirmation.
27
+
28
+ ## 2026-09-18: the terminal's opening `next:` line named the same agent twice and no model
29
+
30
+ **Fixed in `src/attach.mjs`: the line maps the chain through `rungLabel`, so it
31
+ reads `next: claude/opus → claude/sonnet → codex → agy`.**
32
+
33
+ The ladder made destinations rungs of (agent, account, model), and every other
34
+ surface (the ledger's `handoff` event, the board's chain rail, `leg ladder`)
35
+ moved to `rungLabel`. The one line the human reads first, printed at session
36
+ start, kept its pre-ladder formatter of `agent[/account]`, so a claude/fable
37
+ terminal announced `next: claude → claude → codex → agy`. Found while writing
38
+ the marketing site's transcript from the source lines rather than from memory:
39
+ a transcript that mirrors the code cannot show a line the code prints wrongly.
40
+ The lesson is the same one as the site rule: a sample transcript is written
41
+ from the `say()` calls, and a `say()` that reads wrong there is a bug here.
42
+
6
43
  ## 2026-09-17: a new field on the session view leaked the owner's reset times to a guest, through the guest's own terminal
7
44
 
8
45
  **Fixed in `src/server.mjs` (`sessionsView` decides `guest` before the map and
package/docs/faq.md CHANGED
@@ -135,7 +135,9 @@ paths, no bundle, no events, no logs, and none of the pipeline side. The
135
135
  limit line keeps only the reason and the reset time, never the raw limit
136
136
  text. The one thing they can do on your terminal is ask for a hand-off, which
137
137
  you approve or dismiss on the row. `leg share off` ends it and every link
138
- stops working. There is no TLS, so keep it on Tailscale or a network you trust.
138
+ stops working. TLS is served from a certificate pair you supply (`leg share on
139
+ --tls-cert <file> --tls-key <file>`); Leg issues none, so without one keep it on
140
+ Tailscale or a network you trust.
139
141
 
140
142
  **What if the CLI I want isn't installed?**
141
143
  `node bin/leg.mjs up --dry` prints a preflight table with `ok`/`missing`
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ucsandman/legcli",
3
- "version": "0.13.0",
4
- "description": "Usage-limit monitor and automatic handoff for Claude Code, Codex, agy and Grok. Type leg claude|codex|agy|grok and get the same interactive agent with a board alongside, auto-approve on by default, usage tracking per agent and account, a live context handoff bundle, and at the limit the next agent continuing in the same terminal. $79 once, 30-day money-back guarantee.",
3
+ "version": "0.14.0",
4
+ "description": "Usage-limit monitor and automatic handoff for Claude Code, Codex, agy and Grok. Type leg claude|codex|agy|grok and get the same interactive agent with a board alongside, auto-approve on by default, usage tracking per agent and account, a live context handoff bundle, and at the limit the next agent continuing in the same terminal. 14-day free trial, then $79 once, 30-day money-back guarantee.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
7
7
  "homepage": "https://legcli.com",
@@ -199,13 +199,13 @@ function page ({ slug, title, description, body, headings }) {
199
199
  <meta property="og:url" content="${url}">
200
200
  <meta property="og:site_name" content="Leg">
201
201
  <meta property="og:title" content="${escapeHtml(title)}">
202
- <meta property="og:image" content="${ORIGIN}/og.png?v=3">
203
- <meta property="og:image:secure_url" content="${ORIGIN}/og.png?v=3">
202
+ <meta property="og:image" content="${ORIGIN}/og.png?v=4">
203
+ <meta property="og:image:secure_url" content="${ORIGIN}/og.png?v=4">
204
204
  <meta property="og:image:type" content="image/png">
205
205
  <meta name="twitter:card" content="summary_large_image">
206
206
  <meta name="twitter:title" content="${escapeHtml(title)}">
207
207
  <meta name="twitter:description" content="${escapeHtml(description)}">
208
- <meta name="twitter:image" content="${ORIGIN}/og.png?v=3">
208
+ <meta name="twitter:image" content="${ORIGIN}/og.png?v=4">
209
209
  <link rel="preload" href="/fonts/atkinson-hyperlegible-next-var.woff2" as="font" type="font/woff2" crossorigin>
210
210
  <link rel="preload" href="/fonts/azeret-mono-var.woff2" as="font" type="font/woff2" crossorigin>
211
211
  <link rel="stylesheet" href="/style.css">
package/src/attach.mjs CHANGED
@@ -902,7 +902,7 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
902
902
  }
903
903
  if (iso) updateSession(sid, { cwd: iso.cwd, branch: iso.branch, worktree: { path: iso.path, branch: iso.branch, base: iso.base } })
904
904
  updateSession(sid, { head_at_start: g.head, head: g.head, files_dirty: iso ? [] : g.dirty, board_url: board.url })
905
- say(`session ${sid} · ${agent}${account !== 'default' ? '/' + account : ''} · board ${board.url ?? 'off'}${board.started ? ' (started)' : ''} · next: ${chain.map((c) => c.agent + (c.account !== 'default' ? '/' + c.account : '')).join(' → ') || 'none'}`)
905
+ say(`session ${sid} · ${agent}${account !== 'default' ? '/' + account : ''} · board ${board.url ?? 'off'}${board.started ? ' (started)' : ''} · next: ${chain.map(rungLabel).join(' → ') || 'none'}`)
906
906
  if (iso) {
907
907
  const others = iso.live.map((s) => `${s.agent} ${s.session_id.split('-').pop()}`).join(', ')
908
908
  say(`another session is live in this checkout (${others}): this one works in ${iso.cwd} on ${iso.branch}; Land on its card brings it to ${iso.base ?? 'nothing (detached HEAD)'} · --no-worktree to share`)
@@ -68,7 +68,7 @@
68
68
  // release lands on disk under a board that was started before it: the page
69
69
  // then draws controls the process has no routes for (an empty agent select,
70
70
  // no buckets). test/files-version.test.mjs pins this to package.json.
71
- const FILES_VERSION = '0.13.0'
71
+ const FILES_VERSION = '0.14.0'
72
72
  const TIMELINE_CAP = 12
73
73
  // mirrors LOOPBACK in src/auth.mjs; state.bind is "<host>:<port>" and an IPv6
74
74
  // host arrives bracketed
@@ -50,7 +50,7 @@
50
50
  // name. test/board-updates.test.mjs pins `state.stopped` in this file by
51
51
  // source text, which is why the module object keeps the name.
52
52
  // pinned to package.json by test/files-version.test.mjs; see board.js FILES_VERSION
53
- const FILES_VERSION = '0.13.0'
53
+ const FILES_VERSION = '0.14.0'
54
54
  const state = { es: null, retryMs: 1000, timers: [], stopped: false, sseRequest: 0, floorRequest: 0, trunkRequest: 0, headRequest: 0, cardsRequest: 0, lastReadingAt: null, bind: (typeof location !== 'undefined' && location.host) || '127.0.0.1:4747', pendingFloor: null, pendingCards: null, cards: new Map(), blockers: new Map(), doneOpen: false, ringId: null, scheduler: {}, trunkOff: false }
55
55
 
56
56
  // What the entry row reads on every render: the terminals (for the repo it
package/src/license.mjs CHANGED
@@ -8,10 +8,11 @@
8
8
  // team per-seat subscription; the key carries an expiry a few days past
9
9
  // the billing period, `leg license refresh` fetches a renewed one
10
10
  //
11
- // There is no trial. Leg pays off in the moment a limit lands mid-flow, which
12
- // is not a thing a fortnight of evaluation reliably contains; the risk reversal
13
- // is a 30-day money-back guarantee instead, which costs no code and no expiry
14
- // machinery. Key shape: LEG-<base64url payload>.<base64url signature> (legacy
11
+ // Before any key there is a 14-day trial, started the first time a session
12
+ // asks and recorded under $LEG_HOME/trial.json with every gate open. Deleting
13
+ // that file resets the clock; that is a known and accepted limit of an offline
14
+ // trial. Past the trial, or with a refused key, Leg needs a license; the risk
15
+ // reversal after buying is a 30-day money-back guarantee. Key shape: LEG-<base64url payload>.<base64url signature> (legacy
15
16
  // BATON- prefix still accepted) where the signature is Ed25519 over the payload
16
17
  // bytes exactly as encoded.
17
18
  import { createPublicKey, createPrivateKey, verify as cryptoVerify, sign as cryptoSign, createHash } from 'node:crypto'
@@ -29,11 +30,13 @@ export const PUBLIC_KEY_B64 = 'MCowBQYDK2VwAyEAIpVQymHHJAkIrZHv0u4o0bgfFmtW3Crm7
29
30
  const ACTIVE_PUBLIC_KEY = process.env.LEG_PUBLIC_KEY_B64 || process.env.BATON_PUBLIC_KEY_B64 || PUBLIC_KEY_B64
30
31
  // The date this release was cut. A personal key activates when this is on or
31
32
  // before its updates_until. Bumped with every published version.
32
- export const RELEASE_DATE = '2026-09-16'
33
+ export const RELEASE_DATE = '2026-09-18'
34
+ export const TRIAL_DAYS = 14
33
35
  export const GUARANTEE_DAYS = 30
34
36
  export const SITE = process.env.LEG_SITE || process.env.BATON_SITE || 'https://legcli.com'
35
37
  export const BUY_URL = `${SITE}/#pricing`
36
38
  export const PLANS = {
39
+ trial: { label: 'Trial', gates: ['run', 'share'] },
37
40
  personal: { label: 'Personal', gates: ['run'] },
38
41
  team: { label: 'Team', gates: ['run', 'share'] },
39
42
  }
@@ -86,6 +89,7 @@ export function verifyLicense(key, { publicKeyB64 = ACTIVE_PUBLIC_KEY, today = i
86
89
  }
87
90
 
88
91
  export function licensePath() { return join(home(), 'license.json') }
92
+ export function trialPath() { return join(home(), 'trial.json') }
89
93
 
90
94
  function readJson(f) { try { return JSON.parse(readFileSync(f, 'utf8')) } catch { return null } }
91
95
  function writeJson(f, obj) {
@@ -114,14 +118,30 @@ export function deactivate() {
114
118
  return had
115
119
  }
116
120
 
117
- // What this machine may do right now: a valid key, or nothing, with the reason
118
- // the stored key (if any) was refused.
119
- export function entitlement({ today = isoToday(), releaseDate = RELEASE_DATE } = {}) {
121
+ // The trial starts the first time something asks with start on (a session;
122
+ // `leg license status` asks with it off, so looking does not start the clock).
123
+ export function trial({ today = isoToday(), start = true } = {}) {
124
+ let t = readJson(trialPath())
125
+ if (!t?.started && start) { t = { started: today }; writeJson(trialPath(), t) }
126
+ if (!t?.started) return { started: null, daysLeft: TRIAL_DAYS, expired: false }
127
+ const used = Math.floor((Date.parse(today) - Date.parse(t.started)) / 86400000)
128
+ const daysLeft = Math.max(0, TRIAL_DAYS - used)
129
+ return { started: t.started, daysLeft, expired: used >= TRIAL_DAYS || used < 0 }
130
+ }
131
+
132
+ // What this machine may do right now. A valid key wins; otherwise the trial;
133
+ // otherwise nothing, with the reason the stored key (if any) was refused.
134
+ export function entitlement({ today = isoToday(), releaseDate = RELEASE_DATE, startTrial = true } = {}) {
120
135
  const lic = readLicense()
121
- if (!lic) return { ok: false, plan: 'none', reason: 'no-license', refused: null }
122
- const v = verifyLicense(lic.key, { today, releaseDate })
123
- if (v.ok) return { ok: true, plan: v.payload.plan, seats: v.payload.seats ?? 1, payload: v.payload, source: 'license' }
124
- return { ok: false, plan: 'none', reason: v.reason, refused: v.reason }
136
+ let refused = null
137
+ if (lic) {
138
+ const v = verifyLicense(lic.key, { today, releaseDate })
139
+ if (v.ok) return { ok: true, plan: v.payload.plan, seats: v.payload.seats ?? 1, payload: v.payload, source: 'license' }
140
+ refused = v.reason
141
+ }
142
+ const t = trial({ today, start: startTrial })
143
+ if (!t.expired) return { ok: true, plan: 'trial', started: t.started, daysLeft: t.daysLeft, source: 'trial', refused }
144
+ return { ok: false, plan: 'none', reason: refused ?? 'trial-expired', refused }
125
145
  }
126
146
 
127
147
  export function allows(ent, gate) {
@@ -136,13 +156,18 @@ export function explain(reason, payload) {
136
156
  case 'unknown-plan': return `the key names a plan this version does not know (${payload?.plan})`
137
157
  case 'personal-updates-ended': return `this Personal key covers releases up to ${payload?.updates_until}; this release is dated ${RELEASE_DATE}. Keep the version you have, or renew at ${BUY_URL}`
138
158
  case 'team-expired': return `this Team key expired on ${payload?.expires}; run "leg license refresh" (the subscription renews it) or see ${BUY_URL}`
139
- case 'no-license': return `Leg needs a license key. Buy one at ${BUY_URL} (${GUARANTEE_DAYS}-day money-back guarantee), then: leg license activate <key>`
159
+ case 'trial-expired': return `the ${TRIAL_DAYS}-day trial has ended; Leg needs a license key. Buy one at ${BUY_URL} (${GUARANTEE_DAYS}-day money-back guarantee), then: leg license activate <key>`
140
160
  default: return String(reason)
141
161
  }
142
162
  }
143
163
 
144
164
  export function describe(ent) {
145
165
  if (!ent.ok) return `no license: ${explain(ent.reason)}`
166
+ if (ent.plan === 'trial') {
167
+ const note = ent.refused ? ` (stored key refused: ${ent.refused})` : ''
168
+ if (!ent.started) return `${TRIAL_DAYS}-day trial, not started; it starts with your first leg <agent>${note}`
169
+ return `${TRIAL_DAYS}-day trial, ${ent.daysLeft} day${ent.daysLeft === 1 ? '' : 's'} left; buy at ${BUY_URL}${note}`
170
+ }
146
171
  const p = ent.payload
147
172
  const until = p.plan === 'personal' ? `updates through ${p.updates_until}` : `renews; valid through ${p.expires}`
148
173
  return `${PLANS[p.plan].label} license ${p.id}${p.seats > 1 ? `, ${p.seats} seats` : ''}, ${until}`