cronfish 0.12.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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +541 -0
  3. package/examples/.cronfish.json +17 -0
  4. package/examples/README.md +28 -0
  5. package/examples/disk-space.sh +25 -0
  6. package/examples/healthcheck.ts +27 -0
  7. package/examples/hello.md +18 -0
  8. package/examples/one-time/cleanup.ts +25 -0
  9. package/examples/one-time/reminder.md +22 -0
  10. package/package.json +44 -0
  11. package/src/alerts/dispatch.ts +134 -0
  12. package/src/alerts/index.ts +21 -0
  13. package/src/alerts/registry.ts +34 -0
  14. package/src/alerts/safe.ts +26 -0
  15. package/src/alerts/shell.ts +63 -0
  16. package/src/alerts/slack.ts +98 -0
  17. package/src/alerts/types.ts +34 -0
  18. package/src/cli.ts +1097 -0
  19. package/src/db.ts +365 -0
  20. package/src/frontmatter.ts +654 -0
  21. package/src/jobs.ts +536 -0
  22. package/src/models.ts +54 -0
  23. package/src/oneTime.ts +259 -0
  24. package/src/parsers/friendly.ts +53 -0
  25. package/src/platform/index.ts +14 -0
  26. package/src/platform/launchd.ts +564 -0
  27. package/src/prune.ts +155 -0
  28. package/src/result.ts +111 -0
  29. package/src/runner.ts +827 -0
  30. package/src/schedule.ts +188 -0
  31. package/src/state.ts +55 -0
  32. package/src/ts-shim.ts +44 -0
  33. package/src/ui/server.ts +469 -0
  34. package/src/watchdog.ts +201 -0
  35. package/templates/_examples/one-time/echo-at.md +10 -0
  36. package/templates/plist.template +35 -0
  37. package/ui/README.md +73 -0
  38. package/ui/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  39. package/ui/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  40. package/ui/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  41. package/ui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  42. package/ui/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  43. package/ui/dist/assets/index-DNE046Zp.js +9 -0
  44. package/ui/dist/assets/index-DmWTmu9X.css +2 -0
  45. package/ui/dist/favicon.svg +1 -0
  46. package/ui/dist/icons.svg +24 -0
  47. package/ui/dist/index.html +14 -0
