@tech-leads-club/harness-toolkit 0.2.4 → 0.3.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 (50) hide show
  1. package/README.md +22 -26
  2. package/bin/tlc-build.mjs +93 -0
  3. package/bin/tlc-cli.ts +110 -66
  4. package/bin/tlc-exec.mjs +16 -13
  5. package/dist/compact-before.mjs +66 -8
  6. package/dist/doctor.mjs +178 -41
  7. package/dist/help-topic.mjs +0 -0
  8. package/dist/init-project.mjs +2 -7
  9. package/dist/install-runtime.mjs +100 -17
  10. package/dist/lessons-cli.mjs +67 -1
  11. package/dist/obs-cli.mjs +64 -1
  12. package/dist/price-lookup.mjs +45 -23
  13. package/dist/prompt-submit.mjs +66 -8
  14. package/dist/refresh-model-prices.mjs +7190 -46
  15. package/dist/response-after.mjs +66 -8
  16. package/dist/run.mjs +66 -8
  17. package/dist/session-end.mjs +66 -8
  18. package/dist/session-start.mjs +66 -8
  19. package/dist/shim.mjs +66 -3
  20. package/dist/stop.mjs +66 -8
  21. package/dist/subagent-start.mjs +66 -8
  22. package/dist/subagent-stop.mjs +66 -8
  23. package/dist/support.mjs +64 -1
  24. package/dist/tlc-cli.mjs +193 -87
  25. package/dist/tool-after.mjs +111 -31
  26. package/dist/tool-before.mjs +66 -8
  27. package/dist/tool-failure.mjs +66 -8
  28. package/dist/uninstall-runtime.mjs +9 -10
  29. package/docs/log.md +2 -0
  30. package/docs/measure.md +35 -31
  31. package/package.json +4 -4
  32. package/src/core/core.facade.ts +7 -0
  33. package/src/core/index.ts +1 -0
  34. package/src/core/pricing/pricing.freshness.ts +118 -0
  35. package/src/core/skill/skill.link.ts +14 -3
  36. package/src/entrypoints/shim.ts +8 -2
  37. package/src/platform/links.ts +73 -0
  38. package/src/platform/pricing.ts +139 -31
  39. package/src/providers/cursor/cursor.wiring.ts +11 -8
  40. package/tools/doctor.ts +78 -8
  41. package/tools/init-project.ts +7 -7
  42. package/tools/install-runtime.ts +89 -6
  43. package/tools/refresh-model-prices.ts +242 -75
  44. package/tools/uninstall-runtime.ts +23 -19
  45. package/bin/tlc-build +0 -80
  46. package/bin/tlc-exec +0 -10
  47. package/bin/tlc-exec.cmd +0 -4
  48. package/model-aliases.json +0 -12
  49. package/model-prices.cursor.json +0 -410
  50. package/model-prices.json +0 -1
@@ -1,6 +1,8 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
3
  import { join, relative, resolve, sep } from "node:path";
3
4
  import { NPM_MARKER, NPM_PACKAGE } from "../bin/tlc-cli.ts";
5
+ import { linkDir } from "../src/platform/links.ts";
4
6
  import { conventionalRuntimeHome, runtimeHome, runtimeHomeWasChosen } from "../src/platform/paths.ts";
5
7
  import { type Row, render, type Screen } from "../src/platform/screen.ts";
6
8
  import { createStyle, PLAIN, type Style } from "../src/platform/style.ts";
@@ -23,9 +25,6 @@ export const RUNTIME_PAYLOAD = [
23
25
  "src",
24
26
  "tools",
25
27
  "config.example.json",
26
- "model-aliases.json",
27
- "model-prices.cursor.json",
28
- "model-prices.json",
29
28
  "package.json",
30
29
  ] as const;
31
30
 
@@ -52,11 +51,12 @@ export function isShipped(relativePath: string): boolean {
52
51
  }
53
52
 
54
53
  export type InstallReport = {
55
- kind: "copied" | "in-place";
54
+ kind: "copied" | "in-place" | "linked" | "relinked" | "refused";
56
55
  source: string;
57
56
  dest: string;
58
57
  entries: string[];
59
58
  missing: string[];
59
+ reason?: string;
60
60
  };
61
61
 
62
62
  /** The physical location of the copy that launched us, which is not the home once an npm shim is driving one. */
@@ -110,7 +110,62 @@ export function installRuntime(source: string, dest: string): InstallReport {
110
110
  return { kind: "copied", source, dest, entries, missing };
111
111
  }
