@tech-leads-club/harness-toolkit 0.2.4 → 0.3.1
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/README.md +68 -37
- package/bin/tlc-build.mjs +93 -0
- package/bin/tlc-cli.ts +110 -66
- package/bin/tlc-exec.mjs +16 -13
- package/config.example.json +1 -1
- package/dist/compact-before.mjs +66 -8
- package/dist/doctor.mjs +178 -41
- package/dist/help-topic.mjs +0 -0
- package/dist/init-project.mjs +2 -7
- package/dist/install-runtime.mjs +6594 -281
- package/dist/lessons-cli.mjs +67 -1
- package/dist/obs-cli.mjs +64 -1
- package/dist/price-lookup.mjs +45 -23
- package/dist/prompt-submit.mjs +66 -8
- package/dist/refresh-model-prices.mjs +7190 -46
- package/dist/response-after.mjs +66 -8
- package/dist/run.mjs +66 -8
- package/dist/session-end.mjs +66 -8
- package/dist/session-start.mjs +66 -8
- package/dist/shim.mjs +66 -3
- package/dist/stop.mjs +66 -8
- package/dist/subagent-start.mjs +66 -8
- package/dist/subagent-stop.mjs +66 -8
- package/dist/support.mjs +64 -1
- package/dist/tlc-cli.mjs +193 -87
- package/dist/tool-after.mjs +111 -31
- package/dist/tool-before.mjs +66 -8
- package/dist/tool-failure.mjs +66 -8
- package/dist/uninstall-runtime.mjs +9 -10
- package/docs/log.md +2 -0
- package/docs/measure.md +35 -31
- package/package.json +5 -5
- package/src/core/core.facade.ts +7 -0
- package/src/core/index.ts +1 -0
- package/src/core/pricing/pricing.freshness.ts +118 -0
- package/src/core/skill/skill.link.ts +14 -3
- package/src/entrypoints/shim.ts +8 -2
- package/src/platform/links.ts +73 -0
- package/src/platform/pricing.ts +139 -31
- package/src/providers/cursor/cursor.wiring.ts +11 -8
- package/tools/doctor.ts +78 -8
- package/tools/init-project.ts +7 -7
- package/tools/install-runtime.ts +110 -7
- package/tools/refresh-model-prices.ts +242 -75
- package/tools/uninstall-runtime.ts +23 -19
- package/bin/tlc-build +0 -80
- package/bin/tlc-exec +0 -10
- package/bin/tlc-exec.cmd +0 -4
- package/model-aliases.json +0 -12
- package/model-prices.cursor.json +0 -410
- package/model-prices.json +0 -1
package/README.md
CHANGED
|
@@ -1,9 +1,46 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src=".github/assets/logo.png" alt="Tech Leads Club" width="400" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<img src="https://img.shields.io/npm/v/@tech-leads-club/harness-toolkit?style=flat-square&color=blue" alt="npm version" />
|
|
7
|
+
<img src="https://img.shields.io/npm/dt/@tech-leads-club/harness-toolkit?style=flat-square&color=blue" alt="total downloads" />
|
|
8
|
+
<img src="https://img.shields.io/npm/dm/@tech-leads-club/harness-toolkit?style=flat-square&color=blue" alt="monthly downloads" />
|
|
9
|
+
<img src="https://img.shields.io/badge/license-PolyForm%20Noncommercial-blue?style=flat-square" alt="license" />
|
|
10
|
+
<img src="https://img.shields.io/github/actions/workflow/status/tech-leads-club/harness-toolkit/release.yml?style=flat-square" alt="build status" />
|
|
11
|
+
</p>
|
|
12
|
+
|
|
13
|
+
<p align="center">
|
|
14
|
+
<img src="https://img.shields.io/badge/Bun-recommended-black?style=flat-square&logo=bun" alt="bun" />
|
|
15
|
+
<img src="https://img.shields.io/badge/node-%3E%3D24-brightgreen?style=flat-square&logo=node.js" alt="node version" />
|
|
16
|
+
<img src="https://img.shields.io/badge/TypeScript-strict-blue?style=flat-square&logo=typescript" alt="typescript" />
|
|
17
|
+
<img src="https://img.shields.io/badge/provenance-SLSA%20v1-green?style=flat-square&logo=npm" alt="npm provenance" />
|
|
18
|
+
<img src="https://img.shields.io/badge/dependencies-0%20runtime-brightgreen?style=flat-square" alt="zero runtime dependencies" />
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
<p align="center">
|
|
22
|
+
<img src="https://img.shields.io/badge/Cursor-supported-black?style=flat-square" alt="cursor" />
|
|
23
|
+
<img src="https://img.shields.io/badge/Claude%20Code-supported-black?style=flat-square&logo=anthropic" alt="claude code" />
|
|
24
|
+
<img src="https://img.shields.io/badge/Linux%20%C2%B7%20macOS%20%C2%B7%20Windows-tested%20in%20CI-informational?style=flat-square" alt="platforms" />
|
|
25
|
+
</p>
|
|
26
|
+
|
|
27
|
+
<p align="center">
|
|
28
|
+
<img src="https://img.shields.io/github/stars/tech-leads-club/harness-toolkit?style=flat-square&color=yellow" alt="github stars" />
|
|
29
|
+
<img src="https://img.shields.io/github/contributors/tech-leads-club/harness-toolkit?style=flat-square&color=orange" alt="contributors" />
|
|
30
|
+
<img src="https://img.shields.io/github/last-commit/tech-leads-club/harness-toolkit?style=flat-square" alt="last commit" />
|
|
31
|
+
</p>
|
|
32
|
+
|
|
33
|
+
<h1 align="center">🛡️ Harness Toolkit</h1>
|
|
34
|
+
|
|
35
|
+
<p align="center">
|
|
36
|
+
<strong>Steers Cursor and Claude Code agents with gates → follow-up → handoff → policy</strong>
|
|
37
|
+
</p>
|
|
38
|
+
|
|
39
|
+
<p align="center">
|
|
40
|
+
Hooks fire on the editor's own events. The harness answers each one with a decision — <b>allow</b>, <b>ask</b>,
|
|
41
|
+
<b>deny</b>, or text injected into the turn — and writes a record of what it decided and why.
|
|
42
|
+
<b>7 floor rules</b> no configuration can reach, <b>3 always-on checks</b>, and <b>23 rails</b> you choose.
|
|
43
|
+
</p>
|
|
7
44
|
|
|
8
45
|
- **[Everything it validates](#everything-it-validates)** — the whole list, one row per check
|
|
9
46
|
- **[How to see any of it](#how-to-see-any-of-it)** — the command behind each row
|
|
@@ -12,11 +49,10 @@ text injected into the turn — and writes a record of what it decided and why.
|
|
|
12
49
|
## Start here
|
|
13
50
|
|
|
14
51
|
```bash
|
|
15
|
-
npm i -g @tech-leads-club/harness-toolkit
|
|
16
|
-
tlc harness install
|
|
52
|
+
npm i -g @tech-leads-club/harness-toolkit && tlc harness install
|
|
17
53
|
```
|
|
18
54
|
|
|
19
|
-
Then restart Cursor or Claude Code. That is the whole setup —
|
|
55
|
+
Then restart Cursor or Claude Code. That is the whole setup — `install` finds which of the two you
|
|
20
56
|
have and wires only those, and the harness works in every repository right away with a safe baseline.
|
|
21
57
|
|
|
22
58
|
To give one project its own rules, open it and say **"setup harness"** to the agent, or run
|
|
@@ -41,12 +77,11 @@ To give one project its own rules, open it and say **"setup harness"** to the ag
|
|
|
41
77
|
12. [Connect a project](#connect-a-project)
|
|
42
78
|
13. [Paths and shared state](#paths-and-shared-state)
|
|
43
79
|
14. [Ship claims](#ship-claims)
|
|
44
|
-
15. [
|
|
45
|
-
16. [
|
|
46
|
-
17. [
|
|
47
|
-
18. [
|
|
48
|
-
19. [
|
|
49
|
-
20. [License](#license)
|
|
80
|
+
15. [Prices](#prices)
|
|
81
|
+
16. [Troubleshooting](#troubleshooting)
|
|
82
|
+
17. [Documentation](#documentation)
|
|
83
|
+
18. [Contributing](#contributing)
|
|
84
|
+
19. [License](#license)
|
|
50
85
|
|
|
51
86
|
## Everything it validates
|
|
52
87
|
|
|
@@ -214,17 +249,19 @@ nothing reads — `src/providers/provider.degrade.ts`.
|
|
|
214
249
|
|
|
215
250
|
| Dependency | Notes |
|
|
216
251
|
|------------|--------|
|
|
217
|
-
| **Bun** *or* **Node.js 24+** | Either one is enough. Bun runs every hook directly
|
|
218
|
-
| **npm** | Delivers the package
|
|
219
|
-
|
|
252
|
+
| **Bun** *or* **Node.js 24+** | Either one is enough. Bun runs every hook directly (~1 ms/hook); Node runs the shipped bundles (~27 ms/hook). With neither, the install stops and names both fixes |
|
|
253
|
+
| **npm** | Delivers the package, and generates the `tlc` shim for the platform it runs on |
|
|
254
|
+
|
|
255
|
+
Linux, macOS and Windows, same commands and same code path. CI runs the full suite and the bundle build on all
|
|
256
|
+
three on every push; hooks firing inside an editor session on Windows are outside that coverage
|
|
257
|
+
([`docs/decisions/ad-006.md`](docs/decisions/ad-006.md), [`docs/decisions/ad-097.md`](docs/decisions/ad-097.md)).
|
|
220
258
|
|
|
221
259
|
## Install
|
|
222
260
|
|
|
223
|
-
|
|
261
|
+
One command, the same on every platform.
|
|
224
262
|
|
|
225
263
|
```bash
|
|
226
|
-
npm i -g @tech-leads-club/harness-toolkit
|
|
227
|
-
tlc harness install
|
|
264
|
+
npm i -g @tech-leads-club/harness-toolkit && tlc harness install
|
|
228
265
|
```
|
|
229
266
|
|
|
230
267
|
The package is the delivery vehicle and `tlc harness install` is what puts the runtime in place. They are
|
|
@@ -301,8 +338,8 @@ Reload/restart the provider session afterward if hooks or the init skill should
|
|
|
301
338
|
| `managed checkout` | moves it to upstream with a hard reset. Do not develop there — a local change is discarded |
|
|
302
339
|
| `link to a working clone` | nothing in the clone. That is a contributor install; you pull it yourself |
|
|
303
340
|
|
|
304
|
-
|
|
305
|
-
|
|
341
|
+
The bundles are rebuilt only when one is missing, and only Bun builds them — a second bundler emits different
|
|
342
|
+
bytes for the same source.
|
|
306
343
|
|
|
307
344
|
**If `update` aborts on `dist/`, install the package again.** A stuck install cannot deliver its own fix — the fix
|
|
308
345
|
lives in the revision `update` has to fetch — so `npm i -g @tech-leads-club/harness-toolkit@latest` followed by
|
|
@@ -352,7 +389,8 @@ graph LR
|
|
|
352
389
|
| Project shim (per provider) | `<repo>/.cursor/hooks.json`, `<repo>/.claude/settings.json` |
|
|
353
390
|
|
|
354
391
|
Entrypoint: `bin/tlc-exec.mjs`.
|
|
355
|
-
Wrappers: `bin/tlc
|
|
392
|
+
Wrappers: `bin/tlc` and `bin/tlc.cmd`, kept for installs made before the package existed. Everything else
|
|
393
|
+
spawns `bin/tlc-exec.mjs` with the interpreter already running.
|
|
356
394
|
|
|
357
395
|
See `tlc harness help architecture` or [`docs/architecture.md`](docs/architecture.md).
|
|
358
396
|
|
|
@@ -373,7 +411,7 @@ See `tlc harness help architecture` or [`docs/architecture.md`](docs/architectur
|
|
|
373
411
|
| `tlc harness attest` | Tamper-evident record of what each session ran under |
|
|
374
412
|
| `tlc harness handoff` | Handoff state between turns and sessions — the sanctioned reader |
|
|
375
413
|
| `tlc harness obs live` / `obs report` | Signal / session rollup |
|
|
376
|
-
| `tlc harness prices refresh` / `lookup` |
|
|
414
|
+
| `tlc harness prices refresh` / `lookup` | The machine's price catalogue |
|
|
377
415
|
| `tlc harness lessons list` | Lessons across the three tiers, with staleness and effectiveness |
|
|
378
416
|
| `tlc harness lessons add "…" [--ref path:symbol] [--global] [--pin]` | Write a lesson; `--ref` retires it when that stops resolving, `--pin` puts it ahead of ranking |
|
|
379
417
|
| `tlc harness policy` / `policy accept <path>` | List a mid-session policy change; accept exactly those paths |
|
|
@@ -427,26 +465,19 @@ HARNESS_PLAN_DEVIATION: src/x.ts — the call site moved with the type
|
|
|
427
465
|
Both are protocol lines on purpose. A gate that fires on free-English "done" fires on the word, not the
|
|
428
466
|
claim.
|
|
429
467
|
|
|
430
|
-
##
|
|
468
|
+
## Prices
|
|
431
469
|
|
|
432
470
|
```bash
|
|
433
471
|
tlc harness prices refresh
|
|
434
472
|
tlc harness prices refresh cursor
|
|
435
|
-
tlc harness prices refresh litellm
|
|
436
473
|
tlc harness prices lookup <model-id> [provider]
|
|
437
474
|
```
|
|
438
475
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
Path resolution goes through `os.homedir()` only, hooks use exec form, filenames are sanitized,
|
|
444
|
-
atomic writes retry, and the CLI ships a `.cmd` shim alongside directory junctions
|
|
445
|
-
([`docs/decisions/ad-006.md`](docs/decisions/ad-006.md)).
|
|
476
|
+
Catalogue: `~/.tlc/harness/model-prices.json`, one plane per billing origin. Overrides:
|
|
477
|
+
`model-prices.local.json`. Neither is versioned. `install` fetches once, `update` refreshes past a 7-day TTL,
|
|
478
|
+
`doctor` reports the age.
|
|
446
479
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
Outside CI coverage: `install.ps1`, and hooks firing inside a Cursor or Claude Code session on Windows.
|
|
480
|
+
See `tlc harness help prices` or [`docs/measure.md`](docs/measure.md).
|
|
450
481
|
|
|
451
482
|
## Troubleshooting
|
|
452
483
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Build the Node-runnable ESM bundles under `dist/`.
|
|
4
|
+
*
|
|
5
|
+
* why this is not a shell script any more: the bash version ran only where bash runs, so `update` could not
|
|
6
|
+
* rebuild a missing bundle on Windows — `spawnSync` cannot execute an extensionless bash file there — and CI had
|
|
7
|
+
* to invoke it through `shell: bash` on all three legs. Nothing it does needs a shell
|
|
8
|
+
* ([/decisions/ad-097.md](/decisions/ad-097.md)).
|
|
9
|
+
*
|
|
10
|
+
* why Bun only: `dist/` is committed and `check-dist-fresh` compares bytes, so the bundler is part of the
|
|
11
|
+
* artefact. The bash version preferred Bun and fell back to esbuild, which means a contributor without Bun
|
|
12
|
+
* produced different bytes for the same source — measured at 223,390 against 228,018 for one bundle
|
|
13
|
+
* ([/decisions/ad-046.md](/decisions/ad-046.md)). One bundler is the only reproducible answer.
|
|
14
|
+
*
|
|
15
|
+
* invariant: what to build is derived from disk, one level deep. A hardcoded list silently stops building a new
|
|
16
|
+
* entrypoint, and the missing bundle only surfaces when a hook fires on somebody's machine.
|
|
17
|
+
*/
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
20
|
+
import { basename, dirname, join } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
24
|
+
const dist = join(root, "dist");
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* why one level: `tools/dev/` holds the checks that validate *this* repository — a user's install has no
|
|
28
|
+
* `src/core` of ours to validate — so the directory boundary is the whole declaration and nothing under it is
|
|
29
|
+
* ever a bundle ([/decisions/ad-068.md](/decisions/ad-068.md)).
|
|
30
|
+
*/
|
|
31
|
+
function sourcesIn(dir) {
|
|
32
|
+
if (!existsSync(dir)) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
36
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts"))
|
|
37
|
+
.map((entry) => ({ name: basename(entry.name, ".ts"), source: join(dir, entry.name) }));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function buildOne(source, out) {
|
|
41
|
+
const result = spawnSync(
|
|
42
|
+
"bun",
|
|
43
|
+
["build", "--target=node", "--format=esm", `--outfile=${out}`, source],
|
|
44
|
+
{ stdio: "inherit" },
|
|
45
|
+
);
|
|
46
|
+
if (result.error?.code === "ENOENT") {
|
|
47
|
+
console.error("tlc-build: Bun is not on PATH, and dist/ is committed so the bundler is part of the artefact.");
|
|
48
|
+
console.error(" curl -fsSL https://bun.sh/install | bash");
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
if ((result.status ?? 1) !== 0) {
|
|
52
|
+
process.exit(result.status ?? 1);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const targets = [
|
|
57
|
+
...sourcesIn(join(root, "src", "entrypoints")),
|
|
58
|
+
...sourcesIn(join(root, "tools")),
|
|
59
|
+
{ name: "tlc-cli", source: join(root, "bin", "tlc-cli.ts") },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
mkdirSync(dist, { recursive: true });
|
|
63
|
+
console.log(`tlc-build → ${dist}`);
|
|
64
|
+
for (const target of targets) {
|
|
65
|
+
buildOne(target.source, join(dist, `${target.name}.mjs`));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// invariant: the launcher stays executable on a filesystem that tracks the bit. A no-op where it does not.
|
|
69
|
+
try {
|
|
70
|
+
chmodSync(join(root, "bin", "tlc"), 0o755);
|
|
71
|
+
} catch {
|
|
72
|
+
// a checkout on a filesystem without a mode bit is not a build failure
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* hazard: a bundle whose source moved or was deleted is not rebuilt, so it is never diffed either — it stays in
|
|
77
|
+
* `dist/` and ships. Deriving what to remove from the same disk that decides what to build closes that.
|
|
78
|
+
*/
|
|
79
|
+
const wanted = new Set(targets.map((target) => `${target.name}.mjs`));
|
|
80
|
+
let bundles = 0;
|
|
81
|
+
for (const entry of readdirSync(dist, { withFileTypes: true })) {
|
|
82
|
+
if (!entry.isFile() || !entry.name.endsWith(".mjs")) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (wanted.has(entry.name)) {
|
|
86
|
+
bundles += 1;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
console.log(`tlc-build: pruning ${entry.name} — no source`);
|
|
90
|
+
rmSync(join(dist, entry.name), { force: true });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log(`tlc-build: ok (${bundles} bundles)`);
|
package/bin/tlc-cli.ts
CHANGED
|
@@ -9,10 +9,10 @@ import {
|
|
|
9
9
|
rmSync,
|
|
10
10
|
writeFileSync,
|
|
11
11
|
} from "node:fs";
|
|
12
|
-
import { homedir } from "node:os";
|
|
13
12
|
import { delimiter, join } from "node:path";
|
|
14
13
|
import { coreFacade } from "../src/core/index.ts";
|
|
15
14
|
import { emitJson, JSON_FLAG, takeJsonFlag, unknownFlags } from "../src/platform/cli-output.ts";
|
|
15
|
+
import { linkDir, seedConfig } from "../src/platform/links.ts";
|
|
16
16
|
import {
|
|
17
17
|
flagsDir,
|
|
18
18
|
projectConfigPath,
|
|
@@ -488,7 +488,7 @@ export const NPM_PACKAGE = "@tech-leads-club/harness-toolkit";
|
|
|
488
488
|
export const NPM_MARKER = "installed-from-npm";
|
|
489
489
|
|
|
490
490
|
/**
|
|
491
|
-
* hazard: `install
|
|
491
|
+
* hazard: `install --link` points the runtime path at the clone it was run from, so on a contributor's machine
|
|
492
492
|
* `~/.tlc/harness` is a symlink to their working repository. The old failure message told them to run
|
|
493
493
|
* `git reset --hard` there, which would have destroyed uncommitted work. Verified on this machine.
|
|
494
494
|
*
|
|
@@ -520,7 +520,7 @@ export function classifyRuntimePath(
|
|
|
520
520
|
* hazard: an earlier version also treated "resolves elsewhere" as linked, to catch a symlinked ancestor. macOS CI
|
|
521
521
|
* refuted it: `/var` is a symlink to `/private/var`, so every path under the system temp directory resolves
|
|
522
522
|
* elsewhere and a **managed** checkout was classified as linked — which would silently stop updates on the very
|
|
523
|
-
* platform the reporter uses. Only the last hop decides, which is the one thing
|
|
523
|
+
* platform the reporter uses. Only the last hop decides, which is the one thing the install actually creates.
|
|
524
524
|
*/
|
|
525
525
|
export function runtimePathKind(dest: string): RuntimePathKind {
|
|
526
526
|
return classifyRuntimePath(dest, {
|
|
@@ -752,13 +752,16 @@ const GATE_FIELDS: Record<string, GateField> = {
|
|
|
752
752
|
|
|
753
753
|
// why: resolved without executing. Running the binary to see whether it exists would run it, which is not
|
|
754
754
|
// something a config write is allowed to do.
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
755
|
+
/**
|
|
756
|
+
* why one list rather than a platform branch reading PATHEXT: the extensions only exist on the platform that uses
|
|
757
|
+
* them, so trying all of them everywhere finds the same file and removes a branch nobody can test twice. The bare
|
|
758
|
+
* name is first, so a POSIX `foo` is never beaten by a stray `foo.exe`
|
|
759
|
+
* ([/decisions/ad-097.md](/decisions/ad-097.md)).
|
|
760
|
+
*/
|
|
761
|
+
const EXECUTABLE_EXTENSIONS = ["", ".exe", ".cmd", ".bat", ".ps1"];
|
|
762
|
+
|
|
763
|
+
export function resolveExecutable(name: string, env: NodeJS.ProcessEnv = process.env): string | null {
|
|
764
|
+
const candidates = (base: string): string[] => EXECUTABLE_EXTENSIONS.map((ext) => `${base}${ext}`);
|
|
762
765
|
|
|
763
766
|
if (name.includes("/") || name.includes("\\")) {
|
|
764
767
|
return candidates(name).find((candidate) => existsSync(candidate)) ?? null;
|
|
@@ -867,22 +870,24 @@ export function helpText(style: Style = PLAIN): string {
|
|
|
867
870
|
|
|
868
871
|
export function pricesHelpScreen(): Screen {
|
|
869
872
|
return {
|
|
870
|
-
title: "
|
|
873
|
+
title: "prices",
|
|
871
874
|
sections: [
|
|
872
875
|
{
|
|
873
|
-
lines: ` tlc harness prices refresh [all|cursor|litellm]
|
|
876
|
+
lines: ` tlc harness prices refresh [all|cursor|litellm] [--if-stale]
|
|
874
877
|
tlc harness prices lookup <model-id>
|
|
875
878
|
|
|
876
|
-
refresh / refresh all
|
|
877
|
-
refresh cursor
|
|
878
|
-
refresh litellm
|
|
879
|
+
refresh / refresh all both planes of model-prices.json
|
|
880
|
+
refresh cursor the provider's own rates
|
|
881
|
+
refresh litellm the vendors' list prices
|
|
882
|
+
--if-stale fetch only past the 7-day TTL
|
|
879
883
|
lookup <model-id> catalog key, pool, USD for 1M in + 1M out
|
|
880
884
|
|
|
881
|
-
|
|
885
|
+
Catalogue: <runtime home>/model-prices.json — fetched per machine, never versioned
|
|
886
|
+
Overrides: <runtime home>/model-prices.local.json — yours, hand-written
|
|
882
887
|
Documentation: tlc harness help prices`.split("\n"),
|
|
883
888
|
},
|
|
884
889
|
],
|
|
885
|
-
footer: "resolution:
|
|
890
|
+
footer: "resolution: your overrides → the asking provider's plane → the vendor plane → null",
|
|
886
891
|
};
|
|
887
892
|
}
|
|
888
893
|
|
|
@@ -899,12 +904,69 @@ export function resolveHarnessRoot(): string {
|
|
|
899
904
|
}
|
|
900
905
|
}
|
|
901
906
|
|
|
907
|
+
/**
|
|
908
|
+
* Everything an install has to put in place outside the runtime directory itself: the init skill where each
|
|
909
|
+
* provider reads it, the user-level hooks, and a seeded config.
|
|
910
|
+
*
|
|
911
|
+
* hazard: there were three implementations of this — bash, PowerShell, and the POSIX branch here — and they
|
|
912
|
+
* disagreed. The PowerShell one linked the init skill into `~/.tlc/skills/harness-init`, which no provider reads,
|
|
913
|
+
* so on Windows `update` refreshed a skill nothing could route to. That is the defect
|
|
914
|
+
* [/decisions/ad-095.md](/decisions/ad-095.md) fixed on the other side, still live on this one
|
|
915
|
+
* ([/decisions/ad-097.md](/decisions/ad-097.md)).
|
|
916
|
+
*
|
|
917
|
+
* invariant: one function, no platform branch, and the launcher on PATH is npm's business.
|
|
918
|
+
*/
|
|
919
|
+
export function wireRuntime(dest: string, home: string): { lines: string[]; missingSkill: boolean } {
|
|
920
|
+
const lines: string[] = [];
|
|
921
|
+
const seeded = seedConfig(dest);
|
|
922
|
+
if (seeded.seeded) {
|
|
923
|
+
lines.push(`config seeded → ${seeded.path}`);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (!existsSync(join(dest, "skills", "harness-init"))) {
|
|
927
|
+
return { lines, missingSkill: true };
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const links = coreFacade.skill.skillLinks(dest, providerConfigDirs(), existsSync);
|
|
931
|
+
if (links.length === 0) {
|
|
932
|
+
lines.push("no provider config dir found — skill not linked");
|
|
933
|
+
}
|
|
934
|
+
for (const link of links) {
|
|
935
|
+
const outcome = linkDir(link.source, link.target);
|
|
936
|
+
lines.push(
|
|
937
|
+
outcome.kind === "refused" ? `skill not linked — ${outcome.reason}` : `skill → ${outcome.target}`,
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const hooks = spawnSync(process.execPath, [join(dest, "bin", "write-user-hooks.mjs")], {
|
|
942
|
+
stdio: "inherit",
|
|
943
|
+
env: { ...process.env, TLC_HOME: home },
|
|
944
|
+
});
|
|
945
|
+
if ((hooks.status ?? 1) !== 0) {
|
|
946
|
+
lines.push("hooks unchanged (merge manually or: node bin/write-user-hooks.mjs --force)");
|
|
947
|
+
}
|
|
948
|
+
return { lines, missingSkill: false };
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* hazard: this was the extensionless bash wrapper, so every `runEntry` spawn — `doctor`, `prices refresh`,
|
|
953
|
+
* `install-runtime`, `price-lookup` — named a file Windows cannot execute. The hooks never had this problem
|
|
954
|
+
* because they name the `.mjs` ([/decisions/ad-097.md](/decisions/ad-097.md)).
|
|
955
|
+
*
|
|
956
|
+
* invariant: paired with `process.execPath`, so the entry runs under the interpreter that is already running.
|
|
957
|
+
*/
|
|
902
958
|
export function execBinPath(): string {
|
|
903
|
-
return join(resolveHarnessRoot(), "bin", "tlc-exec");
|
|
959
|
+
return join(resolveHarnessRoot(), "bin", "tlc-exec.mjs");
|
|
904
960
|
}
|
|
905
961
|
|
|
962
|
+
/**
|
|
963
|
+
* why the `.mjs` and not a wrapper: the wrapper was bash, so `spawnSync` could not run it on Windows and
|
|
964
|
+
* `update` there could never rebuild a missing bundle ([/decisions/ad-097.md](/decisions/ad-097.md)).
|
|
965
|
+
*
|
|
966
|
+
* invariant: spawned with `process.execPath`, so the interpreter running the CLI is the one that builds.
|
|
967
|
+
*/
|
|
906
968
|
export function buildBinPath(): string {
|
|
907
|
-
return join(resolveHarnessRoot(), "bin", "tlc-build");
|
|
969
|
+
return join(resolveHarnessRoot(), "bin", "tlc-build.mjs");
|
|
908
970
|
}
|
|
909
971
|
|
|
910
972
|
export type Action =
|
|
@@ -1223,13 +1285,18 @@ function runUpdate(root: string): never {
|
|
|
1223
1285
|
const bump = spawnSync("npm", ["install", "-g", `${NPM_PACKAGE}@latest`], {
|
|
1224
1286
|
stdio: "inherit",
|
|
1225
1287
|
env: process.env,
|
|
1226
|
-
|
|
1288
|
+
// why: `npm` is `npm.cmd` on Windows and a shell is how that resolves; on POSIX it costs one `/bin/sh`,
|
|
1289
|
+
// and the argv here is fixed ([/decisions/ad-097.md](/decisions/ad-097.md)).
|
|
1290
|
+
shell: true,
|
|
1227
1291
|
});
|
|
1228
1292
|
if ((bump.status ?? 1) !== 0) {
|
|
1229
1293
|
console.error(npmUpdateFailureMessage());
|
|
1230
1294
|
process.exit(bump.status ?? 1);
|
|
1231
1295
|
}
|
|
1232
|
-
const sync = spawnSync(execBinPath(),
|
|
1296
|
+
const sync = spawnSync(process.execPath, [execBinPath(), "install-runtime"], {
|
|
1297
|
+
stdio: "inherit",
|
|
1298
|
+
env: process.env,
|
|
1299
|
+
});
|
|
1233
1300
|
if ((sync.status ?? 1) !== 0) {
|
|
1234
1301
|
process.exit(sync.status ?? 1);
|
|
1235
1302
|
}
|
|
@@ -1268,47 +1335,13 @@ function runUpdate(root: string): never {
|
|
|
1268
1335
|
);
|
|
1269
1336
|
}
|
|
1270
1337
|
|
|
1271
|
-
const
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
env: { ...process.env, TLC_HOME: home },
|
|
1279
|
-
cwd: dest,
|
|
1280
|
-
});
|
|
1281
|
-
if ((r.status ?? 1) !== 0) {
|
|
1282
|
-
process.exit(r.status ?? 1);
|
|
1283
|
-
}
|
|
1284
|
-
} else {
|
|
1285
|
-
const tlcBin = join(dest, "bin", "tlc");
|
|
1286
|
-
const skillSrc = join(dest, "skills", "harness-init");
|
|
1287
|
-
spawnSync("ln", ["-sfn", tlcBin, join(binDir, "tlc")], { stdio: "inherit" });
|
|
1288
|
-
if (!existsSync(skillSrc)) {
|
|
1289
|
-
console.error(`update: missing skill at ${skillSrc}`);
|
|
1290
|
-
process.exit(1);
|
|
1291
|
-
}
|
|
1292
|
-
// hazard: this linked to `<home>/../skills/harness-init` — a directory no provider reads, and which did not
|
|
1293
|
-
// exist at all on the machine where this was found. `install.sh` had it right all along, linking into every
|
|
1294
|
-
// provider config directory that exists, because each provider only reads its own. The resolution is now one
|
|
1295
|
-
// function both use ([/decisions/ad-095.md](/decisions/ad-095.md)).
|
|
1296
|
-
const links = coreFacade.skill.skillLinks(dest, providerConfigDirs(), existsSync);
|
|
1297
|
-
if (links.length === 0) {
|
|
1298
|
-
console.log("update: no provider config dir found — skill not linked");
|
|
1299
|
-
}
|
|
1300
|
-
for (const link of links) {
|
|
1301
|
-
mkdirSync(join(link.providerDir, "skills"), { recursive: true });
|
|
1302
|
-
spawnSync("ln", ["-sfn", link.source, link.target], { stdio: "inherit" });
|
|
1303
|
-
console.log(`update: skill → ${link.target}`);
|
|
1304
|
-
}
|
|
1305
|
-
const hooks = spawnSync(process.execPath, [join(dest, "bin", "write-user-hooks.mjs")], {
|
|
1306
|
-
stdio: "inherit",
|
|
1307
|
-
env: { ...process.env, TLC_HOME: home },
|
|
1308
|
-
});
|
|
1309
|
-
if ((hooks.status ?? 1) !== 0) {
|
|
1310
|
-
console.log("update: hooks unchanged (merge manually or: node bin/write-user-hooks.mjs --force)");
|
|
1311
|
-
}
|
|
1338
|
+
const wired = wireRuntime(dest, home);
|
|
1339
|
+
for (const line of wired.lines) {
|
|
1340
|
+
console.log(`update: ${line}`);
|
|
1341
|
+
}
|
|
1342
|
+
if (wired.missingSkill) {
|
|
1343
|
+
console.error(`update: missing skill at ${join(dest, "skills", "harness-init")}`);
|
|
1344
|
+
process.exit(1);
|
|
1312
1345
|
}
|
|
1313
1346
|
|
|
1314
1347
|
// invariant: never build into the artifact when it is already complete. `dist/` is committed for the Node
|
|
@@ -1320,7 +1353,7 @@ function runUpdate(root: string): never {
|
|
|
1320
1353
|
console.log("update: dist/ complete — no rebuild, so the runtime path stays clean");
|
|
1321
1354
|
} else if (existsSync(buildBinPath())) {
|
|
1322
1355
|
console.log(`update: ${missing.length} bundle(s) missing — building`);
|
|
1323
|
-
const build = spawnSync(buildBinPath()
|
|
1356
|
+
const build = spawnSync(process.execPath, [buildBinPath()], { stdio: "inherit", env: process.env });
|
|
1324
1357
|
if ((build.status ?? 1) !== 0) {
|
|
1325
1358
|
console.log(`update: build failed — ${missing.length} bundle(s) still missing from dist/`);
|
|
1326
1359
|
}
|
|
@@ -1329,8 +1362,19 @@ function runUpdate(root: string): never {
|
|
|
1329
1362
|
announceNewCapabilities(root, dest);
|
|
1330
1363
|
announceLandedDecisions(root, dest, revisionBefore);
|
|
1331
1364
|
|
|
1365
|
+
/**
|
|
1366
|
+
* why: an update is the moment a machine is already reaching the network, so it is the natural place to notice
|
|
1367
|
+
* that its prices are a week old. `--if-stale` is what keeps this from being a fetch on every update, and the
|
|
1368
|
+
* failure is tolerated — a rate that could not be fetched is not a reason for a failed update
|
|
1369
|
+
* ([/decisions/ad-096.md](/decisions/ad-096.md)).
|
|
1370
|
+
*/
|
|
1371
|
+
spawnSync(process.execPath, [execBinPath(), "refresh-model-prices", "all", "--if-stale"], {
|
|
1372
|
+
stdio: "inherit",
|
|
1373
|
+
env: { ...process.env, TLC_PROJECT_DIR: root },
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1332
1376
|
console.log("update: running doctor…");
|
|
1333
|
-
const doctor = spawnSync(execBinPath(),
|
|
1377
|
+
const doctor = spawnSync(process.execPath, [execBinPath(), "doctor"], {
|
|
1334
1378
|
stdio: "inherit",
|
|
1335
1379
|
env: { ...process.env, TLC_PROJECT_DIR: root },
|
|
1336
1380
|
});
|
|
@@ -1339,7 +1383,7 @@ function runUpdate(root: string): never {
|
|
|
1339
1383
|
}
|
|
1340
1384
|
|
|
1341
1385
|
function runEntry(entry: string, toolArgs: string[], root: string): never {
|
|
1342
|
-
const r = spawnSync(execBinPath(),
|
|
1386
|
+
const r = spawnSync(process.execPath, [execBinPath(), entry, ...toolArgs], {
|
|
1343
1387
|
stdio: "inherit",
|
|
1344
1388
|
env: { ...process.env, TLC_PROJECT_DIR: root },
|
|
1345
1389
|
});
|
|
@@ -1440,7 +1484,7 @@ function main(argv: string[]): void {
|
|
|
1440
1484
|
console.log(helpText(createStyle()));
|
|
1441
1485
|
break;
|
|
1442
1486
|
case "build": {
|
|
1443
|
-
const r = spawnSync(buildBinPath()
|
|
1487
|
+
const r = spawnSync(process.execPath, [buildBinPath()], { stdio: "inherit", env: process.env });
|
|
1444
1488
|
process.exit(r.status ?? 1);
|
|
1445
1489
|
break;
|
|
1446
1490
|
}
|
package/bin/tlc-exec.mjs
CHANGED
|
@@ -62,20 +62,23 @@ export function resolveHarnessHome(
|
|
|
62
62
|
return candidate;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
/**
|
|
66
|
+
* why a list and not a branch: the only difference between platforms is which of these names exists on disk, and
|
|
67
|
+
* asking for both costs one extra `existsSync` per PATH entry. `bun.exe` never exists on Linux and `bun` never
|
|
68
|
+
* shadows it on Windows ([/decisions/ad-097.md](/decisions/ad-097.md)).
|
|
69
|
+
*/
|
|
70
|
+
export const BUN_EXECUTABLE_NAMES = ["bun", "bun.exe"];
|
|
68
71
|
|
|
69
|
-
export function findBunOnPath(env = process.env
|
|
70
|
-
const
|
|
71
|
-
const bunName = bunExecutableName(platform);
|
|
72
|
-
for (const dir of pathValue.split(delimiter)) {
|
|
72
|
+
export function findBunOnPath(env = process.env) {
|
|
73
|
+
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
73
74
|
if (!dir) {
|
|
74
75
|
continue;
|
|
75
76
|
}
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
for (const name of BUN_EXECUTABLE_NAMES) {
|
|
78
|
+
const candidate = join(dir, name);
|
|
79
|
+
if (existsSync(candidate)) {
|
|
80
|
+
return candidate;
|
|
81
|
+
}
|
|
79
82
|
}
|
|
80
83
|
}
|
|
81
84
|
return null;
|
|
@@ -122,12 +125,12 @@ export function writeRuntimeCache(harnessHome, bunPath) {
|
|
|
122
125
|
return record;
|
|
123
126
|
}
|
|
124
127
|
|
|
125
|
-
export function resolveBunPath(harnessHome, env = process.env
|
|
128
|
+
export function resolveBunPath(harnessHome, env = process.env) {
|
|
126
129
|
const cached = readRuntimeCache(harnessHome);
|
|
127
130
|
if (cached) {
|
|
128
131
|
return cached.bunPath;
|
|
129
132
|
}
|
|
130
|
-
const found = findBunOnPath(env
|
|
133
|
+
const found = findBunOnPath(env);
|
|
131
134
|
writeRuntimeCache(harnessHome, found);
|
|
132
135
|
return found;
|
|
133
136
|
}
|
|
@@ -167,7 +170,7 @@ export function decideRuntime({ harnessHome, entry, bunPath, nodeMajor, distExis
|
|
|
167
170
|
status: 1,
|
|
168
171
|
message: [
|
|
169
172
|
`tlc: Node ${process.version} found, but dist/${entry}.mjs is missing.`,
|
|
170
|
-
` Run: ${join(harnessHome, "bin", "tlc-build")}`,
|
|
173
|
+
` Run: node ${join(harnessHome, "bin", "tlc-build.mjs")}`,
|
|
171
174
|
].join("\n"),
|
|
172
175
|
};
|
|
173
176
|
}
|