package/src/jobs.ts ADDED
@@ -0,0 +1,536 @@
1
+ // Job discovery + validation. cli.ts and runner.ts both go through these
2
+ // loaders so strict-field rules are enforced exactly once.
3
+ //
4
+ // Every field validates: missing → undefined (defaults applied elsewhere),
5
+ // wrong type → throw with file + key + expected + got.
6
+
7
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
8
+ import { basename, extname, join, relative } from "node:path";
9
+ import {
10
+ parseFrontmatter,
11
+ parseShellFrontmatter,
12
+ parseTsJobConfig,
13
+ FrontmatterError,
14
+ type Scalar,
15
+ type TsJobConfigShape,
16
+ } from "./frontmatter.ts";
17
+ import {
18
+ DEFAULT_GRACE_SECONDS,
19
+ isOneTimePath,
20
+ parseRunAt,
21
+ } from "./oneTime.ts";
22
+
23
+ export type JobKind = "md" | "ts" | "sh";
24
+ export type Concurrency = "skip" | "queue";
25
+
26
+ export interface OnFailure {
27
+ notify?: string;
28
+ channel?: string;
29
+ }
30
+
31
+ export interface JobMeta {
32
+ slug: string;
33
+ path: string;
34
+ kind: JobKind;
35
+ enabled: boolean;
36
+ schedule?: string | number;
37
+ timeout?: number;
38
+ model?: string;
39
+ retries?: number;
40
+ concurrency?: Concurrency;
41
+ description?: string;
42
+ missed_after?: string;
43
+ on_failure?: OnFailure;
44
+ // Scoped secrets. When set, only these keys from the consumer .env are
45
+ // injected into the job's launchd plist EnvironmentVariables — instead of
46
+ // the whole .env. Unset → full .env (backward compatible). Note: `.ts` jobs
47
+ // also read `.env` via bun's auto-loader, so `env:` only fences `.md`/`.sh`
48
+ // runs (which rely on the plist block). See README "Security".
49
+ env?: string[];
50
+ // .md jobs only. Capability fence for the Claude Code runner. When set, the
51
+ // run drops `--dangerously-skip-permissions` and instead passes
52
+ // `--allowedTools <list>` under the default permission mode, so any tool not
53
+ // on the list auto-denies in headless mode. Unset → skip-permissions
54
+ // (backward compatible). See README "Security".
55
+ allowed_tools?: string[];
56
+ // .md jobs only. Dollar budget cap for the Claude Code run, passed to the
57
+ // CLI as `--max-budget-usd`. The run stops making API calls once the cap is
58
+ // hit — backstops a runaway loop or an LLM quietly billing on a short cron.
59
+ // Accepts a fraction (e.g. `0.50`). Unset → no cap. See README "Security".
60
+ max_cost?: number;
61
+ // .md jobs only. "Draft but don't send." When true, the run denies the
62
+ // mutating built-in tools (Write, Edit, NotebookEdit, Bash) via
63
+ // `--disallowedTools`, so the model can read/search/draft but not edit files
64
+ // or shell out. Composes with `allowed_tools` (deny wins). MCP sends aren't
65
+ // auto-detected — pair with `allowed_tools` to fence those. See README.
66
+ read_only?: boolean;
67
+ // .md jobs only. When set, the .md is dispatched to a runner registered
68
+ // in `.cronfish.json#runners.<runner>.path` instead of the default
69
+ // claude-cli path. Lets a single .md format target multiple engines
70
+ // (claude CLI, Vercel AI SDK, future LangChain/Mastra, etc.).
71
+ runner?: string;
72
+ // One-shot scheduled jobs (files under `cron/one-time/`).
73
+ oneTime?: boolean;
74
+ runAtMs?: number;
75
+ graceSeconds?: number;
76
+ executedAt?: string;
77
+ }
78
+
79
+ export class JobValidationError extends Error {
80
+ constructor(path: string, message: string) {
81
+ super(`${path}: ${message}`);
82
+ this.name = "JobValidationError";
83
+ }
84
+ }
85
+
86
+ function asString(
87
+ path: string,
88
+ key: string,
89
+ val: Scalar | undefined,
90
+ ): string | undefined {
91
+ if (val === undefined) return undefined;
92
+ if (typeof val !== "string") {
93
+ throw new JobValidationError(
94
+ path,
95
+ `${key} must be a string, got ${typeof val}: ${val}`,
96
+ );
97
+ }
98
+ return val;
99
+ }
100
+
101
+ function asScheduleInput(
102
+ path: string,
103
+ val: Scalar | undefined,
104
+ ): string | number | undefined {
105
+ if (val === undefined) return undefined;
106
+ if (typeof val === "boolean") {
107
+ throw new JobValidationError(
108
+ path,
109
+ `schedule must be a string or number, got boolean`,
110
+ );
111
+ }
112
+ return val;
113
+ }
114
+
115
+ function asPositiveInt(
116
+ path: string,
117
+ key: string,
118
+ val: Scalar | undefined,
119
+ { min }: { min: number },
120
+ ): number | undefined {
121
+ if (val === undefined) return undefined;
122
+ if (typeof val !== "number" || !Number.isInteger(val)) {
123
+ throw new JobValidationError(
124
+ path,
125
+ `${key} must be an integer, got ${typeof val}: ${val}`,
126
+ );
127
+ }
128
+ if (val < min) {
129
+ throw new JobValidationError(path, `${key} must be >= ${min}, got ${val}`);
130
+ }
131
+ return val;
132
+ }
133
+
134
+ // Validate an inline-array field (e.g. `env:`). `undefined` (key absent) →
135
+ // undefined, meaning "not declared". An explicit empty `[]` stays `[]`,
136
+ // meaning "declared but empty". Every item must be a non-empty string; the
137
+ // frontmatter parser already produced a string[] so this is mostly a guard.
138
+ function asStringList(
139
+ path: string,
140
+ key: string,
141
+ val: string[] | undefined,
142
+ ): string[] | undefined {
143
+ if (val === undefined) return undefined;
144
+ for (const item of val) {
145
+ if (typeof item !== "string" || item.length === 0) {
146
+ throw new JobValidationError(
147
+ path,
148
+ `${key} entries must be non-empty strings, got: ${JSON.stringify(item)}`,
149
+ );
150
+ }
151
+ }
152
+ return val;
153
+ }
154
+
155
+ // A positive number that may be fractional. The frontmatter parser only
156
+ // coerces integers, so a value like `0.50` arrives as the string "0.50" — we
157
+ // accept both a parsed number and a numeric string here. `> 0` required.
158
+ function asPositiveNumber(
159
+ path: string,
160
+ key: string,
161
+ val: Scalar | undefined,
162
+ ): number | undefined {
163
+ if (val === undefined) return undefined;
164
+ let n: number;
165
+ if (typeof val === "number") n = val;
166
+ else if (typeof val === "string" && /^\d*\.?\d+$/.test(val.trim())) {
167
+ n = parseFloat(val);
168
+ } else {
169
+ throw new JobValidationError(
170
+ path,
171
+ `${key} must be a positive number, got: ${val}`,
172
+ );
173
+ }
174
+ if (!(n > 0) || !Number.isFinite(n)) {
175
+ throw new JobValidationError(
176
+ path,
177
+ `${key} must be a positive number, got: ${val}`,
178
+ );
179
+ }
180
+ return n;
181
+ }
182
+
183
+ function asConcurrency(
184
+ path: string,
185
+ val: Scalar | undefined,
186
+ ): Concurrency | undefined {
187
+ if (val === undefined) return undefined;
188
+ if (val !== "skip" && val !== "queue") {
189
+ throw new JobValidationError(
190
+ path,
191
+ `concurrency must be "skip" or "queue", got: ${val}`,
192
+ );
193
+ }
194
+ return val;
195
+ }
196
+
197
+ function asBool(
198
+ path: string,
199
+ key: string,
200
+ val: Scalar | undefined,
201
+ fallback: boolean,
202
+ ): boolean {
203
+ if (val === undefined) return fallback;
204
+ if (typeof val !== "boolean") {
205
+ throw new JobValidationError(
206
+ path,
207
+ `${key} must be true or false, got: ${val}`,
208
+ );
209
+ }
210
+ return val;
211
+ }
212
+
213
+ // Like asBool but stays undefined when the key is absent — keeps the field off
214
+ // the meta entirely unless the author opts in.
215
+ function asOptionalBool(
216
+ path: string,
217
+ key: string,
218
+ val: Scalar | undefined,
219
+ ): boolean | undefined {
220
+ if (val === undefined) return undefined;
221
+ return asBool(path, key, val, false);
222
+ }
223
+
224
+ // Slug = path relative to cron/, with the trailing `.<ext>` rewritten to
225
+ // `-<ext>` so the kind is encoded in the slug itself. This makes collisions
226
+ // impossible (`foo.md` and `foo.sh` coexist as `foo-md` and `foo-sh`) and
227
+ // keeps the launchd label readable. Always forward slashes.
228
+ function slugOf(path: string): string {
229
+ return basename(path).replace(/\.(md|ts|sh)$/, "-$1");
230
+ }
231
+
232
+ export function slugFromPath(cronDir: string, absPath: string): string {
233
+ const rel = relative(cronDir, absPath).split("\\").join("/");
234
+ return rel.replace(/\.(md|ts|sh)$/, "-$1");
235
+ }
236
+
237
+ function asOnFailure(
238
+ path: string,
239
+ nested: Record<string, Scalar> | undefined,
240
+ ): OnFailure | undefined {
241
+ if (!nested) return undefined;
242
+ const out: OnFailure = {};
243
+ const notify = nested.notify;
244
+ if (notify !== undefined) {
245
+ if (typeof notify !== "string") {
246
+ throw new JobValidationError(
247
+ path,
248
+ `on_failure.notify must be a string, got ${typeof notify}: ${notify}`,
249
+ );
250
+ }
251
+ out.notify = notify;
252
+ }
253
+ const channel = nested.channel;
254
+ if (channel !== undefined) {
255
+ if (typeof channel !== "string") {
256
+ throw new JobValidationError(
257
+ path,
258
+ `on_failure.channel must be a string, got ${typeof channel}: ${channel}`,
259
+ );
260
+ }
261
+ out.channel = channel;
262
+ }
263
+ for (const k of Object.keys(nested)) {
264
+ if (k !== "notify" && k !== "channel") {
265
+ throw new JobValidationError(
266
+ path,
267
+ `on_failure.${k}: unknown key (allowed: notify, channel)`,
268
+ );
269
+ }
270
+ }
271
+ return out;
272
+ }
273
+
274
+ // Apply one-time fields and validate the schedule/run_at exclusivity rule.
275
+ // Mutates the meta in place. Throws on validation errors.
276
+ function applyOneTime(
277
+ meta: JobMeta,
278
+ isOneTime: boolean,
279
+ runAtRaw: Scalar | undefined,
280
+ graceRaw: Scalar | undefined,
281
+ executedAtRaw: Scalar | undefined,
282
+ ): void {
283
+ if (!isOneTime) {
284
+ if (runAtRaw !== undefined) {
285
+ throw new JobValidationError(
286
+ meta.path,
287
+ `run_at is only valid inside cron/one-time/`,
288
+ );
289
+ }
290
+ return;
291
+ }
292
+ meta.oneTime = true;
293
+ if (meta.schedule !== undefined) {
294
+ throw new JobValidationError(
295
+ meta.path,
296
+ `one-time job must NOT set "schedule"; use "run_at" instead`,
297
+ );
298
+ }
299
+ if (runAtRaw === undefined) {
300
+ throw new JobValidationError(
301
+ meta.path,
302
+ `one-time job missing required "run_at" (ISO timestamp or "+N{s,m,h,d}")`,
303
+ );
304
+ }
305
+ let mtimeMs: number;
306
+ try {
307
+ mtimeMs = statSync(meta.path).mtimeMs;
308
+ } catch {
309
+ mtimeMs = Date.now();
310
+ }
311
+ try {
312
+ meta.runAtMs = parseRunAt(runAtRaw, mtimeMs);
313
+ } catch (e) {
314
+ throw new JobValidationError(meta.path, (e as Error).message);
315
+ }
316
+ if (graceRaw === undefined) {
317
+ meta.graceSeconds = DEFAULT_GRACE_SECONDS;
318
+ } else if (typeof graceRaw === "number" && Number.isInteger(graceRaw) && graceRaw >= 0) {
319
+ meta.graceSeconds = graceRaw;
320
+ } else {
321
+ throw new JobValidationError(
322
+ meta.path,
323
+ `grace_seconds must be a non-negative integer, got: ${graceRaw}`,
324
+ );
325
+ }
326
+ if (executedAtRaw !== undefined) {
327
+ if (typeof executedAtRaw !== "string") {
328
+ throw new JobValidationError(
329
+ meta.path,
330
+ `executed_at must be a string, got ${typeof executedAtRaw}`,
331
+ );
332
+ }
333
+ meta.executedAt = executedAtRaw;
334
+ }
335
+ }
336
+
337
+ function fromMarkdown(path: string, slug: string, isOneTime: boolean): JobMeta {
338
+ const raw = readFileSync(path, "utf-8");
339
+ let frontmatter: Record<string, Scalar>;
340
+ let nested: Record<string, Record<string, Scalar>>;
341
+ let lists: Record<string, string[]>;
342
+ try {
343
+ const parsed = parseFrontmatter(raw);
344
+ frontmatter = parsed.frontmatter;
345
+ nested = parsed.nested;
346
+ lists = parsed.lists;
347
+ } catch (e) {
348
+ if (e instanceof FrontmatterError)
349
+ throw new JobValidationError(path, e.message);
350
+ throw e;
351
+ }
352
+ const meta: JobMeta = {
353
+ slug,
354
+ path,
355
+ kind: "md",
356
+ enabled: asBool(path, "enabled", frontmatter.enabled, true),
357
+ schedule: asScheduleInput(path, frontmatter.schedule),
358
+ timeout: asPositiveInt(path, "timeout", frontmatter.timeout, { min: 1 }),
359
+ model: asString(path, "model", frontmatter.model) ?? "haiku",
360
+ retries: asPositiveInt(path, "retries", frontmatter.retries, { min: 0 }),
361
+ concurrency: asConcurrency(path, frontmatter.concurrency),
362
+ description: asString(path, "description", frontmatter.description),
363
+ missed_after: asString(path, "missed_after", frontmatter.missed_after),
364
+ on_failure: asOnFailure(path, nested.on_failure),
365
+ env: asStringList(path, "env", lists.env),
366
+ allowed_tools: asStringList(path, "allowed_tools", lists.allowed_tools),
367
+ max_cost: asPositiveNumber(path, "max_cost", frontmatter.max_cost),
368
+ read_only: asOptionalBool(path, "read_only", frontmatter.read_only),
369
+ runner: asString(path, "runner", frontmatter.runner),
370
+ };
371
+ applyOneTime(
372
+ meta,
373
+ isOneTime,
374
+ frontmatter.run_at,
375
+ frontmatter.grace_seconds,
376
+ frontmatter.executed_at,
377
+ );
378
+ return meta;
379
+ }
380
+
381
+ function fromTypescript(path: string, slug: string, isOneTime: boolean): JobMeta {
382
+ const source = readFileSync(path, "utf-8");
383
+ let cfg: TsJobConfigShape;
384
+ try {
385
+ cfg = parseTsJobConfig(source);
386
+ } catch (e) {
387
+ if (e instanceof FrontmatterError)
388
+ throw new JobValidationError(path, e.message);
389
+ throw e;
390
+ }
391
+ const meta: JobMeta = {
392
+ slug,
393
+ path,
394
+ kind: "ts",
395
+ enabled: cfg.enabled ?? true,
396
+ schedule: cfg.schedule,
397
+ timeout: cfg.timeout,
398
+ model: cfg.model,
399
+ retries: cfg.retries,
400
+ concurrency: cfg.concurrency,
401
+ description: cfg.description,
402
+ missed_after: cfg.missed_after,
403
+ on_failure: asOnFailure(path, cfg.on_failure),
404
+ env: asStringList(path, "env", cfg.env),
405
+ };
406
+ applyOneTime(meta, isOneTime, cfg.run_at, cfg.grace_seconds, cfg.executed_at);
407
+ return meta;
408
+ }
409
+
410
+ function fromShell(path: string, slug: string, isOneTime: boolean): JobMeta {
411
+ const raw = readFileSync(path, "utf-8");
412
+ let frontmatter: Record<string, Scalar>;
413
+ let nested: Record<string, Record<string, Scalar>>;
414
+ let lists: Record<string, string[]>;
415
+ try {
416
+ const parsed = parseShellFrontmatter(raw);
417
+ frontmatter = parsed.frontmatter;
418
+ nested = parsed.nested;
419
+ lists = parsed.lists;
420
+ } catch (e) {
421
+ if (e instanceof FrontmatterError)
422
+ throw new JobValidationError(path, e.message);
423
+ throw e;
424
+ }
425
+ if (
426
+ Object.keys(frontmatter).length === 0 &&
427
+ Object.keys(nested).length === 0
428
+ ) {
429
+ throw new JobValidationError(
430
+ path,
431
+ `shell job needs a "# ---" frontmatter block at the top (with at least "schedule:")`,
432
+ );
433
+ }
434
+ const meta: JobMeta = {
435
+ slug,
436
+ path,
437
+ kind: "sh",
438
+ enabled: asBool(path, "enabled", frontmatter.enabled, true),
439
+ schedule: asScheduleInput(path, frontmatter.schedule),
440
+ timeout: asPositiveInt(path, "timeout", frontmatter.timeout, { min: 1 }),
441
+ retries: asPositiveInt(path, "retries", frontmatter.retries, { min: 0 }),
442
+ concurrency: asConcurrency(path, frontmatter.concurrency),
443
+ description: asString(path, "description", frontmatter.description),
444
+ missed_after: asString(path, "missed_after", frontmatter.missed_after),
445
+ on_failure: asOnFailure(path, nested.on_failure),
446
+ env: asStringList(path, "env", lists.env),
447
+ };
448
+ applyOneTime(
449
+ meta,
450
+ isOneTime,
451
+ frontmatter.run_at,
452
+ frontmatter.grace_seconds,
453
+ frontmatter.executed_at,
454
+ );
455
+ return meta;
456
+ }
457
+
458
+ export function loadJob(
459
+ absPath: string,
460
+ slug?: string,
461
+ cronDir?: string,
462
+ ): JobMeta {
463
+ const ext = extname(absPath);
464
+ const s = slug ?? slugOf(absPath);
465
+ const isOneTime = cronDir ? isOneTimePath(cronDir, absPath) : false;
466
+ if (ext === ".md") return fromMarkdown(absPath, s, isOneTime);
467
+ if (ext === ".ts") return fromTypescript(absPath, s, isOneTime);
468
+ if (ext === ".sh") return fromShell(absPath, s, isOneTime);
469
+ throw new JobValidationError(absPath, `unsupported extension ${ext}`);
470
+ }
471
+
472
+ // Recursively collect every `.md`/`.ts`/`.sh` file under cronDir. The single magic
473
+ // filename `README.md` is ignored at any depth so authors can document a
474
+ // folder of crons without the README getting parsed as a job.
475
+ function walkJobFiles(cronDir: string): string[] {
476
+ const out: string[] = [];
477
+ const visit = (dir: string): void => {
478
+ let entries: string[];
479
+ try {
480
+ entries = readdirSync(dir);
481
+ } catch {
482
+ return;
483
+ }
484
+ for (const name of entries) {
485
+ const full = join(dir, name);
486
+ let st;
487
+ try {
488
+ st = statSync(full);
489
+ } catch {
490
+ continue;
491
+ }
492
+ if (st.isDirectory()) {
493
+ // Skip the sentinel folder cronfish writes to under cron/. Anything
494
+ // else (including nested project folders) is recursed.
495
+ if (name === ".errors") continue;
496
+ visit(full);
497
+ continue;
498
+ }
499
+ if (!st.isFile()) continue;
500
+ if (name === "README.md") continue;
501
+ if (name.endsWith(".md") || name.endsWith(".ts") || name.endsWith(".sh"))
502
+ out.push(full);
503
+ }
504
+ };
505
+ visit(cronDir);
506
+ return out;
507
+ }
508
+
509
+ export function discoverJobs(cronDir: string): {
510
+ jobs: JobMeta[];
511
+ errors: { path: string; message: string }[];
512
+ } {
513
+ if (!existsSync(cronDir)) return { jobs: [], errors: [] };
514
+ const entries = walkJobFiles(cronDir);
515
+ const jobs: JobMeta[] = [];
516
+ const errors: { path: string; message: string }[] = [];
517
+ for (const p of entries) {
518
+ try {
519
+ jobs.push(loadJob(p, slugFromPath(cronDir, p), cronDir));
520
+ } catch (e) {
521
+ errors.push({ path: p, message: (e as Error).message });
522
+ }
523
+ }
524
+ jobs.sort((a, b) => a.slug.localeCompare(b.slug));
525
+ return { jobs, errors };
526
+ }
527
+
528
+ // Inverse of slugFromPath: a slug ends in `-md`, `-ts`, or `-sh`. Split the
529
+ // suffix to reconstruct the filename. Returns null for malformed slugs or
530
+ // missing files.
531
+ export function findJobFile(cronDir: string, slug: string): string | null {
532
+ const m = slug.match(/^(.*)-(md|ts|sh)$/);
533
+ if (!m) return null;
534
+ const p = join(cronDir, `${m[1]}.${m[2]}`);
535
+ return existsSync(p) ? p : null;
536
+ }
package/src/models.ts ADDED
@@ -0,0 +1,54 @@
1
+ // Model resolution + provider dispatch for cronfish MD jobs.
2
+ //
3
+ // Anthropic models run via the `claude` CLI in headless mode against
4
+ // api.anthropic.com.
5
+ //
6
+ // Local models also run via the `claude` CLI, but with ANTHROPIC_BASE_URL
7
+ // pointed at a local Anthropic-compatible endpoint (Ollama 0.14+ speaks
8
+ // Messages natively; LiteLLM proxies it for everything else). The model
9
+ // ID after the `local:` prefix is passed verbatim to `--model` and as
10
+ // the three slot overrides so sub-agents route locally too.
11
+
12
+ export type Provider = "anthropic" | "local";
13
+
14
+ export interface Resolved {
15
+ provider: Provider;
16
+ id: string;
17
+ }
18
+
19
+ const ALIASES: Record<string, string> = {
20
+ haiku: "claude-haiku-4-5-20251001",
21
+ sonnet: "claude-sonnet-4-6",
22
+ opus: "claude-opus-4-7",
23
+ };
24
+
25
+ export function resolveModel(input: string | undefined): Resolved {
26
+ const raw = (input ?? "haiku").trim();
27
+ if (raw.startsWith("local:")) {
28
+ return { provider: "local", id: raw.slice("local:".length) };
29
+ }
30
+ if (ALIASES[raw]) return { provider: "anthropic", id: ALIASES[raw] };
31
+ return { provider: "anthropic", id: raw };
32
+ }
33
+
34
+ // Env block to inject when spawning `claude` for a local model.
35
+ // Overridable via CRONFISH_LOCAL_BASE_URL / CRONFISH_LOCAL_AUTH_TOKEN
36
+ // so the same binary can target Ollama, LiteLLM, LM Studio, or a remote
37
+ // box on the LAN without touching cronfish.
38
+ export function localClaudeEnv(modelId: string): Record<string, string> {
39
+ const baseUrl =
40
+ process.env.CRONFISH_LOCAL_BASE_URL ?? "http://localhost:11434";
41
+ const authToken = process.env.CRONFISH_LOCAL_AUTH_TOKEN ?? "ollama";
42
+ return {
43
+ ANTHROPIC_BASE_URL: baseUrl,
44
+ ANTHROPIC_AUTH_TOKEN: authToken,
45
+ ANTHROPIC_API_KEY: authToken,
46
+ ANTHROPIC_MODEL: modelId,
47
+ ANTHROPIC_SMALL_FAST_MODEL: modelId,
48
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: modelId,
49
+ ANTHROPIC_DEFAULT_SONNET_MODEL: modelId,
50
+ ANTHROPIC_DEFAULT_OPUS_MODEL: modelId,
51
+ CLAUDE_CODE_SUBAGENT_MODEL: modelId,
52
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
53
+ };
54
+ }