@ucsandman/legcli 0.7.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.
Files changed (184) hide show
  1. package/CHANGELOG.md +560 -0
  2. package/LICENSE +58 -0
  3. package/NOTICE +4 -0
  4. package/README.md +558 -0
  5. package/bin/fake-agent.mjs +143 -0
  6. package/bin/leg.mjs +449 -0
  7. package/docs/DECISIONS.md +119 -0
  8. package/docs/DEMO.md +214 -0
  9. package/docs/DEVIATIONS.md +182 -0
  10. package/docs/ERRORS.md +326 -0
  11. package/docs/README.md +40 -0
  12. package/docs/REUSE.md +289 -0
  13. package/docs/ROADMAP-v2.md +55 -0
  14. package/docs/VOCABULARY.md +243 -0
  15. package/docs/adapters.md +300 -0
  16. package/docs/board-guide.md +579 -0
  17. package/docs/cli-contracts.md +401 -0
  18. package/docs/concepts.md +426 -0
  19. package/docs/configuration.md +220 -0
  20. package/docs/faq.md +206 -0
  21. package/docs/getting-started.md +251 -0
  22. package/docs/real-run.md +80 -0
  23. package/docs/review-2026-09-14.md +72 -0
  24. package/docs/screenshots/board-400px.png +0 -0
  25. package/docs/screenshots/board-details-open.png +0 -0
  26. package/docs/screenshots/board-done.png +0 -0
  27. package/docs/screenshots/board-drawer.png +0 -0
  28. package/docs/screenshots/board-empty.png +0 -0
  29. package/docs/screenshots/board-handoff.png +0 -0
  30. package/docs/screenshots/board-running.png +0 -0
  31. package/docs/screenshots/demo-1-claude-running.png +0 -0
  32. package/docs/screenshots/demo-2-limit-hit.png +0 -0
  33. package/docs/screenshots/demo-3-handoff-bundle.png +0 -0
  34. package/docs/screenshots/demo-4-codex-running.png +0 -0
  35. package/docs/screenshots/demo-5-done.png +0 -0
  36. package/docs/screenshots/floor-final-1280.png +0 -0
  37. package/docs/screenshots/floor-final-400.png +0 -0
  38. package/docs/screenshots/floor-landing.png +0 -0
  39. package/docs/screenshots/floor.png +0 -0
  40. package/docs/screenshots/share-guest-1280.png +0 -0
  41. package/docs/screenshots/share-owner-1280.png +0 -0
  42. package/docs/screenshots/terminals-1280.png +0 -0
  43. package/docs/screenshots/thumb-done.jpg +0 -0
  44. package/docs/screenshots/thumb-handoff-details.jpg +0 -0
  45. package/docs/screenshots/thumb-limit-hit.jpg +0 -0
  46. package/docs/screenshots/youtube-thumbnail.jpg +0 -0
  47. package/fixtures/limits/agy/agy-resource-exhausted.json +11 -0
  48. package/fixtures/limits/claude/claude-budget-limit.json +11 -0
  49. package/fixtures/limits/claude/claude-max-turns.json +11 -0
  50. package/fixtures/limits/claude/claude-model-limit.json +11 -0
  51. package/fixtures/limits/claude/claude-session-limit.json +11 -0
  52. package/fixtures/limits/claude/claude-weekly-limit.json +11 -0
  53. package/fixtures/limits/codex/codex-quota-exceeded.json +11 -0
  54. package/fixtures/limits/codex/codex-rate-limit-exceeded.json +11 -0
  55. package/fixtures/limits/codex/codex-skills-notice.json +11 -0
  56. package/fixtures/limits/codex/codex-usage-limit-reached.json +11 -0
  57. package/fixtures/limits/codex/codex-usage-limit.json +11 -0
  58. package/fixtures/limits/generic/generic-429.json +11 -0
  59. package/fixtures/limits/generic/generic-overloaded.json +11 -0
  60. package/fixtures/limits/generic/generic-quota.json +11 -0
  61. package/fixtures/limits/generic/generic-rate-limit.json +11 -0
  62. package/fixtures/limits/generic/generic-resource-exhausted.json +11 -0
  63. package/fixtures/limits/generic/generic-usage-limit.json +11 -0
  64. package/fixtures/limits/grok/grok-not-logged-in.json +11 -0
  65. package/fixtures/limits/negative/auth-source-set.json +11 -0
  66. package/fixtures/limits/negative/compile-error.json +11 -0
  67. package/fixtures/limits/negative/empty-stdout-exit-0.json +11 -0
  68. package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +1 -0
  69. package/fixtures/live/agy/cmd.txt +1 -0
  70. package/fixtures/live/agy/err.log +0 -0
  71. package/fixtures/live/agy/out.log +1 -0
  72. package/fixtures/live/agy/parsed.json +5 -0
  73. package/fixtures/live/agy/run.json +18 -0
  74. package/fixtures/live/agy/supervisor.log +2 -0
  75. package/fixtures/live/claude/cmd.txt +1 -0
  76. package/fixtures/live/claude/err.log +0 -0
  77. package/fixtures/live/claude/limit-rate_limit.json +21 -0
  78. package/fixtures/live/claude/out.log +1 -0
  79. package/fixtures/live/claude/parsed.json +11 -0
  80. package/fixtures/live/claude/run.json +18 -0
  81. package/fixtures/live/claude/supervisor.log +2 -0
  82. package/fixtures/live/codex/cmd.txt +1 -0
  83. package/fixtures/live/codex/err.log +1 -0
  84. package/fixtures/live/codex/last.md +2 -0
  85. package/fixtures/live/codex/limit-usage_limit_exceeded.json +25 -0
  86. package/fixtures/live/codex/out.log +8 -0
  87. package/fixtures/live/codex/parsed.json +15 -0
  88. package/fixtures/live/codex/run.json +18 -0
  89. package/fixtures/live/codex/supervisor.log +2 -0
  90. package/fixtures/live/grok/cmd.txt +1 -0
  91. package/fixtures/live/grok/err.log +32 -0
  92. package/fixtures/live/grok/out.log +7 -0
  93. package/fixtures/live/grok/parsed.json +5 -0
  94. package/fixtures/live/grok/run.json +18 -0
  95. package/fixtures/live/grok/supervisor.log +2 -0
  96. package/fixtures/verified.json +34 -0
  97. package/package.json +68 -0
  98. package/scripts/board-shots.mjs +69 -0
  99. package/scripts/build-docs-site.mjs +308 -0
  100. package/scripts/check-claims.mjs +150 -0
  101. package/scripts/clean-clone-check.sh +42 -0
  102. package/scripts/license-sign.mjs +23 -0
  103. package/scripts/limits-table.mjs +38 -0
  104. package/scripts/live-limits.mjs +36 -0
  105. package/scripts/privacy-check.mjs +63 -0
  106. package/scripts/probe.mjs +92 -0
  107. package/scripts/seed-fake-cards.mjs +34 -0
  108. package/scripts/seed-floor-board.mjs +69 -0
  109. package/scripts/seed-wes-board.mjs +91 -0
  110. package/scripts/stripe-setup.mjs +99 -0
  111. package/scripts/vercel-env.mjs +40 -0
  112. package/src/accounts.mjs +119 -0
  113. package/src/adapters/agy.mjs +56 -0
  114. package/src/adapters/claude.mjs +54 -0
  115. package/src/adapters/codex.mjs +73 -0
  116. package/src/adapters/common.mjs +32 -0
  117. package/src/adapters/fake.mjs +48 -0
  118. package/src/adapters/grok.mjs +51 -0
  119. package/src/adapters/index.mjs +27 -0
  120. package/src/adapters/resolve.mjs +40 -0
  121. package/src/attach.mjs +592 -0
  122. package/src/auth.mjs +85 -0
  123. package/src/board/board.css +543 -0
  124. package/src/board/board.js +1307 -0
  125. package/src/board/favicon.svg +1 -0
  126. package/src/board/floor.html +127 -0
  127. package/src/board/floor.js +670 -0
  128. package/src/board/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
  129. package/src/board/fonts/OFL-azeret-mono.txt +94 -0
  130. package/src/board/fonts/atkinson-board.woff2 +0 -0
  131. package/src/board/fonts/azeret-board.woff2 +0 -0
  132. package/src/board/index.html +251 -0
  133. package/src/board/sessions.js +1451 -0
  134. package/src/bundle.mjs +103 -0
  135. package/src/cards.mjs +114 -0
  136. package/src/chain.mjs +264 -0
  137. package/src/commands.mjs +79 -0
  138. package/src/contract.mjs +73 -0
  139. package/src/env.mjs +19 -0
  140. package/src/fsx.mjs +72 -0
  141. package/src/git-snapshot.mjs +152 -0
  142. package/src/handoff.mjs +171 -0
  143. package/src/hook.mjs +49 -0
  144. package/src/land.mjs +106 -0
  145. package/src/launcher.mjs +272 -0
  146. package/src/leases.mjs +86 -0
  147. package/src/ledger.mjs +369 -0
  148. package/src/license.mjs +168 -0
  149. package/src/limits.mjs +97 -0
  150. package/src/live-capture.mjs +69 -0
  151. package/src/mergequeue.mjs +225 -0
  152. package/src/orchestrator.mjs +365 -0
  153. package/src/pipeline.mjs +100 -0
  154. package/src/preferences.mjs +47 -0
  155. package/src/presets.mjs +23 -0
  156. package/src/ratelimit.mjs +49 -0
  157. package/src/redact.mjs +47 -0
  158. package/src/resume.mjs +354 -0
  159. package/src/runner.mjs +438 -0
  160. package/src/scheduler.mjs +118 -0
  161. package/src/server.mjs +845 -0
  162. package/src/session-detail.mjs +129 -0
  163. package/src/sessions.mjs +202 -0
  164. package/src/share.mjs +163 -0
  165. package/src/stations/agent.mjs +42 -0
  166. package/src/stations/build.mjs +9 -0
  167. package/src/stations/human.mjs +5 -0
  168. package/src/stations/land.mjs +6 -0
  169. package/src/stations/plan.mjs +8 -0
  170. package/src/stations/pr.mjs +31 -0
  171. package/src/stations/review.mjs +8 -0
  172. package/src/stations/test.mjs +27 -0
  173. package/src/store.mjs +90 -0
  174. package/src/sync/dashclaw.mjs +70 -0
  175. package/src/sync/index.mjs +54 -0
  176. package/src/sync/workboard.mjs +54 -0
  177. package/src/taps/agy.mjs +64 -0
  178. package/src/taps/claude-usage.mjs +67 -0
  179. package/src/taps/claude.mjs +170 -0
  180. package/src/taps/codex.mjs +286 -0
  181. package/src/trust.mjs +323 -0
  182. package/src/usage.mjs +179 -0
  183. package/src/wait.mjs +30 -0
  184. package/src/worktree.mjs +207 -0
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@ucsandman/legcli",
3
+ "version": "0.7.0",
4
+ "description": "Claude Code and Codex usage limit monitor with an automatic handoff. Type leg claude|codex|agy and get the same interactive agent with a board alongside, 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.",
5
+ "type": "module",
6
+ "license": "SEE LICENSE IN LICENSE",
7
+ "homepage": "https://legcli.com",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/ucsandman/legcli.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://legcli.com/support",
14
+ "email": "legcli@practicalsystems.io"
15
+ },
16
+ "keywords": [
17
+ "claude-usage-limit",
18
+ "claude-code-usage-limit",
19
+ "codex-usage-limit",
20
+ "usage-monitor",
21
+ "usage-limits",
22
+ "claude-code",
23
+ "codex",
24
+ "coding-agents",
25
+ "agent-harness",
26
+ "handoff",
27
+ "kanban",
28
+ "worktree",
29
+ "local-first"
30
+ ],
31
+ "author": "Wes Sander",
32
+ "bin": {
33
+ "leg": "bin/leg.mjs"
34
+ },
35
+ "files": [
36
+ "bin",
37
+ "src",
38
+ "scripts",
39
+ "README.md",
40
+ "LICENSE",
41
+ "NOTICE",
42
+ "CHANGELOG.md",
43
+ "docs",
44
+ "fixtures/limits",
45
+ "fixtures/live",
46
+ "fixtures/verified.json"
47
+ ],
48
+ "engines": {
49
+ "node": ">=22"
50
+ },
51
+ "scripts": {
52
+ "start": "node --env-file-if-exists=.env bin/leg.mjs up",
53
+ "test": "node --test --test-concurrency=1 && node scripts/privacy-check.mjs && node scripts/check-claims.mjs",
54
+ "lint": "eslint .",
55
+ "privacy": "node scripts/privacy-check.mjs",
56
+ "claims": "node scripts/check-claims.mjs",
57
+ "claims:drift": "node scripts/check-claims.mjs --strict",
58
+ "docs": "node scripts/build-docs-site.mjs",
59
+ "dev": "node --env-file-if-exists=.env bin/leg.mjs up --no-open",
60
+ "stop": "node bin/leg.mjs down"
61
+ },
62
+ "devDependencies": {
63
+ "@eslint/js": "^9",
64
+ "eslint": "^9",
65
+ "globals": "^16",
66
+ "marked": "^18.0.13"
67
+ }
68
+ }
@@ -0,0 +1,69 @@
1
+ // Screenshots and measures the board at both widths, reporting the numbers that
2
+ // caught real defects in the v4 redesign: full-page height, per-row height,
3
+ // horizontal overflow, the computed font-size floor, console errors, and a
4
+ // scroll-hold probe. Point it at a board seeded by scripts/seed-wes-board.mjs.
5
+ //
6
+ // node scripts/board-shots.mjs <port> <tag>
7
+ //
8
+ // networkidle never fires on this board because it polls, so it waits for real
9
+ // content instead.
10
+ import { chromium } from 'playwright'
11
+ import { mkdirSync } from 'node:fs'
12
+ import { fileURLToPath } from 'node:url'
13
+
14
+ const port = process.argv[2] || '4861'
15
+ const tag = process.argv[3] || 'run'
16
+ const out = fileURLToPath(new URL('./shots/', import.meta.url))
17
+ mkdirSync(out, { recursive: true })
18
+
19
+ const browser = await chromium.launch()
20
+ const report = {}
21
+
22
+ for (const w of [1280, 400]) {
23
+ const page = await browser.newPage({ viewport: { width: w, height: 900 } })
24
+ const errors = []
25
+ page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()) })
26
+ page.on('pageerror', (e) => errors.push(String(e)))
27
+ // networkidle never fires: the board polls. Wait for real content instead.
28
+ await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'domcontentloaded' })
29
+ await page.waitForFunction(() => document.querySelectorAll('[class*="term"], .session-list > *, .panel').length > 0, { timeout: 15000 }).catch(() => {})
30
+ await page.waitForTimeout(1500)
31
+
32
+ const m = await page.evaluate(() => {
33
+ const rows = [...document.querySelectorAll('.term, .session-list > *')]
34
+ const px = (n) => Math.round(n)
35
+ const small = [...document.querySelectorAll('body *')]
36
+ .filter((e) => e.textContent.trim() && !e.children.length)
37
+ .map((e) => ({ t: e.textContent.trim().slice(0, 28), s: parseFloat(getComputedStyle(e).fontSize) }))
38
+ .filter((x) => x.s < 14)
39
+ return {
40
+ pageHeight: px(document.documentElement.scrollHeight),
41
+ docWidth: px(document.documentElement.scrollWidth),
42
+ viewport: window.innerWidth,
43
+ overflowX: document.documentElement.scrollWidth > window.innerWidth + 1,
44
+ rowCount: rows.length,
45
+ rowHeights: rows.map((r) => px(r.getBoundingClientRect().height)),
46
+ belowFontFloor: small.slice(0, 6),
47
+ }
48
+ })
49
+
50
+ // the fold is shot before anything scrolls the page, or it is a mid-page crop
51
+ await page.screenshot({ path: `${out}${tag}-${w}-fold.png` })
52
+
53
+ // scroll-hold: park the viewport, wait through every timer the page owns,
54
+ // assert it did not move.
55
+ await page.evaluate(() => window.scrollTo(0, 600))
56
+ const before = await page.evaluate(() => window.scrollY)
57
+ await page.waitForTimeout(6000)
58
+ const after = await page.evaluate(() => window.scrollY)
59
+ m.scrollHeld = before === after
60
+ m.scrollDrift = after - before
61
+ m.consoleErrors = errors
62
+
63
+ await page.screenshot({ path: `${out}${tag}-${w}.png`, fullPage: true })
64
+ report[w] = m
65
+ await page.close()
66
+ }
67
+
68
+ await browser.close()
69
+ console.log(JSON.stringify(report, null, 2))
@@ -0,0 +1,308 @@
1
+ #!/usr/bin/env node
2
+ // Renders the public docs to static HTML under site/docs, so a stranger can
3
+ // read them without the repository. The repository is private and stays that
4
+ // way, which means these pages are the only place the claims on the landing
5
+ // page can be checked.
6
+ //
7
+ // node scripts/build-docs-site.mjs
8
+ //
9
+ // Regenerate after editing anything in docs/ and commit the output; Vercel
10
+ // serves site/ as static files and runs no build step.
11
+
12
+ import { readFileSync, writeFileSync, mkdirSync, copyFileSync, readdirSync, rmSync, existsSync } from 'node:fs'
13
+ import { join, dirname, basename } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { marked } from 'marked'
16
+
17
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
18
+
19
+ // The one place the domain lives. Friday's custom domain is a single edit here
20
+ // plus the same constant in site/index.html's canonical and og:url.
21
+ const ORIGIN = process.env.LEG_SITE_ORIGIN || process.env.BATON_SITE_ORIGIN || 'https://legcli.com'
22
+
23
+ // Public docs only. DECISIONS, DEVIATIONS, ERRORS, REUSE, ROADMAP-v2, DEMO and
24
+ // the dated review notes are working files: they name unshipped plans and
25
+ // internal postmortems, and they are not part of what a buyer is promised.
26
+ const PAGES = [
27
+ {
28
+ slug: 'index',
29
+ source: null,
30
+ title: 'Leg docs: usage limits, the board and the handoff',
31
+ description: 'Every Leg document: install, the concepts, the board, configuration, the CLI contract, the agent adapters and the FAQ.',
32
+ },
33
+ {
34
+ slug: 'getting-started',
35
+ nav: 'Install',
36
+ source: 'docs/getting-started.md',
37
+ title: 'Install Leg and monitor your Claude Code usage limit',
38
+ description: 'Install Leg, run your first agent under it, and see the board, the usage reading and the handoff. Node 22+, one npm command.',
39
+ },
40
+ {
41
+ slug: 'concepts',
42
+ nav: 'How it works',
43
+ source: 'docs/concepts.md',
44
+ title: 'How Leg works: sessions, bundles and the wall',
45
+ description: 'The pieces Leg is built from: a session, the usage reading, the handoff bundle, the wall, worktrees and landing work on trunk.',
46
+ },
47
+ {
48
+ slug: 'board-guide',
49
+ nav: 'The board',
50
+ source: 'docs/board-guide.md',
51
+ title: 'The Leg board, panel by panel',
52
+ description: 'Every card, gauge, button and drawer on the Leg board, what each number means, and where the reading behind it came from.',
53
+ },
54
+ {
55
+ slug: 'configuration',
56
+ nav: 'Configuration',
57
+ source: 'docs/configuration.md',
58
+ title: 'Leg configuration and environment variables',
59
+ description: 'Every Leg environment variable, the config file, network exposure, the token seam and the folder-trust switch.',
60
+ },
61
+ {
62
+ slug: 'cli-contracts',
63
+ nav: 'What it reads',
64
+ source: 'docs/cli-contracts.md',
65
+ title: 'What Leg reads from Claude Code, Codex and agy',
66
+ description: 'The exact usage endpoints, hooks, log lines and limit strings Baton reads from Claude Code, Codex and agy, each cited to its source.',
67
+ },
68
+ {
69
+ slug: 'adapters',
70
+ nav: 'Adapters',
71
+ source: 'docs/adapters.md',
72
+ title: 'Leg agent adapters for Claude Code, Codex and agy',
73
+ description: 'How each agent adapter spawns its CLI, what it strips from the environment, and how to tell whether a tap is live or docs-only.',
74
+ },
75
+ {
76
+ slug: 'faq',
77
+ nav: 'FAQ',
78
+ source: 'docs/faq.md',
79
+ title: 'Leg FAQ: what it edits, what it sends, how the license works',
80
+ description: 'What Leg edits and never edits, whether it sends anything anywhere, what happens to your license, and what it does not do.',
81
+ },
82
+ {
83
+ slug: 'real-run',
84
+ nav: 'A real run',
85
+ source: 'docs/real-run.md',
86
+ title: 'A real run: Claude Code hits its usage limit, Codex continues',
87
+ description: 'One recorded session: Claude Code working, the limit landing, the bundle saved, and Codex picking the work up in the same terminal.',
88
+ },
89
+ {
90
+ slug: 'vocabulary',
91
+ nav: 'Vocabulary',
92
+ source: 'docs/VOCABULARY.md',
93
+ title: 'Leg vocabulary',
94
+ description: 'The words Leg uses on the board and in the CLI, each with the one meaning it carries everywhere.',
95
+ },
96
+ {
97
+ slug: 'readme',
98
+ nav: 'README',
99
+ source: 'README.md',
100
+ title: 'Leg README',
101
+ description: 'The complete Leg README as shipped in the npm package: what it does, what it reads, what it never touches, and the license.',
102
+ },
103
+ {
104
+ slug: 'changelog',
105
+ nav: 'Changelog',
106
+ source: 'CHANGELOG.md',
107
+ title: 'Leg changelog',
108
+ description: 'Every released version of Leg and what changed in it.',
109
+ },
110
+ ]
111
+
112
+ const escapeHtml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
113
+
114
+ // Where a markdown link inside docs/ should point once the docs are pages.
115
+ const slugForSource = new Map(PAGES.filter((p) => p.source).map((p) => [p.source.toLowerCase(), p.slug]))
116
+
117
+ function rewriteHref (href) {
118
+ if (/^(https?:|mailto:|#)/i.test(href)) return href
119
+ const [path, hash = ''] = href.split('#')
120
+ if (!path) return href
121
+ const normalised = path.replace(/^\.\//, '').replace(/^\.\.\//, '').toLowerCase()
122
+ const withDocs = normalised.startsWith('docs/') ? normalised : `docs/${normalised}`
123
+ const slug = slugForSource.get(normalised) || slugForSource.get(withDocs)
124
+ if (slug) return `/docs/${slug}${hash ? '#' + hash : ''}`
125
+ // An image or asset that travels with the docs.
126
+ if (/\.(png|jpe?g|gif|svg|webp|mp4)$/i.test(path)) return `/img/docs/${basename(path)}`
127
+ // Files with a public home of their own.
128
+ const elsewhere = {
129
+ license: '/license',
130
+ 'security.md': '/support#security',
131
+ 'code_of_conduct.md': '/support',
132
+ 'contributing.md': '/support',
133
+ }
134
+ if (elsewhere[normalised]) return elsewhere[normalised] + (hash ? '#' + hash : '')
135
+ // Everything left is a working file that ships inside the package but has no
136
+ // public page: DEMO, REUSE, ROADMAP-v2, DEVIATIONS, NOTICE, the site source.
137
+ // The package is where a reader can actually open it, so point there rather
138
+ // than dangling a 404 at a stranger.
139
+ return 'https://www.npmjs.com/package/@ucsandman/legcli'
140
+ }
141
+
142
+ const slugify = (text) => text.toLowerCase().replace(/<[^>]+>/g, '').replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-')
143
+
144
+ marked.use({
145
+ gfm: true,
146
+ breaks: false,
147
+ // Rewriting hrefs on the token, before rendering, keeps the default link and
148
+ // image renderers (and their escaping) exactly as marked ships them.
149
+ walkTokens (token) {
150
+ if (token.type === 'link' || token.type === 'image') token.href = rewriteHref(token.href)
151
+ },
152
+ renderer: {
153
+ // Headings get an id so the on-this-page nav and cross-doc anchors resolve.
154
+ heading ({ tokens, depth }) {
155
+ const text = this.parser.parseInline(tokens)
156
+ const id = slugify(tokens.map((t) => t.raw).join(''))
157
+ return `<h${depth} id="${id}"><a class="anchor" href="#${id}">${text}</a></h${depth}>\n`
158
+ },
159
+ },
160
+ })
161
+
162
+ function page ({ slug, title, description, body, headings }) {
163
+ const url = slug === 'index' ? `${ORIGIN}/docs` : `${ORIGIN}/docs/${slug}`
164
+ // Short labels here, not the SEO titles: the sidebar is for finding a page,
165
+ // and at phone width these become a single swipeable row of pills.
166
+ const nav = [
167
+ `<li><a href="/docs"${slug === 'index' ? ' aria-current="page"' : ''}>All docs</a></li>`,
168
+ ...PAGES.filter((p) => p.slug !== 'index')
169
+ .map((p) => `<li><a href="/docs/${p.slug}"${p.slug === slug ? ' aria-current="page"' : ''}>${escapeHtml(p.nav || p.title)}</a></li>`),
170
+ ].join('\n')
171
+ const onThisPage = headings.length
172
+ ? `<nav class="toc" aria-label="On this page"><p class="toc-title">On this page</p><ul>${headings.map((h) => `<li><a href="#${h.id}">${escapeHtml(h.text)}</a></li>`).join('')}</ul></nav>`
173
+ : ''
174
+ return `<!doctype html>
175
+ <html lang="en">
176
+ <head>
177
+ <meta charset="utf-8">
178
+ <meta name="viewport" content="width=device-width, initial-scale=1">
179
+ <title>${escapeHtml(/legcli/i.test(title) ? title : `${title} | LegCli`)}</title>
180
+ <meta name="description" content="${escapeHtml(description)}">
181
+ <link rel="canonical" href="${url}">
182
+ <meta name="theme-color" content="#0E1012">
183
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml">
184
+ <meta property="og:type" content="article">
185
+ <meta property="og:url" content="${url}">
186
+ <meta property="og:site_name" content="LegCli">
187
+ <meta property="og:title" content="${escapeHtml(title)}">
188
+ <meta property="og:description" content="${escapeHtml(description)}">
189
+ <meta property="og:image" content="${ORIGIN}/og.png?v=2">
190
+ <meta name="twitter:card" content="summary_large_image">
191
+ <meta name="twitter:title" content="${escapeHtml(title)}">
192
+ <meta name="twitter:description" content="${escapeHtml(description)}">
193
+ <meta name="twitter:image" content="${ORIGIN}/og.png?v=2">
194
+ <link rel="preload" href="/fonts/atkinson-hyperlegible-next-var.woff2" as="font" type="font/woff2" crossorigin>
195
+ <link rel="preload" href="/fonts/azeret-mono-var.woff2" as="font" type="font/woff2" crossorigin>
196
+ <link rel="stylesheet" href="/style.css">
197
+ <link rel="stylesheet" href="/docs.css">
198
+ <script defer src="/_vercel/insights/script.js"></script>
199
+ </head>
200
+ <body class="docs">
201
+ <a class="skip" href="#doc">Skip to the document</a>
202
+ <header class="docs-top">
203
+ <div class="docs-top-wrap">
204
+ <a class="docs-home" href="/">LegCli</a>
205
+ <nav aria-label="Site"><a href="/docs">Docs</a> <a href="/#pricing">Pricing</a> <a href="/license">License</a></nav>
206
+ </div>
207
+ </header>
208
+ <div class="docs-shell">
209
+ <nav class="docs-nav" aria-label="Documentation">
210
+ <p class="docs-nav-title">Documentation</p>
211
+ <ul>
212
+ ${nav}
213
+ </ul>
214
+ </nav>
215
+ <main id="doc" class="docs-body">
216
+ ${onThisPage}
217
+ ${body}
218
+ <hr class="docs-end">
219
+ <p class="docs-foot">Leg is commercial, source-available software by Wes Sander. The source you run ships in the npm package. Questions or a refund: <a href="mailto:legcli@practicalsystems.io">legcli@practicalsystems.io</a>.</p>
220
+ </main>
221
+ </div>
222
+ </body>
223
+ </html>
224
+ `
225
+ }
226
+
227
+ function headingsOf (markdown) {
228
+ const out = []
229
+ let inFence = false
230
+ for (const line of markdown.split('\n')) {
231
+ if (/^\s*```/.test(line)) { inFence = !inFence; continue }
232
+ if (inFence) continue
233
+ const m = /^##\s+(.+?)\s*$/.exec(line)
234
+ if (m) out.push({ text: m[1].replace(/`/g, ''), id: slugify(m[1]) })
235
+ }
236
+ return out
237
+ }
238
+
239
+ const outDir = join(root, 'site', 'docs')
240
+ rmSync(outDir, { recursive: true, force: true })
241
+ mkdirSync(outDir, { recursive: true })
242
+
243
+ // Screenshots referenced from the markdown travel with the pages.
244
+ const shots = join(root, 'docs', 'screenshots')
245
+ const imgDir = join(root, 'site', 'img', 'docs')
246
+ mkdirSync(imgDir, { recursive: true })
247
+ let copied = 0
248
+ if (existsSync(shots)) {
249
+ for (const f of readdirSync(shots)) {
250
+ if (!/\.(png|jpe?g|gif|svg|webp)$/i.test(f)) continue
251
+ copyFileSync(join(shots, f), join(imgDir, f))
252
+ copied++
253
+ }
254
+ }
255
+
256
+ const written = []
257
+ for (const p of PAGES) {
258
+ let body, headings
259
+ if (p.slug === 'index') {
260
+ const cards = PAGES.filter((x) => x.slug !== 'index')
261
+ .map((x) => `<li><a href="/docs/${x.slug}"><strong>${escapeHtml(x.title.replace(/ \| .*$/, ''))}</strong><span>${escapeHtml(x.description)}</span></a></li>`)
262
+ .join('\n')
263
+ body = `<h1>Leg documentation</h1>
264
+ <p class="lede">Leg is a local wrapper for coding-agent CLIs: type <code>leg claude</code>, <code>leg codex</code> or <code>leg agy</code> and you get the same interactive agent with a board beside it, usage tracking per agent and account, a handoff bundle kept current, and an automatic handoff to the next agent in the same terminal when the usage limit hits.</p>
265
+ <p class="lede">The source repository is private. These pages carry the same text as the documentation shipped inside the npm package, so every claim on the site can be checked before you buy, and the source itself is readable at <code>$(npm root -g)/legcli/src</code> after you install.</p>
266
+ <ul class="doc-cards">
267
+ ${cards}
268
+ </ul>`
269
+ headings = []
270
+ } else {
271
+ const md = readFileSync(join(root, p.source), 'utf8')
272
+ headings = headingsOf(md)
273
+ body = marked.parse(md)
274
+ }
275
+ const file = p.slug === 'index' ? join(outDir, 'index.html') : join(outDir, `${p.slug}.html`)
276
+ writeFileSync(file, page({ ...p, body, headings }))
277
+ written.push(p.slug === 'index' ? '/docs' : `/docs/${p.slug}`)
278
+ }
279
+
280
+ // The sitemap has to list every page that is now indexable, or the new docs
281
+ // are invisible to search and to the AI crawlers that read sitemaps.
282
+ //
283
+ // No `lastmod`. It is optional, and here it cannot be made honest: this file is
284
+ // generated and committed alongside the pages it describes, so a date read from
285
+ // the clock changes on every build, and a date read from git changes the moment
286
+ // the sources are committed — the sitemap would always describe the commit
287
+ // before the one containing it. Search engines discount a lastmod they cannot
288
+ // trust, so an absent one is worth more than a wrong one.
289
+ const urls = [
290
+ { loc: `${ORIGIN}/`, priority: '1.0', changefreq: 'weekly' },
291
+ { loc: `${ORIGIN}/docs`, priority: '0.9', changefreq: 'weekly' },
292
+ ...PAGES.filter((p) => p.slug !== 'index').map((p) => ({
293
+ loc: `${ORIGIN}/docs/${p.slug}`,
294
+ priority: '0.7',
295
+ changefreq: 'weekly',
296
+ })),
297
+ { loc: `${ORIGIN}/support`, priority: '0.5', changefreq: 'monthly' },
298
+ { loc: `${ORIGIN}/license`, priority: '0.3', changefreq: 'yearly' },
299
+ ]
300
+ writeFileSync(join(root, 'site', 'sitemap.xml'), `<?xml version="1.0" encoding="UTF-8"?>
301
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
302
+ ${urls.map((u) => ` <url><loc>${u.loc}</loc><changefreq>${u.changefreq}</changefreq><priority>${u.priority}</priority></url>`).join('\n')}
303
+ </urlset>
304
+ `)
305
+
306
+ console.log(`docs pages written: ${written.length} (${written.join(', ')})`)
307
+ console.log(`screenshots copied: ${copied} -> site/img/docs`)
308
+ console.log(`sitemap urls: ${urls.length}`)
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ // Every dated or numbered claim Baton makes in public, checked against one
3
+ // file. Two separate jobs:
4
+ //
5
+ // node scripts/check-claims.mjs consistency, offline, runs in CI
6
+ // node scripts/check-claims.mjs --strict also asks each CLI what it is now
7
+ //
8
+ // Consistency asks: does every surface state the versions and counts in
9
+ // fixtures/verified.json? A surface that says "463 tests" while another says
10
+ // "406" is a trust liability whichever one is right, so a mismatch fails.
11
+ //
12
+ // Strict asks: is the pin still true? An agent CLI ships most weeks, and
13
+ // "verified against 2.1.268" quietly becomes a claim about a version nobody
14
+ // runs any more. This half is scheduled weekly rather than run on every push,
15
+ // because drift is not a broken build, it is a prompt to go and re-verify.
16
+ //
17
+ // Every verdict prints the number of things it looked at. A check that can
18
+ // pass without touching anything is indistinguishable from a clean week.
19
+
20
+ import { readFileSync, existsSync } from 'node:fs'
21
+ import { join, dirname } from 'node:path'
22
+ import { fileURLToPath } from 'node:url'
23
+ import { execFileSync } from 'node:child_process'
24
+
25
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
26
+ const strict = process.argv.includes('--strict')
27
+ const testsArg = process.argv.indexOf('--tests')
28
+ const measuredTests = testsArg !== -1 ? Number(process.argv[testsArg + 1]) : null
29
+
30
+ const pin = JSON.parse(readFileSync(join(root, 'fixtures', 'verified.json'), 'utf8'))
31
+ const problems = []
32
+ let checked = 0
33
+
34
+ // ---------------------------------------------------------------- consistency
35
+
36
+ // A version string is only a claim about a CLI when the CLI is named near it,
37
+ // so each surface is searched for the version and then for a contradicting one:
38
+ // any OTHER version of the same shape attached to the same CLI's name.
39
+ const versionsSeen = new Map()
40
+
41
+ for (const surface of pin.surfaces) {
42
+ const path = join(root, surface)
43
+ if (!existsSync(path)) { problems.push(`surface missing: ${surface}`); continue }
44
+ const text = readFileSync(path, 'utf8')
45
+
46
+ for (const [key, cli] of Object.entries(pin.clis)) {
47
+ // Every version-shaped token that sits next to this CLI's label.
48
+ const near = new RegExp(`${cli.label.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}[^\\n]{0,24}?(\\d+\\.\\d+\\.\\d+)`, 'g')
49
+ for (const m of text.matchAll(near)) {
50
+ checked++
51
+ const found = m[1]
52
+ versionsSeen.set(`${key}:${found}`, (versionsSeen.get(`${key}:${found}`) || 0) + 1)
53
+ if (found !== cli.version) {
54
+ problems.push(`${surface}: says ${cli.label} ${found}, fixtures/verified.json pins ${cli.version}`)
55
+ }
56
+ }
57
+ }
58
+
59
+ // Test counts. "463 tests" and "406 tests" on two pages is the same defect
60
+ // whichever number is right.
61
+ for (const m of text.matchAll(/(\d{2,5})\s+tests\b/g)) {
62
+ checked++
63
+ if (Number(m[1]) !== pin.tests) {
64
+ problems.push(`${surface}: says ${m[1]} tests, fixtures/verified.json pins ${pin.tests}`)
65
+ }
66
+ }
67
+
68
+ // The verification date, in both the forms the surfaces use.
69
+ for (const m of text.matchAll(/(?:verified|checked)[^\n.]{0,40}?(\d{4}-\d{2}-\d{2}|\d{1,2} [A-Z][a-z]+ \d{4})/gi)) {
70
+ checked++
71
+ const found = m[1]
72
+ if (found !== pin.verifiedOn && found !== pin.verifiedOnLong) {
73
+ problems.push(`${surface}: says verified ${found}, fixtures/verified.json pins ${pin.verifiedOn} / ${pin.verifiedOnLong}`)
74
+ }
75
+ }
76
+ }
77
+
78
+ if (measuredTests !== null && Number.isFinite(measuredTests)) {
79
+ checked++
80
+ if (measuredTests !== pin.tests) {
81
+ problems.push(`the suite just reported ${measuredTests} tests, fixtures/verified.json pins ${pin.tests}`)
82
+ }
83
+ }
84
+
85
+ console.log(`claims: ${checked} version, count and date mentions checked across ${pin.surfaces.length} surfaces`)
86
+
87
+ // ---------------------------------------------------------------------- drift
88
+
89
+ if (strict) {
90
+ let probed = 0
91
+ const drift = []
92
+
93
+ for (const [, cli] of Object.entries(pin.clis)) {
94
+ let current = null
95
+ let how = null
96
+
97
+ if (cli.npm) {
98
+ try {
99
+ const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(cli.npm)}/latest`, { headers: { Accept: 'application/json' } })
100
+ if (res.ok) { current = (await res.json()).version; how = `npm ${cli.npm}` }
101
+ else drift.push(`${cli.label}: npm returned ${res.status}, could not check`)
102
+ } catch (error) {
103
+ drift.push(`${cli.label}: npm request failed (${error.message}), could not check`)
104
+ }
105
+ } else {
106
+ // No registry to ask, so ask the binary if this machine has it. Spawned
107
+ // as argv and never through a shell, like every other spawn in this
108
+ // repo; on Windows that means naming the shim extension ourselves rather
109
+ // than letting cmd resolve it.
110
+ const [bin, ...args] = cli.probe.split(' ')
111
+ const candidates = process.platform === 'win32' ? [`${bin}.cmd`, `${bin}.exe`, bin] : [bin]
112
+ for (const candidate of candidates) {
113
+ try {
114
+ current = execFileSync(candidate, args, { encoding: 'utf8', timeout: 20000, stdio: ['ignore', 'pipe', 'ignore'] }).trim().split('\n')[0].trim()
115
+ how = cli.probe
116
+ break
117
+ } catch { /* try the next spelling */ }
118
+ }
119
+ if (!current) {
120
+ console.log(` ${cli.label}: no npm package and ${cli.probe} is not available here; not checked`)
121
+ }
122
+ }
123
+
124
+ if (!current) continue
125
+ probed++
126
+ if (current !== cli.version) {
127
+ drift.push(`${cli.label}: pinned ${cli.version}, current ${current} (${how})`)
128
+ }
129
+ }
130
+
131
+ console.log(`drift: ${probed} of ${Object.keys(pin.clis).length} CLIs reachable and compared`)
132
+
133
+ if (drift.length) {
134
+ console.error(`\nThe verified-against versions are behind. Re-run the taps on a real machine, then update fixtures/verified.json and every surface in the same commit.\n`)
135
+ for (const d of drift) console.error(` - ${d}`)
136
+ process.exitCode = 1
137
+ } else if (probed > 0) {
138
+ console.log(`every reachable CLI still matches the pin of ${pin.verifiedOn}`)
139
+ }
140
+ }
141
+
142
+ // --------------------------------------------------------------------- verdict
143
+
144
+ if (problems.length) {
145
+ console.error(`\n${problems.length} claim${problems.length === 1 ? '' : 's'} disagree with fixtures/verified.json:\n`)
146
+ for (const p of problems) console.error(` - ${p}`)
147
+ process.exitCode = 1
148
+ } else {
149
+ console.log('every surface agrees with the pin')
150
+ }
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env bash
2
+ # clean-clone-check — prove baton runs from a fresh clone: clone, install,
3
+ # test, lint, dry launcher. Usage: bash scripts/clean-clone-check.sh [scratch-dir]
4
+ # Prints "CLEAN-CLONE PASS tests=<n>" or "CLEAN-CLONE FAIL step=<step>".
5
+ set -uo pipefail
6
+ SRC="$(cd "$(dirname "$0")/.." && pwd)"
7
+ SCRATCH="${1:-${TMPDIR:-/tmp}}"
8
+ TS="$(date +%s)"
9
+ DEST="$SCRATCH/baton-clean-$TS"
10
+ LOG="$DEST.log"
11
+ fail() { echo "CLEAN-CLONE FAIL step=$1 (log: $LOG)"; tail -20 "$LOG" 2>/dev/null; exit 1; }
12
+
13
+ mkdir -p "$SCRATCH" || fail mkdir
14
+ echo "clone $SRC -> $DEST"
15
+ git clone -q "file://$SRC" "$DEST" > "$LOG" 2>&1 || fail clone
16
+ cd "$DEST" || fail cd
17
+
18
+ echo "npm ci"
19
+ if ! npm ci --no-audit --no-fund >> "$LOG" 2>&1; then
20
+ if grep -q edgesOut "$LOG"; then
21
+ echo "npm ci hit edgesOut; retrying with npx --yes npm@latest ci"
22
+ npx --yes npm@latest ci --no-audit --no-fund >> "$LOG" 2>&1 || fail npm-ci
23
+ else
24
+ fail npm-ci
25
+ fi
26
+ fi
27
+
28
+ echo "npm test"
29
+ npm test >> "$LOG" 2>&1 || fail npm-test
30
+ TESTS="$(grep -E '^ℹ tests [0-9]+' "$LOG" | tail -1 | grep -oE '[0-9]+$')"
31
+ PASS="$(grep -E '^ℹ pass [0-9]+' "$LOG" | tail -1 | grep -oE '[0-9]+$')"
32
+ FAILS="$(grep -E '^ℹ fail [0-9]+' "$LOG" | tail -1 | grep -oE '[0-9]+$')"
33
+ [ "${FAILS:-1}" = "0" ] || fail npm-test-fail
34
+ [ "${TESTS:-0}" = "${PASS:-x}" ] || fail npm-test-count
35
+
36
+ echo "npm run lint"
37
+ npm run lint >> "$LOG" 2>&1 || fail lint
38
+
39
+ echo "node bin/baton.mjs up --dry"
40
+ BATON_HOME="$DEST/.baton-home" node bin/baton.mjs up --dry >> "$LOG" 2>&1 || fail up-dry
41
+
42
+ echo "CLEAN-CLONE PASS tests=$TESTS"
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ // Sign a Baton license key by hand (the site's /api/key does the same for
3
+ // paid orders). Needs BATON_LICENSE_PRIVATE_KEY in the environment:
4
+ // node --env-file=.env scripts/license-sign.mjs --plan personal --email you@example.com
5
+ // node --env-file=.env scripts/license-sign.mjs --plan team --email ops@acme.com --seats 5 --months 1
6
+ // Prints the key on stdout and nothing else.
7
+ import { randomBytes } from 'node:crypto'
8
+ import { signLicense, emailHash } from '../src/license.mjs'
9
+
10
+ const args = {}
11
+ const argv = process.argv.slice(2)
12
+ for (let i = 0; i < argv.length; i++) if (argv[i].startsWith('--')) { args[argv[i].slice(2)] = argv[i + 1]; i++ }
13
+ const priv = (process.env.LEG_LICENSE_PRIVATE_KEY || process.env.BATON_LICENSE_PRIVATE_KEY)
14
+ if (!priv) { process.stderr.write('BATON_LICENSE_PRIVATE_KEY is not set\n'); process.exit(2) }
15
+ const plan = args.plan === 'team' ? 'team' : 'personal'
16
+ const issued = args.issued || new Date().toISOString().slice(0, 10)
17
+ const months = parseInt(args.months || (plan === 'team' ? '1' : '12'), 10)
18
+ const end = new Date(issued + 'T00:00:00Z'); end.setUTCMonth(end.getUTCMonth() + months)
19
+ const endIso = new Date(end.getTime() + (plan === 'team' ? 3 * 86400000 : 0)).toISOString().slice(0, 10)
20
+ const payload = { v: 1, id: args.id || ('lic_' + randomBytes(8).toString('hex')), plan, seats: parseInt(args.seats || '1', 10), email_hash: emailHash(args.email || ''), issued }
21
+ if (plan === 'personal') payload.updates_until = endIso
22
+ else { payload.expires = endIso; if (args.sub) payload.sub = args.sub }
23
+ process.stdout.write(signLicense(payload, priv) + '\n')