112
112
 
113
+ /**
114
+ * The contributor route: the runtime home points at a checkout, so an edit is live in the next hook with no
115
+ * install step.
116
+ *
117
+ * why it is here and not in a shell script: it was `ln -sfn` in bash and `mklink /J` in PowerShell, and the
118
+ * PowerShell one asked for Developer Mode. One `symlinkSync` covers all three platforms
119
+ * ([/decisions/ad-097.md](/decisions/ad-097.md)).
120
+ *
121
+ * invariant: the checkout is never written to, and a destination that is not already a link is refused rather
122
+ * than removed ([/decisions/ad-046.md](/decisions/ad-046.md)).
123
+ */
124
+ export function linkRuntime(source: string, dest: string): InstallReport {
125
+ if (resolve(source) === resolve(dest)) {
126
+ return { kind: "in-place", source, dest, entries: [], missing: [] };
127
+ }
128
+ const outcome = linkDir(resolve(source), dest);
129
+ if (outcome.kind === "refused") {
130
+ return { kind: "refused", source, dest, entries: [], missing: [], reason: outcome.reason };
131
+ }
132
+ const missing = RUNTIME_PAYLOAD.filter((entry) => !existsSync(join(dest, entry)));
133
+ return { kind: outcome.kind === "relinked" ? "relinked" : "linked", source, dest, entries: [], missing };
134
+ }
135
+
113
136
  export function installScreen(report: InstallReport): Screen {
137
+ if (report.kind === "refused") {
138
+ return {
139
+ title: "harness install",
140
+ sections: [{ rows: [{ label: "refused", value: report.reason ?? "", level: "fail" }] }],
141
+ };
142
+ }
143
+ if (report.kind === "linked" || report.kind === "relinked") {
144
+ return {
145
+ title: "harness install",
146
+ sections: [
147
+ {
148
+ rows: [
149
+ {
150
+ label: report.kind === "linked" ? "linked" : "relinked",
151
+ value: `${report.dest} → ${report.source}`,
152
+ level: "ok",
153
+ },
154
+ ...(report.missing.length > 0
155
+ ? [
156
+ {
157
+ label: "incomplete",
158
+ value: `the checkout has no ${report.missing.join(", ")} — run the build`,
159
+ level: "fail" as const,
160
+ },
161
+ ]
162
+ : []),
163
+ ],
164
+ },
165
+ ],
166
+ footer: "an edit in the checkout is live in the next hook · `npm link` puts `tlc` on PATH",
167
+ };
168
+ }
114
169
  if (report.kind === "in-place") {
115
170
  return {
116
171
  title: "harness install",
@@ -155,10 +210,38 @@ export function installDest(env: NodeJS.ProcessEnv = process.env): string {
155
210
  return runtimeHomeWasChosen(env) ? runtimeHome(env) : conventionalRuntimeHome();
156
211
  }
157
212
 
213
+ /**
214
+ * The first price fetch, on the machine, at install time.
215
+ *
216
+ * why: prices are no longer in the package, so a fresh install has no catalogue at all until something fetches
217
+ * one. This is that something ([/decisions/ad-096.md](/decisions/ad-096.md)).
218
+ *
219
+ * invariant: never fails the install. An operator installing behind a proxy, on a plane, or against a page that
220
+ * moved still gets a working harness — they get no cost figures until the next refresh, which `doctor` reports.
221
+ */
222
+ export function fetchPrices(dest: string, spawn = spawnSync): void {
223
+ const result = spawn(process.execPath, [join(dest, "bin", "tlc-exec.mjs"), "refresh-model-prices"], {
224
+ stdio: "inherit",
225
+ env: { ...process.env, TLC_HOME: dest },
226
+ });
227
+ if ((result.status ?? 1) !== 0) {
228
+ console.log("install: prices not fetched — cost estimates stay empty until `tlc harness prices refresh`");
229
+ }
230
+ }
231
+
158
232
  if (import.meta.main) {
159
- const source = originRoot();
233
+ /**
234
+ * why a flag rather than a second command: install is install. The only difference is whether the runtime home
235
+ * holds a copy of the package or points at a checkout ([/decisions/ad-097.md](/decisions/ad-097.md)).
236
+ */
237
+ const link = process.argv.includes("--link");
238
+ const source = link ? process.cwd() : originRoot();
160
239
  const dest = installDest();
161
- const report = installRuntime(source, dest);
240
+ const report = link ? linkRuntime(source, dest) : installRuntime(source, dest);
162
241
  console.log(installReportText(report, createStyle()));
242
+ if (report.kind === "refused") {
243
+ process.exit(1);
244
+ }
245
+ fetchPrices(dest);
163
246
  process.exit(report.missing.length > 0 ? 1 : 0);
164
247
  }
@@ -1,35 +1,97 @@
1
1
  #!/usr/bin/env node
2
- import { writeFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
- import { fileURLToPath } from "node:url";
2
+ import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { basename, join } from "node:path";
4
+ import { coreFacade } from "../src/core/index.ts";
5
+ import { runtimeHome } from "../src/platform/paths.ts";
6
+ import {
7
+ cataloguePath,
8
+ FALLBACK_PLANE,
9
+ loadCatalogue,
10
+ type ModelPriceEntry,
11
+ overridesPath,
12
+ type PriceCatalogue,
13
+ type PriceTable,
14
+ slugifyModelName,
15
+ } from "../src/platform/pricing.ts";
5
16
 
6
- const HARNESS_HOME = join(dirname(fileURLToPath(import.meta.url)), "..");
7
- const CURSOR_DOCS_URL = "https://cursor.com/docs/models-and-pricing.md";
17
+ /**
18
+ * hazard: this was `dirname(import.meta.url)/..` — the directory the script lives in. Under an npm install that is
19
+ * inside the package, which npm replaces wholesale on the next update, so the refresh wrote prices into a directory
20
+ * that would be deleted while `pricing.ts` read `runtimeHome()` and never received them. The same reason the
21
+ * runtime is materialised outside the package at all ([/decisions/ad-056.md](/decisions/ad-056.md),
22
+ * [/decisions/ad-096.md](/decisions/ad-096.md)).
23
+ *
24
+ * invariant: written where it is read. One resolution, `runtimeHome()`, used by both sides.
25
+ */
26
+ const HARNESS_HOME = runtimeHome();
27
+
28
+ /**
29
+ * The plane a provider's own rates land in. It is the provider's id, because the catalogue is keyed by who bills
30
+ * the call and a second provider publishing its own rates is a new plane, not a new file.
31
+ */
32
+ const PROVIDER_PLANE = "cursor";
33
+ const PROVIDER_DOCS_URL = "https://cursor.com/docs/models-and-pricing.md";
8
34
  const LITELLM_URL =
9
35
  "https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/model_prices_and_context_window_backup.json";
10
36
 
11
- type PriceEntry = {
12
- displayName?: string;
13
- provider?: string;
14
- promptPer1M?: number;
15
- completionPer1M?: number;
16
- cacheWritePer1M?: number;
17
- cacheReadPer1M?: number;
18
- pool?: "cursor_models" | "other_models" | "auto" | "unknown";
19
- billing?: "metered" | "included" | "unknown";
20
- contextWindow?: number;
21
- };
37
+ /**
38
+ * The files this replaced. Left on disk they are stale duplicates of two planes and an alias table nothing reads,
39
+ * and the operator has no way to tell which one a price came from ([/decisions/ad-096.md](/decisions/ad-096.md)).
40
+ */
41
+ const SUPERSEDED = [
42
+ `model-prices.${PROVIDER_PLANE}.json`,
43
+ "model-prices.litellm.json",
44
+ "model-aliases.json",
45
+ ] as const;
22
46
 
23
- function slugify(name: string): string {
24
- return name
25
- .trim()
26
- .toLowerCase()
27
- .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
28
- .replace(/\(.*?\)/g, "")
29
- .replace(/[^a-z0-9.+]+/g, "-")
30
- .replace(/^-+|-+$/g, "");
47
+ /**
48
+ * hazard: `model-prices.json` used to be the operator's overrides table, and it is the name this now writes. An
49
+ * operator who had put their own rates in it would have had them replaced by the first refresh. A flat table with
50
+ * no `planes` key is that old file, so it is moved to where overrides are read from rather than overwritten.
51
+ */
52
+ function adoptLegacyOverrides(quiet: boolean): void {
53
+ const path = cataloguePath();
54
+ if (!existsSync(path)) {
55
+ return;
56
+ }
57
+ const parsed = loadCatalogue();
58
+ const keys = Object.keys(parsed).filter((key) => key !== "_meta");
59
+ if (parsed.planes !== undefined || keys.length === 0) {
60
+ return;
61
+ }
62
+ if (existsSync(overridesPath())) {
63
+ console.error(
64
+ `refresh: ${basename(path)} holds ${keys.length} legacy entries and ${basename(overridesPath())} already exists — merge them by hand`,
65
+ );
66
+ return;
67
+ }
68
+ renameSync(path, overridesPath());
69
+ if (!quiet) {
70
+ console.log(`refresh: moved ${keys.length} local overrides → ${overridesPath()}`);
71
+ }
31
72
  }
32
73
 
74
+ /** invariant: only the exact names this replaced, only inside the runtime home. Nothing else is removed. */
75
+ function retireSupersededFiles(quiet: boolean): void {
76
+ for (const name of SUPERSEDED) {
77
+ const path = join(HARNESS_HOME, name);
78
+ if (!existsSync(path)) {
79
+ continue;
80
+ }
81
+ rmSync(path, { force: true });
82
+ if (!quiet) {
83
+ console.log(`refresh: removed superseded ${name}`);
84
+ }
85
+ }
86
+ }
87
+
88
+ /**
89
+ * invariant: the key a model is written under is computed by the same function the lookup uses. There were two
90
+ * copies of this and they disagreed about parentheses, which is what let a variant overwrite its base model and be
91
+ * read back at the wrong price ([/decisions/ad-096.md](/decisions/ad-096.md)).
92
+ */
93
+ const slugify = slugifyModelName;
94
+
33
95
  function parseMoney(cell: string): number | undefined {
34
96
  const t = cell.trim();
35
97
  if (!t || t === "-" || t === "—" || t.toLowerCase() === "n/a") {
@@ -50,7 +112,7 @@ function stripCell(cell: string): string {
50
112
  .trim();
51
113
  }
52
114
 
53
- function inferPool(displayName: string, provider: string): PriceEntry["pool"] {
115
+ export function inferPool(displayName: string, provider: string): ModelPriceEntry["pool"] {
54
116
  const n = displayName.toLowerCase();
55
117
  if (n === "auto cost" || n.startsWith("auto ")) {
56
118
  return "auto";
@@ -61,16 +123,24 @@ function inferPool(displayName: string, provider: string): PriceEntry["pool"] {
61
123
  return "other_models";
62
124
  }
63
125
 
64
- function parseCursorDocs(md: string): Record<string, PriceEntry> {
65
- const out: Record<string, PriceEntry> = {};
126
+ /**
127
+ * hazard: this used to `break` on the first line that is not a table row, so it read the FIRST table and stopped.
128
+ * The page now carries three: the provider's own models, then the rest. The parser went from 43 models to 3
129
+ * overnight and the only guard was `count === 0`, so a mutilated catalogue overwrote a good one and read as a
130
+ * successful refresh ([/decisions/ad-096.md](/decisions/ad-096.md)).
131
+ *
132
+ * invariant: every table on the page, and the header row of each resets the column expectation rather than ending
133
+ * the parse.
134
+ */
135
+ export function parseCursorDocs(md: string): PriceTable {
136
+ const out: PriceTable = {};
66
137
  const lines = md.split("\n");
67
138
  let inTable = false;
68
139
 
69
140
  for (const line of lines) {
70
141
  if (!line.startsWith("|")) {
71
- if (inTable) {
72
- break;
73
- }
142
+ // why: leaving a table is not the end of the document. The next one may be a few lines down.
143
+ inTable = false;
74
144
  continue;
75
145
  }
76
146
  const cells = line
@@ -125,15 +195,15 @@ type LiteLlmEntry = {
125
195
  litellm_provider?: string;
126
196
  };
127
197
 
128
- function parseLiteLlm(raw: Record<string, LiteLlmEntry>): Record<string, PriceEntry> {
129
- const out: Record<string, PriceEntry> = {};
198
+ export function parseLiteLlm(raw: Record<string, LiteLlmEntry>): PriceTable {
199
+ const out: PriceTable = {};
130
200
  for (const [id, entry] of Object.entries(raw)) {
131
201
  if (id === "sample_spec") {
132
202
  continue;
133
203
  }
134
204
  const contextWindow =
135
205
  typeof entry.max_input_tokens === "number" ? entry.max_input_tokens : entry.max_tokens;
136
- const compact: PriceEntry = {
206
+ const compact: ModelPriceEntry = {
137
207
  displayName: id,
138
208
  provider: entry.litellm_provider,
139
209
  pool: "unknown",
@@ -163,48 +233,145 @@ function parseLiteLlm(raw: Record<string, LiteLlmEntry>): Record<string, PriceEn
163
233
  return out;
164
234
  }
165
235
 
166
- const mode = (process.argv[2] ?? "all").toLowerCase();
167
-
168
- if (mode === "all" || mode === "cursor") {
169
- const res = await fetch(CURSOR_DOCS_URL);
170
- if (!res.ok) {
171
- console.error(`Failed to fetch Cursor docs: ${res.status}`);
172
- process.exit(1);
173
- }
174
- const md = await res.text();
175
- const cursor = parseCursorDocs(md);
176
- const count = Object.keys(cursor).length;
177
- if (count === 0) {
178
- console.error("Parsed 0 Cursor models — docs table format may have changed");
179
- process.exit(1);
180
- }
181
- const path = join(HARNESS_HOME, "model-prices.cursor.json");
182
- writeFileSync(
183
- path,
184
- `${JSON.stringify({ _meta: { source: CURSOR_DOCS_URL, refreshedAt: new Date().toISOString() }, ...cursor }, null, 2)}\n`,
185
- );
186
- console.log(`Cursor catalog: ${count} models ${path}`);
236
+ /**
237
+ * Which planes a refresh may write, and what happens when one of them comes back mutilated.
238
+ *
239
+ * invariant: a plane is replaced only if the incoming table is not a large loss against what is already there, and
240
+ * a refused plane leaves the others alone. One bad fetch must not take a good catalogue down with it
241
+ * ([/decisions/ad-096.md](/decisions/ad-096.md)).
242
+ */
243
+ export type PlaneUpdate = { plane: string; source: string; table: PriceTable };
244
+ export type PlaneOutcome = { plane: string; accepted: boolean; reason: string; count: number };
245
+
246
+ function planeCount(catalogue: PriceCatalogue, plane: string): number {
247
+ return Object.keys(catalogue.planes?.[plane] ?? {}).length;
248
+ }
249
+
250
+ export function applyPlanes(
251
+ existing: PriceCatalogue,
252
+ updates: readonly PlaneUpdate[],
253
+ now: Date,
254
+ ): { catalogue: PriceCatalogue; outcomes: PlaneOutcome[] } {
255
+ const planes: Record<string, PriceTable> = { ...(existing.planes ?? {}) };
256
+ const planeMeta = { ...(existing._meta?.planes ?? {}) };
257
+ const outcomes: PlaneOutcome[] = [];
258
+ let accepted = 0;
259
+
260
+ for (const update of updates) {
261
+ const count = Object.keys(update.table).length;
262
+ const verdict = coreFacade.pricing.mayReplace(planeCount(existing, update.plane), count);
263
+ outcomes.push({ plane: update.plane, accepted: verdict.replace, reason: verdict.reason, count });
264
+ if (!verdict.replace) {
265
+ continue;
266
+ }
267
+ planes[update.plane] = update.table;
268
+ planeMeta[update.plane] = { source: update.source, count, refreshedAt: now.toISOString() };
269
+ accepted += 1;
270
+ }
271
+
272
+ // invariant: the file's own date moves only when something in it actually changed, so a refused refresh stays
273
+ // visibly stale rather than looking fresh.
274
+ const refreshedAt = accepted > 0 ? now.toISOString() : existing._meta?.refreshedAt;
275
+ return {
276
+ catalogue: { _meta: { ...(refreshedAt ? { refreshedAt } : {}), planes: planeMeta }, planes },
277
+ outcomes,
278
+ };
279
+ }
280
+
281
+ /**
282
+ * hazard: this file used to run its fetches at module scope, so importing it to test the parsers would have hit the
283
+ * network. `parseCursorDocs` and the key function were therefore untested — and both carried a defect that reached
284
+ * the catalogue ([/decisions/ad-096.md](/decisions/ad-096.md)).
285
+ */
286
+ async function main(): Promise<void> {
287
+ const mode = (process.argv[2] ?? "all").toLowerCase();
288
+ const ifStale = process.argv.includes("--if-stale");
289
+ const quiet = process.argv.includes("--quiet");
290
+
291
+ const path = cataloguePath();
292
+
293
+ /**
294
+ * why: `--if-stale` is what makes an automatic refresh safe to wire into `install` and `update`. Without it both
295
+ * would reach the network on every run; with it the common case is one file read.
296
+ *
297
+ * invariant: the freshness decision is the core's and takes the clock as a parameter, and it is per plane —
298
+ * the provider's page changes far more often than the vendor list.
299
+ */
300
+ function wanted(plane: string, label: string): boolean {
301
+ if (!ifStale) {
302
+ return true;
303
+ }
304
+ const meta = existing._meta?.planes?.[plane];
305
+ const state = coreFacade.pricing.freshness(
306
+ existing.planes?.[plane] === undefined ? null : (meta ?? {}),
307
+ new Date(),
308
+ );
309
+ if (coreFacade.pricing.shouldRefetch(state)) {
310
+ return true;
311
+ }
312
+ if (!quiet) {
313
+ console.log(`${coreFacade.pricing.freshnessMessage(state, label)} — not refetching`);
314
+ }
315
+ return false;
316
+ }
317
+
318
+ // why: the directory may not exist yet on a first install, and writing into a missing one is the failure this
319
+ // avoids rather than reports.
320
+ mkdirSync(HARNESS_HOME, { recursive: true });
321
+ // invariant: the legacy overrides move out before the catalogue is read, so nothing of the old file's shape —
322
+ // its entries or its date — is carried into the new one.
323
+ adoptLegacyOverrides(quiet);
324
+ const existing = loadCatalogue();
325
+
326
+ const updates: PlaneUpdate[] = [];
327
+
328
+ if ((mode === "all" || mode === PROVIDER_PLANE) && wanted(PROVIDER_PLANE, `${PROVIDER_PLANE} prices`)) {
329
+ const res = await fetch(PROVIDER_DOCS_URL);
330
+ if (!res.ok) {
331
+ console.error(`refresh: provider docs answered ${res.status} — keeping the catalogue as it is`);
332
+ process.exit(1);
333
+ }
334
+ updates.push({
335
+ plane: PROVIDER_PLANE,
336
+ source: PROVIDER_DOCS_URL,
337
+ table: parseCursorDocs(await res.text()),
338
+ });
339
+ }
340
+
341
+ if ((mode === "all" || mode === FALLBACK_PLANE) && wanted(FALLBACK_PLANE, `${FALLBACK_PLANE} prices`)) {
342
+ const res = await fetch(LITELLM_URL);
343
+ if (!res.ok) {
344
+ console.error(`refresh: ${FALLBACK_PLANE} answered ${res.status} — keeping the catalogue as it is`);
345
+ process.exit(1);
346
+ }
347
+ updates.push({
348
+ plane: FALLBACK_PLANE,
349
+ source: LITELLM_URL,
350
+ table: parseLiteLlm((await res.json()) as Record<string, LiteLlmEntry>),
351
+ });
352
+ }
353
+
354
+ if (updates.length === 0) {
355
+ return;
356
+ }
357
+
358
+ const { catalogue, outcomes } = applyPlanes(existing, updates, new Date());
359
+ const refused = outcomes.filter((outcome) => !outcome.accepted);
360
+ if (outcomes.some((outcome) => outcome.accepted)) {
361
+ writeFileSync(path, `${JSON.stringify(catalogue)}\n`);
362
+ for (const outcome of outcomes.filter((o) => o.accepted)) {
363
+ console.log(`${outcome.plane}: ${outcome.count} models (${outcome.reason}) → ${path}`);
364
+ }
365
+ retireSupersededFiles(quiet);
366
+ }
367
+ for (const outcome of refused) {
368
+ console.error(`${outcome.plane}: ${outcome.reason}`);
369
+ }
370
+ if (refused.length > 0) {
371
+ process.exitCode = 1;
372
+ }
187
373
  }
188
374
 
189
- if (mode === "all" || mode === "litellm") {
190
- const res = await fetch(LITELLM_URL);
191
- if (!res.ok) {
192
- console.error(`Failed to fetch LiteLLM prices: ${res.status}`);
193
- process.exit(1);
194
- }
195
- const raw = (await res.json()) as Record<string, LiteLlmEntry>;
196
- const litellm = parseLiteLlm(raw);
197
- const path = join(HARNESS_HOME, "model-prices.litellm.json");
198
- writeFileSync(
199
- path,
200
- `${JSON.stringify({
201
- _meta: {
202
- source: LITELLM_URL,
203
- refreshedAt: new Date().toISOString(),
204
- count: Object.keys(litellm).length,
205
- },
206
- ...litellm,
207
- })}\n`,
208
- );
209
- console.log(`LiteLLM catalog: ${Object.keys(litellm).length} models → ${path}`);
375
+ if (import.meta.main) {
376
+ await main();
210
377
  }
@@ -37,36 +37,38 @@ export type UninstallPlan = {
37
37
 
38
38
  export type UninstallTargets = {
39
39
  home: string;
40
- binLink: string;
40
+ binLinks: string[];
41
41
  claudeSettings: string;
42
42
  cursorHooks: string;
43
43
  skillLinks: string[];
44
44
  };
45
45
 
46
46
  /**
47
- * hazard: `install.ps1` does not write the same artefacts as `install.sh`. It resolves the home from
48
- * `USERPROFILE`, **copies** `tlc.cmd` into the bin directory instead of linking it, and puts one skill junction
49
- * at `~/.tlc/skills/harness-init` rather than one inside each provider's directory. Reading the POSIX layout on
50
- * Windows finds none of them and reports a clean machine ([/decisions/ad-066.md](/decisions/ad-066.md)).
47
+ * Everything an install may have left outside the runtime directory, on any machine.
48
+ *
49
+ * hazard: this used to read one layout per platform, chosen by `process.platform` and the two installers wrote
50
+ * different layouts, so reading the POSIX one on Windows found none of them and reported a clean machine
51
+ * ([/decisions/ad-066.md](/decisions/ad-066.md)).
52
+ *
53
+ * why every name and not a branch: an uninstall has to clean up what is *there*, which includes what an older
54
+ * version put there. `tlc.cmd` does not exist on Linux and the legacy `~/.tlc/skills` junction does not exist on
55
+ * a machine installed after it was retired — an absent path is reported as nothing, so listing them all is both
56
+ * simpler and more complete than deciding ([/decisions/ad-097.md](/decisions/ad-097.md)).
51
57
  */
52
- export function uninstallTargets(
53
- env: NodeJS.ProcessEnv = process.env,
54
- platform: NodeJS.Platform = process.platform,
55
- ): UninstallTargets {
56
- const windows = platform === "win32";
57
- const userHome = (windows ? env.USERPROFILE : env.HOME)?.trim() || homedir();
58
+ export function uninstallTargets(env: NodeJS.ProcessEnv = process.env): UninstallTargets {
59
+ const userHome = homedir();
58
60
  const binDir = env.TLC_BIN_DIR?.trim() || join(userHome, ".local", "bin");
59
61
  return {
60
62
  home: runtimeHome(env),
61
- binLink: join(binDir, windows ? "tlc.cmd" : "tlc"),
63
+ binLinks: [join(binDir, "tlc"), join(binDir, "tlc.cmd")],
62
64
  claudeSettings: join(claudeConfigDir(), "settings.json"),
63
65
  cursorHooks: join(cursorConfigDir(), "hooks.json"),
64
- skillLinks: windows
65
- ? [join(userHome, ".tlc", "skills", "harness-init")]
66
- : [
67
- join(claudeConfigDir(), "skills", "harness-init"),
68
- join(cursorConfigDir(), "skills", "harness-init"),
69
- ],
66
+ skillLinks: [
67
+ join(claudeConfigDir(), "skills", "harness-init"),
68
+ join(cursorConfigDir(), "skills", "harness-init"),
69
+ // the layout the PowerShell installer wrote: one junction no provider ever read
70
+ join(userHome, ".tlc", "skills", "harness-init"),
71
+ ],
70
72
  };
71
73
  }
72
74
 
@@ -312,7 +314,9 @@ export function planUninstall(targets: UninstallTargets, options: { purge?: bool
312
314
  for (const link of targets.skillLinks) {
313
315
  planLink(items, link, targets.home, "skill link", "location");
314
316
  }
315
- planLink(items, targets.binLink, targets.home, "the tlc launcher on PATH", "target");
317
+ for (const link of targets.binLinks) {
318
+ planLink(items, link, targets.home, "the tlc launcher on PATH", "target");
319
+ }
316
320
  const homeIsLink = planRuntime(items, targets.home, purge);
317
321
  planManual(items, targets.home);
318
322
 
package/bin/tlc-build DELETED
@@ -1,80 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Build Node-runnable ESM bundles under dist/. Requires Bun OR esbuild on PATH for compile.
3
- set -euo pipefail
4
-
5
- TLC_HOME="$(cd "$(dirname "$0")/.." && pwd)"
6
- DIST="$TLC_HOME/dist"
7
- mkdir -p "$DIST"
8
-
9
- # Derived from disk, never hardcoded: a fixed list silently stops building a new
10
- # entrypoint, and the missing bundle only surfaces when a hook fires in production.
11
- collect() {
12
- local dir="$1"
13
- local name
14
- for path in "$dir"/*.ts; do
15
- [ -e "$path" ] || continue
16
- name="$(basename "$path" .ts)"
17
- case "$name" in *.test) continue ;; esac
18
- printf '%s\n' "$name"
19
- done
20
- }
21
-
22
- # tools/dev/ holds the checks that validate THIS repository's own architecture, docs and conventions. They must
23
- # not ship — a user's clone has no src/core to validate and no docs/decisions of ours to render — and the
24
- # directory is the whole declaration: `collect` reads one level, so nothing under tools/dev is ever a bundle.
25
- # The list this replaced named four while ten qualified, and the six that were added later shipped for weeks.
26
-
27
- # macOS ships bash 3.2, which has no mapfile — read into arrays the portable way instead.
28
- ENTRYPOINTS=()
29
- while IFS= read -r name; do
30
- [ -n "$name" ] && ENTRYPOINTS+=("$name")
31
- done < <(collect "$TLC_HOME/src/entrypoints")
32
-
33
- TOOLS=()
34
- while IFS= read -r name; do
35
- [ -n "$name" ] && TOOLS+=("$name")
36
- done < <(collect "$TLC_HOME/tools")
37
-
38
- build_one() {
39
- local src="$1"
40
- local out="$2"
41
- if command -v bun >/dev/null 2>&1; then
42
- bun build --target=node --format=esm --outfile="$out" "$src"
43
- return
44
- fi
45
- if command -v esbuild >/dev/null 2>&1; then
46
- esbuild --bundle --platform=node --format=esm --outfile="$out" "$src"
47
- return
48
- fi
49
- echo "tlc-build: need Bun or esbuild to compile TypeScript → dist/" >&2
50
- echo " Install Node.js 24+ (Active LTS) or 26 Current, then either Bun or: npm i -g esbuild" >&2
51
- exit 1
52
- }
53
-
54
- echo "tlc-build → $DIST"
55
- for name in "${ENTRYPOINTS[@]}"; do
56
- build_one "$TLC_HOME/src/entrypoints/${name}.ts" "$DIST/${name}.mjs"
57
- done
58
- for name in "${TOOLS[@]}"; do
59
- build_one "$TLC_HOME/tools/${name}.ts" "$DIST/${name}.mjs"
60
- done
61
-
62
- build_one "$TLC_HOME/bin/tlc-cli.ts" "$DIST/tlc-cli.mjs"
63
-
64
- chmod +x "$TLC_HOME/bin/tlc" "$TLC_HOME/bin/tlc-exec" "$TLC_HOME/bin/tlc-build"
65
-
66
- # A bundle whose source moved or was deleted is not rebuilt, so it is also never diffed — it simply stays in
67
- # dist/ and ships. Deriving what to remove from the same disk that decides what to build closes that.
68
- for bundle in "$DIST"/*.mjs; do
69
- [ -e "$bundle" ] || continue
70
- name="$(basename "$bundle" .mjs)"
71
- if [ "$name" = "tlc-cli" ] ||
72
- [ -f "$TLC_HOME/src/entrypoints/${name}.ts" ] ||
73
- [ -f "$TLC_HOME/tools/${name}.ts" ]; then
74
- continue
75
- fi
76
- echo "tlc-build: pruning $name.mjs — no source"
77
- rm -f "$bundle"
78
- done
79
-
80
- echo "tlc-build: ok ($(ls -1 "$DIST"/*.mjs | wc -l) bundles)"
package/bin/tlc-exec DELETED
@@ -1,10 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
- SOURCE="${BASH_SOURCE[0]:-$0}"
4
- while [[ -L "$SOURCE" ]]; do
5
- DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
6
- SOURCE="$(readlink "$SOURCE")"
7
- [[ "$SOURCE" != /* ]] && SOURCE="$DIR/$SOURCE"
8
- done
9
- BIN_DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
10
- exec node "$BIN_DIR/tlc-exec.mjs" "$@"
package/bin/tlc-exec.cmd DELETED
@@ -1,4 +0,0 @@
1
- @echo off
2
- setlocal
3
- node "%~dp0tlc-exec.mjs" %*
4
- exit /b %ERRORLEVEL%