catence 0.1.0 → 0.2.0-beta.10

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 (51) hide show
  1. package/README.md +239 -269
  2. package/config.example.json +32 -20
  3. package/dist/contracts/index.d.ts +1 -0
  4. package/dist/contracts/index.js +1 -0
  5. package/dist/contracts/progress.d.ts +23 -0
  6. package/dist/contracts/progress.js +48 -0
  7. package/dist/contracts/release.d.ts +1 -1
  8. package/dist/contracts/release.js +1 -1
  9. package/dist/core/logging.d.ts +16 -0
  10. package/dist/core/logging.js +68 -0
  11. package/dist/core/query/repository.d.ts +2 -0
  12. package/dist/core/query/repository.js +42 -0
  13. package/dist/core/runtime/catalog.d.ts +41 -0
  14. package/dist/core/runtime/catalog.js +121 -0
  15. package/dist/core/runtime/configuration.d.ts +1 -1
  16. package/dist/core/runtime/configuration.js +3 -3
  17. package/dist/core/runtime/secrets.d.ts +24 -0
  18. package/dist/core/runtime/secrets.js +68 -0
  19. package/dist/core/runtime/self-update.d.ts +44 -0
  20. package/dist/core/runtime/self-update.js +138 -0
  21. package/dist/elt/application/management.d.ts +2 -0
  22. package/dist/elt/application/management.js +197 -18
  23. package/dist/elt/ingestion/importer.d.ts +3 -2
  24. package/dist/elt/ingestion/importer.js +4 -3
  25. package/dist/elt/ingestion/providers/intervals.d.ts +3 -1
  26. package/dist/elt/ingestion/providers/intervals.js +4 -1
  27. package/dist/elt/ingestion/providers/strava/service.js +9 -2
  28. package/dist/elt/storage/database.d.ts +7 -2
  29. package/dist/elt/storage/database.js +81 -4
  30. package/dist/elt/storage/migrations.js +25 -1
  31. package/dist/interfaces/cli/commands.js +201 -46
  32. package/dist/interfaces/http/cli-options.d.ts +2 -2
  33. package/dist/interfaces/http/cli-options.js +10 -10
  34. package/dist/interfaces/http/server.d.ts +4 -1
  35. package/dist/interfaces/http/server.js +31 -4
  36. package/dist/interfaces/mcp/cli-options.d.ts +2 -2
  37. package/dist/interfaces/mcp/cli-options.js +14 -14
  38. package/dist/interfaces/mcp/main.js +12 -5
  39. package/dist/interfaces/mcp/server.d.ts +2 -2
  40. package/dist/interfaces/mcp/server.js +156 -98
  41. package/dist/runtime/index.d.ts +8 -1
  42. package/dist/runtime/index.js +4 -1
  43. package/docs/deployment.md +338 -0
  44. package/docs/docker-deployment.md +232 -0
  45. package/docs/llm-providers.md +467 -0
  46. package/package.json +6 -2
  47. package/python/catence/providers/garmin/cli.py +30 -9
  48. package/python/catence/providers/garmin/progress.py +85 -0
  49. package/python/catence/providers/garmin/worker.py +132 -5
  50. package/scripts/discover-opencode-go.mjs +210 -0
  51. package/scripts/discover-opencode-zen.mjs +218 -0
@@ -6,11 +6,16 @@ import hashlib
6
6
  import json
7
7
  from typing import Any, Callable, Mapping
8
8
 
9
+ from .progress import ProgressReporter
9
10
  from .registry import GARMIN_READ_METHODS, assert_read_only_registry
10
11
  from .staging import StagingWriter
11
12
  from .streams import SAMPLE_COLUMNS, activity_details_to_samples, activity_power_bests, fit_archive_to_samples, write_parquet
12
13
 
13
14
 
15
+ class WorkerInterrupted(Exception):
16
+ """Raised between extraction steps when the run is asked to stop."""
17
+
18
+
14
19
  def as_dict(value: Any) -> dict[str, Any]:
15
20
  return value if isinstance(value, dict) else {"value": value}
16
21
 
@@ -80,11 +85,23 @@ def payload_date(payload: Mapping[str, Any]) -> str | None:
80
85
  class GarminStagingWorker:
81
86
  """Extract Garmin data through the explicit read-only registry into JSONL/Parquet staging."""
82
87
 
83
- def __init__(self, api: Any, writer: StagingWriter, data_dir: Path, known_activity_hashes: Mapping[str, str] | None = None) -> None:
88
+ def __init__(self, api: Any, writer: StagingWriter, data_dir: Path, known_activity_hashes: Mapping[str, str] | None = None, progress: ProgressReporter | None = None) -> None:
84
89
  self.api = api
85
90
  self.writer = writer
86
91
  self.data_dir = data_dir
87
92
  self.known_activity_hashes = dict(known_activity_hashes or {})
93
+ self.progress = progress
94
+ self._interrupted = False
95
+
96
+ def request_interrupt(self) -> None:
97
+ """Ask the worker to stop at the next safe boundary."""
98
+ self._interrupted = True
99
+
100
+ def _check_interrupted(self) -> None:
101
+ if self._interrupted:
102
+ if self.progress is not None:
103
+ self.progress.finish("interrupted")
104
+ raise WorkerInterrupted("Sync run interrupted between extraction steps")
88
105
 
89
106
  def sync(
90
107
  self,
@@ -100,17 +117,26 @@ class GarminStagingWorker:
100
117
  daily_end = daily_to_date or end
101
118
  activity_end = activity_to_date or end
102
119
  if include_non_historical:
120
+ self._check_interrupted()
103
121
  self._singletons()
104
122
  if daily_from_date and daily_from_date <= daily_end:
123
+ self._check_interrupted()
105
124
  self._daily(daily_from_date, daily_end)
125
+ self._check_interrupted()
106
126
  self._range(daily_from_date, daily_end)
127
+ self._check_interrupted()
107
128
  self._cycling_ftp_history(daily_from_date, daily_end)
129
+ self._check_interrupted()
108
130
  self._max_metrics_history(daily_from_date, daily_end)
131
+ self._check_interrupted()
109
132
  self._hrv_history(daily_from_date, daily_end)
133
+ self._check_interrupted()
110
134
  self._score_history(daily_from_date, daily_end)
111
135
  if activity_from_date and activity_from_date <= activity_end:
136
+ self._check_interrupted()
112
137
  self._activities(activity_from_date, activity_end)
113
138
  if include_non_historical:
139
+ self._check_interrupted()
114
140
  self._collections()
115
141
 
116
142
  def _capture(self, endpoint: str, action: Callable[[], Any], remote_id_value: str | None = None, scope: dict[str, Any] | None = None) -> tuple[Any | None, str | None]:
@@ -162,10 +188,18 @@ class GarminStagingWorker:
162
188
  "golf_user_stats": ("get_golf_user_stats", "golf_user_stat"),
163
189
  "golf_club_stats": ("get_golf_club_stats", "golf_club_stat"),
164
190
  }
165
- for endpoint, (method, entity_type) in calls.items():
191
+ if self.progress is not None:
192
+ self.progress.set_stage("singletons")
193
+ self.progress.advance(total=len(calls))
194
+ for index, (endpoint, (method, entity_type)) in enumerate(calls.items(), start=1):
195
+ self._check_interrupted()
196
+ if self.progress is not None:
197
+ self.progress.advance(step=endpoint)
166
198
  payload, raw_hash = self._capture(endpoint, lambda method=method: getattr(self.api, method)())
167
199
  if payload is not None:
168
200
  self._entity(endpoint, entity_type, payload, raw_hash)
201
+ if self.progress is not None:
202
+ self.progress.advance(completed=index)
169
203
 
170
204
  def _daily(self, from_date: date, to_date: date) -> None:
171
205
  calls = {
@@ -183,13 +217,26 @@ class GarminStagingWorker:
183
217
  "nutrition_food_log": ("get_nutrition_daily_food_log", "nutrition_log"), "nutrition_meals": ("get_nutrition_daily_meals", "nutrition_log"),
184
218
  "nutrition_settings": ("get_nutrition_daily_settings", "nutrition_setting"),
185
219
  }
220
+ total_days = (to_date - from_date).days + 1
221
+ if self.progress is not None:
222
+ self.progress.set_stage("daily")
223
+ self.progress.advance(total=total_days)
186
224
  current = from_date
225
+ completed_days = 0
187
226
  while current <= to_date:
227
+ self._check_interrupted()
188
228
  day = current.isoformat()
229
+ if self.progress is not None:
230
+ self.progress.advance(step=day)
189
231
  for endpoint, (method, entity_type) in calls.items():
232
+ if self.progress is not None:
233
+ self.progress.advance(step=f"{day} {endpoint}")
190
234
  payload, raw_hash = self._capture(endpoint, lambda method=method, day=day: getattr(self.api, method)(day), day, {"date": day})
191
235
  if payload is not None:
192
236
  self._entity(endpoint, entity_type, payload, raw_hash, day)
237
+ completed_days += 1
238
+ if self.progress is not None:
239
+ self.progress.advance(completed=completed_days)
193
240
  current += timedelta(days=1)
194
241
 
195
242
  def _range(self, from_date: date, to_date: date) -> None:
@@ -198,7 +245,10 @@ class GarminStagingWorker:
198
245
  "blood_pressure": ("get_blood_pressure", "blood_pressure"), "weigh_ins": ("get_weigh_ins", "body_composition"),
199
246
  "progress_summary": ("get_progress_summary_between_dates", "progress_summary"), "menstrual_calendar": ("get_menstrual_calendar_data", "menstrual"),
200
247
  }
201
- for endpoint, (method, entity_type) in calls.items():
248
+ if self.progress is not None:
249
+ self.progress.set_stage("range")
250
+ self.progress.advance(total=len(calls))
251
+ for index, (endpoint, (method, entity_type)) in enumerate(calls.items(), start=1):
202
252
  # Garmin rejects broad body-battery and menstrual-calendar ranges.
203
253
  # Keep every request inside the provider's 92-day limit.
204
254
  window_start = from_date
@@ -206,23 +256,36 @@ class GarminStagingWorker:
206
256
  # ranges. Keep it deliberately small; the captures are
207
257
  # content-addressed so overlapping retry windows are harmless.
208
258
  window_days = 7 if endpoint == "body_battery" else (90 if endpoint == "menstrual_calendar" else (to_date - from_date).days + 1)
259
+ if self.progress is not None:
260
+ self.progress.advance(step=endpoint)
209
261
  while window_start <= to_date:
262
+ self._check_interrupted()
210
263
  window_end = min(to_date, window_start + timedelta(days=window_days - 1))
211
264
  start, end = window_start.isoformat(), window_end.isoformat()
212
265
  payload, raw_hash = self._capture(endpoint, lambda method=method, start=start, end=end: getattr(self.api, method)(start, end), None, {"from": start, "to": end})
213
266
  if payload is not None:
214
267
  self._entity(endpoint, entity_type, payload, raw_hash, fallback_scope=start)
215
268
  window_start = window_end + timedelta(days=1)
269
+ if self.progress is not None:
270
+ self.progress.advance(completed=index)
216
271
 
217
272
  def _cycling_ftp_history(self, from_date: date, to_date: date) -> None:
218
273
  """Stage daily cycling FTP setting history from Garmin's range endpoint."""
219
274
  endpoint = "functional_threshold_power_range"
220
275
  window_start = from_date
276
+ total_windows = (to_date - from_date).days // 89 + 1
277
+ if self.progress is not None:
278
+ self.progress.set_stage("ftp_history")
279
+ self.progress.advance(total=total_windows)
280
+ completed_windows = 0
221
281
  while window_start <= to_date:
282
+ self._check_interrupted()
222
283
  # This private Connect endpoint is not documented with a maximum
223
284
  # range. Keep requests aligned with other conservative range calls.
224
285
  window_end = min(to_date, window_start + timedelta(days=89))
225
286
  start, end = window_start.isoformat(), window_end.isoformat()
287
+ if self.progress is not None:
288
+ self.progress.advance(step=start)
226
289
  payload, raw_hash = self._capture(
227
290
  endpoint,
228
291
  lambda start=start, end=end: self.api.get_functional_threshold_power_range(
@@ -251,14 +314,25 @@ class GarminStagingWorker:
251
314
  raw_hash,
252
315
  occurred_on=occurred_on,
253
316
  )
317
+ completed_windows += 1
318
+ if self.progress is not None:
319
+ self.progress.advance(completed=completed_windows)
254
320
  window_start = window_end + timedelta(days=1)
255
321
 
256
322
  def _max_metrics_history(self, from_date: date, to_date: date) -> None:
257
323
  """Stage range max-metric responses by their actual calendar date."""
258
324
  window_start = from_date
325
+ total_windows = (to_date - from_date).days // 89 + 1
326
+ if self.progress is not None:
327
+ self.progress.set_stage("max_metrics")
328
+ self.progress.advance(total=total_windows)
329
+ completed_windows = 0
259
330
  while window_start <= to_date:
331
+ self._check_interrupted()
260
332
  window_end = min(to_date, window_start + timedelta(days=89))
261
333
  start, end = window_start.isoformat(), window_end.isoformat()
334
+ if self.progress is not None:
335
+ self.progress.advance(step=start)
262
336
  payload, raw_hash = self._capture(
263
337
  "max_metrics_range",
264
338
  lambda start=start, end=end: self.api.get_max_metrics_range(start, end),
@@ -286,14 +360,25 @@ class GarminStagingWorker:
286
360
  for item in as_items(fallback):
287
361
  self.writer.source_entity("max_metric", f"max_metrics:{day}", item, fallback_hash, occurred_on=day)
288
362
  current += timedelta(days=1)
363
+ completed_windows += 1
364
+ if self.progress is not None:
365
+ self.progress.advance(completed=completed_windows)
289
366
  window_start = window_end + timedelta(days=1)
290
367
 
291
368
  def _hrv_history(self, from_date: date, to_date: date) -> None:
292
369
  """Stage the PR-402 HRV range API, retaining daily fallback coverage."""
293
370
  window_start = from_date
371
+ total_windows = (to_date - from_date).days // 89 + 1
372
+ if self.progress is not None:
373
+ self.progress.set_stage("hrv_history")
374
+ self.progress.advance(total=total_windows)
375
+ completed_windows = 0
294
376
  while window_start <= to_date:
377
+ self._check_interrupted()
295
378
  window_end = min(to_date, window_start + timedelta(days=89))
296
379
  start, end = window_start.isoformat(), window_end.isoformat()
380
+ if self.progress is not None:
381
+ self.progress.advance(step=start)
297
382
  payload, raw_hash = self._capture(
298
383
  "hrv_range",
299
384
  lambda start=start, end=end: self.api.get_hrv_data_range(start, end),
@@ -315,14 +400,25 @@ class GarminStagingWorker:
315
400
  if fallback is not None:
316
401
  self.writer.source_entity("daily_health", f"hrv:{day}", as_dict(fallback), fallback_hash, occurred_on=day)
317
402
  current += timedelta(days=1)
403
+ completed_windows += 1
404
+ if self.progress is not None:
405
+ self.progress.advance(completed=completed_windows)
318
406
  window_start = window_end + timedelta(days=1)
319
407
 
320
408
  def _score_history(self, from_date: date, to_date: date) -> None:
321
409
  """Stage daily-capable endurance-adjacent metrics from range endpoints."""
322
410
  window_start = from_date
411
+ total_windows = (to_date - from_date).days // 89 + 1
412
+ if self.progress is not None:
413
+ self.progress.set_stage("scores")
414
+ self.progress.advance(total=total_windows)
415
+ completed_windows = 0
323
416
  while window_start <= to_date:
417
+ self._check_interrupted()
324
418
  window_end = min(to_date, window_start + timedelta(days=89))
325
419
  start, end = window_start.isoformat(), window_end.isoformat()
420
+ if self.progress is not None:
421
+ self.progress.advance(step=start)
326
422
  for endpoint, method, entity_type in (
327
423
  ("hill_score", lambda: self.api.get_hill_score(start, end), "hill_score"),
328
424
  ("running_tolerance", lambda: self.api.get_running_tolerance(start, end, aggregation="daily"), "running_tolerance"),
@@ -333,33 +429,56 @@ class GarminStagingWorker:
333
429
  item_day = payload_date(item)
334
430
  if item_day:
335
431
  self.writer.source_entity(entity_type, f"{endpoint}:{item_day}:{index}", item, raw_hash, occurred_on=item_day)
432
+ completed_windows += 1
433
+ if self.progress is not None:
434
+ self.progress.advance(completed=completed_windows)
336
435
  window_start = window_end + timedelta(days=1)
337
436
 
338
437
  # Garmin limits daily race-prediction windows to one year.
339
438
  window_start = from_date
439
+ total_windows = (to_date - from_date).days // 365 + 1
440
+ if self.progress is not None:
441
+ self.progress.advance(total=total_windows)
442
+ completed_windows = 0
340
443
  while window_start <= to_date:
444
+ self._check_interrupted()
341
445
  window_end = min(to_date, window_start + timedelta(days=365))
342
446
  start, end = window_start.isoformat(), window_end.isoformat()
447
+ if self.progress is not None:
448
+ self.progress.advance(step=start)
343
449
  payload, raw_hash = self._capture("race_predictions", lambda start=start, end=end: self.api.get_race_predictions(start, end, "daily"), None, {"from": start, "to": end, "aggregation": "daily"})
344
450
  if payload is not None:
345
451
  for index, item in enumerate(as_items(payload)):
346
452
  item_day = payload_date(item) or start
347
453
  self.writer.source_entity("race_prediction", f"race_prediction:{item_day}:{index}", item, raw_hash, occurred_on=item_day)
454
+ completed_windows += 1
455
+ if self.progress is not None:
456
+ self.progress.advance(completed=completed_windows)
348
457
  window_start = window_end + timedelta(days=1)
349
458
 
350
459
  def _activities(self, from_date: date, to_date: date) -> None:
351
460
  start, end = from_date.isoformat(), to_date.isoformat()
461
+ if self.progress is not None:
462
+ self.progress.set_stage("activities")
352
463
  payload, raw_hash = self._capture("activities", lambda: self.api.get_activities_by_date(start, end), None, {"from": start, "to": end})
353
464
  if payload is None:
354
465
  return
355
466
  self._entity("activities", "activity", payload, raw_hash)
356
- for summary in as_items(payload):
467
+ summaries = as_items(payload)
468
+ if self.progress is not None:
469
+ self.progress.advance(total=len(summaries))
470
+ for index, summary in enumerate(summaries, start=1):
471
+ self._check_interrupted()
357
472
  activity_id = remote_id(summary, "unknown")
473
+ if self.progress is not None:
474
+ self.progress.advance(step=activity_id)
358
475
  summary_hash = hashlib.sha256(json.dumps(summary, sort_keys=True, separators=(",", ":"), default=str).encode()).hexdigest()
359
476
  details_fetched = self.known_activity_hashes.get(activity_id) != summary_hash
360
477
  if details_fetched:
361
478
  self._activity_details(activity_id, summary)
362
479
  self.writer.activity_sync_state(activity_id, summary_hash, details_fetched)
480
+ if self.progress is not None:
481
+ self.progress.advance(completed=index)
363
482
 
364
483
  def _multisport_children(self, parent_activity_id: str, activity: dict[str, Any]) -> None:
365
484
  if not activity.get("isMultiSportParent"):
@@ -444,7 +563,15 @@ class GarminStagingWorker:
444
563
  "workouts": ("get_workouts", (), "workout"), "scheduled_workouts": ("get_scheduled_workouts", (today.year, today.month), "scheduled_workout"),
445
564
  "goals": ("get_goals", (), "goal"), "golf_summary": ("get_golf_summary", (), "golf_scorecard"),
446
565
  }
447
- for endpoint, (method, arguments, entity_type) in calls.items():
566
+ if self.progress is not None:
567
+ self.progress.set_stage("collections")
568
+ self.progress.advance(total=len(calls))
569
+ for index, (endpoint, (method, arguments, entity_type)) in enumerate(calls.items(), start=1):
570
+ self._check_interrupted()
571
+ if self.progress is not None:
572
+ self.progress.advance(step=endpoint)
448
573
  payload, raw_hash = self._capture(endpoint, lambda method=method, arguments=arguments: getattr(self.api, method)(*arguments))
449
574
  if payload is not None:
450
575
  self._entity(endpoint, entity_type, payload, raw_hash)
576
+ if self.progress is not None:
577
+ self.progress.advance(completed=index)
@@ -0,0 +1,210 @@
1
+ // Discover OpenCode Go models and emit Catence Console profiles for them.
2
+ //
3
+ // OpenCode Go serves its catalog through three APIs: most models speak the
4
+ // OpenAI Chat Completions API, Grok and GPT-5.6 Luna speak the OpenAI
5
+ // Responses API, and MiniMax and Qwen speak the Anthropic Messages API. The
6
+ // public `GET /models` endpoint returns only model ids, not which API each
7
+ // model needs, so routing comes from the trusted table below and is intersected
8
+ // with the live list (the same "live discovery + trusted metadata" approach
9
+ // OpenClaw uses).
10
+ //
11
+ // Usage:
12
+ // node scripts/discover-opencode-go.mjs [--base-url <url>] [--write <config.json>] [--set-default]
13
+ //
14
+ // Flags:
15
+ // --base-url <url> Root of the OpenCode Go API (default https://opencode.ai/zen/go/v1).
16
+ // --write <path> Merge the two profiles into an existing config.json instead of printing to stdout.
17
+ // --set-default With --write, set console.defaultProfile to opencode-go even when one already exists.
18
+
19
+ import process from 'node:process';
20
+ import { readFile, writeFile } from 'node:fs/promises';
21
+
22
+ const DEFAULT_BASE_URL = 'https://opencode.ai/zen/go/v1';
23
+ const API_KEY_ENV = 'OPENCODE_GO_API_KEY';
24
+ const API_BASE_ENV = 'OPENCODE_GO_API_BASE';
25
+ const MESSAGES_BASE_ENV = 'OPENCODE_GO_MESSAGES_API_BASE';
26
+
27
+ // LiteLLM appends a fixed suffix to the profile base URL per provider, so the
28
+ // three APIs need different prefixes and two base URLs:
29
+ // openai/<id> -> {base}/chat/completions
30
+ // openai/responses/<id> -> {base}/responses
31
+ // anthropic/<id> -> {base minus /v1}/v1/messages
32
+ const RESPONSES_MODELS = new Set(['grok-4.5', 'gpt-5.6-luna']);
33
+ const MESSAGES_MODELS = new Set([
34
+ 'minimax-m2.5',
35
+ 'minimax-m2.7',
36
+ 'minimax-m3',
37
+ 'qwen3.5-plus',
38
+ 'qwen3.6-plus',
39
+ 'qwen3.7-plus',
40
+ 'qwen3.7-max',
41
+ 'qwen3.8-max',
42
+ ]);
43
+
44
+ const BRAND_NAMES = new Map([
45
+ ['deepseek', 'DeepSeek'],
46
+ ['glm', 'GLM'],
47
+ ['gpt', 'GPT'],
48
+ ['grok', 'Grok'],
49
+ ['hy3', 'Hy3'],
50
+ ['kimi', 'Kimi'],
51
+ ['mimo', 'MiMo'],
52
+ ['minimax', 'MiniMax'],
53
+ ['qwen', 'Qwen'],
54
+ ]);
55
+
56
+ function classify(id) {
57
+ if (RESPONSES_MODELS.has(id)) return { route: 'responses', guessed: false };
58
+ if (MESSAGES_MODELS.has(id)) return { route: 'messages', guessed: false };
59
+ if (/^(grok-|gpt-)/.test(id)) return { route: 'responses', guessed: true };
60
+ if (/^(minimax-|qwen)/.test(id)) return { route: 'messages', guessed: true };
61
+ return { route: 'chat', guessed: false };
62
+ }
63
+
64
+ function modelReference(id, route) {
65
+ if (route === 'responses') return `openai/responses/${id}`;
66
+ if (route === 'messages') return `anthropic/${id}`;
67
+ return `openai/${id}`;
68
+ }
69
+
70
+ function labelFor(id) {
71
+ return id
72
+ .split('-')
73
+ .map((segment) => BRAND_NAMES.get(segment) ?? segment.charAt(0).toUpperCase() + segment.slice(1))
74
+ .join(' ');
75
+ }
76
+
77
+ function usage() {
78
+ process.stderr.write(
79
+ 'Usage: node scripts/discover-opencode-go.mjs [--base-url <url>] [--write <config.json>] [--set-default]\n',
80
+ );
81
+ }
82
+
83
+ async function main() {
84
+ const args = process.argv.slice(2);
85
+ let baseUrl = DEFAULT_BASE_URL;
86
+ let writePath = null;
87
+ let setDefault = false;
88
+ for (let i = 0; i < args.length; i += 1) {
89
+ const arg = args[i];
90
+ if (arg === '--base-url') {
91
+ baseUrl = args[i + 1];
92
+ if (!baseUrl) throw new Error('--base-url requires a URL.');
93
+ i += 1;
94
+ } else if (arg === '--write') {
95
+ writePath = args[i + 1];
96
+ if (!writePath) throw new Error('--write requires a config.json path.');
97
+ i += 1;
98
+ } else if (arg === '--set-default') {
99
+ setDefault = true;
100
+ } else if (arg === '--help' || arg === '-h') {
101
+ usage();
102
+ return;
103
+ } else {
104
+ throw new Error(`Unknown argument: ${arg}`);
105
+ }
106
+ }
107
+
108
+ if (!baseUrl.endsWith('/v1')) {
109
+ process.stderr.write(`Warning: --base-url ${baseUrl} does not end in /v1; chat and responses URLs may be wrong.\n`);
110
+ }
111
+ const modelsUrl = `${baseUrl.replace(/\/$/, '')}/models`;
112
+ const messagesBase = baseUrl.replace(/\/v1\/?$/, '');
113
+
114
+ let response;
115
+ try {
116
+ response = await fetch(modelsUrl, { headers: { accept: 'application/json' } });
117
+ } catch (error) {
118
+ throw new Error(`Could not reach ${modelsUrl}: ${error.message}`);
119
+ }
120
+ if (!response.ok) {
121
+ throw new Error(`GET ${modelsUrl} returned HTTP ${response.status}.`);
122
+ }
123
+ const payload = await response.json();
124
+ const ids = (payload.data ?? []).map((entry) => entry.id).filter((id) => typeof id === 'string' && id);
125
+
126
+ const groups = { chat: [], responses: [], messages: [] };
127
+ const guessed = [];
128
+ for (const id of ids) {
129
+ const { route, guessed: wasGuessed } = classify(id);
130
+ if (!(route in groups)) continue;
131
+ groups[route].push(id);
132
+ if (wasGuessed) guessed.push(`${id} -> ${route}`);
133
+ }
134
+ for (const route of Object.keys(groups)) groups[route].sort();
135
+
136
+ const chatProfileModels = {};
137
+ for (const id of groups.chat) {
138
+ chatProfileModels[id] = { label: labelFor(id), model: modelReference(id, 'chat') };
139
+ }
140
+ for (const id of groups.responses) {
141
+ chatProfileModels[id] = { label: labelFor(id), model: modelReference(id, 'responses') };
142
+ }
143
+ const messagesProfileModels = {};
144
+ for (const id of groups.messages) {
145
+ messagesProfileModels[id] = { label: labelFor(id), model: modelReference(id, 'messages') };
146
+ }
147
+
148
+ const chatDefault = groups.chat[0] ?? groups.responses[0];
149
+ const profiles = {};
150
+ profiles['opencode-go'] = {
151
+ label: 'OpenCode Go',
152
+ ...(chatDefault ? { defaultModel: chatDefault } : {}),
153
+ models: chatProfileModels,
154
+ apiKeyEnv: API_KEY_ENV,
155
+ apiBaseEnv: API_BASE_ENV,
156
+ };
157
+ if (messagesProfileModels && groups.messages.length) {
158
+ profiles['opencode-go-messages'] = {
159
+ label: 'OpenCode Go (Anthropic)',
160
+ defaultModel: groups.messages[0],
161
+ models: messagesProfileModels,
162
+ apiKeyEnv: API_KEY_ENV,
163
+ apiBaseEnv: MESSAGES_BASE_ENV,
164
+ };
165
+ }
166
+
167
+ const consoleSection = { defaultProfile: 'opencode-go', profiles };
168
+
169
+ if (guessed.length) {
170
+ process.stderr.write('Routed by prefix heuristic (verify before use):\n');
171
+ for (const line of guessed) process.stderr.write(` ${line}\n`);
172
+ }
173
+ process.stderr.write(
174
+ `Discovered ${ids.length} models (${groups.chat.length} chat, ${groups.responses.length} responses, ${groups.messages.length} messages).\n`,
175
+ );
176
+
177
+ if (writePath) {
178
+ let root = {};
179
+ try {
180
+ root = JSON.parse(await readFile(writePath, 'utf8'));
181
+ } catch (error) {
182
+ if (error.code !== 'ENOENT') throw new Error(`Could not read ${writePath}: ${error.message}`);
183
+ }
184
+ if (typeof root !== 'object' || root === null || Array.isArray(root)) {
185
+ throw new Error(`${writePath} must contain a JSON object.`);
186
+ }
187
+ const existingConsole = typeof root.console === 'object' && root.console !== null ? root.console : {};
188
+ const mergedProfiles = { ...(existingConsole.profiles ?? {}), ...profiles };
189
+ const defaultProfile =
190
+ existingConsole.defaultProfile && !setDefault ? existingConsole.defaultProfile : 'opencode-go';
191
+ root.console = { ...existingConsole, defaultProfile, profiles: mergedProfiles };
192
+ await writeFile(writePath, `${JSON.stringify(root, null, 2)}\n`);
193
+ process.stderr.write(`Merged profiles into ${writePath} (defaultProfile: ${defaultProfile}).\n`);
194
+ return;
195
+ }
196
+
197
+ process.stderr.write(
198
+ `Export the key and base URLs, then add the JSON below under "console" in config.json:\n` +
199
+ ` export ${API_KEY_ENV}='sk-…'\n` +
200
+ ` export ${API_BASE_ENV}='${baseUrl}'\n` +
201
+ ` export ${MESSAGES_BASE_ENV}='${messagesBase}'\n` +
202
+ `(or rerun with --write ~/.catence/config.json to merge into an existing config.)\n`,
203
+ );
204
+ process.stdout.write(`${JSON.stringify(consoleSection, null, 2)}\n`);
205
+ }
206
+
207
+ await main().catch((error) => {
208
+ process.stderr.write(`Error: ${error.message}\n`);
209
+ process.exit(1);
210
+ });