catence 0.2.0-beta.2 → 0.2.0-beta.4
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 +24 -1
- package/dist/contracts/release.d.ts +1 -1
- package/dist/contracts/release.js +1 -1
- package/dist/core/runtime/catalog.js +15 -6
- package/dist/core/runtime/self-update.d.ts +44 -0
- package/dist/core/runtime/self-update.js +138 -0
- package/dist/interfaces/cli/commands.js +59 -1
- package/dist/runtime/index.d.ts +2 -0
- package/dist/runtime/index.js +1 -0
- package/docs/deployment.md +258 -0
- package/docs/llm-providers.md +76 -6
- package/package.json +3 -1
- package/scripts/discover-opencode-go.mjs +210 -0
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ Catence helps explore recovery, training load, trends, activity detail, swimming
|
|
|
22
22
|
|
|
23
23
|
## Quick start
|
|
24
24
|
|
|
25
|
-
Install Catence and create the first athlete. The default catalog home is `~/.catence`; set `CATENCE_HOME` or pass `--home` to use a different location.
|
|
25
|
+
Install Catence and create the first athlete. The default catalog home is `~/.catence`; set `CATENCE_HOME` or pass `--home` to use a different location. For the full local or Docker deployment walkthrough, see [deployment notes](docs/deployment.md).
|
|
26
26
|
|
|
27
27
|
```sh
|
|
28
28
|
npm install --global catence@0.2.0
|
|
@@ -93,11 +93,21 @@ catence-data --athlete alex retry --run <run-id>
|
|
|
93
93
|
catence-data --athlete alex auth strava --callback
|
|
94
94
|
catence-data --athlete alex disconnect strava
|
|
95
95
|
|
|
96
|
+
# Update the runtime and Console together on the tracked release channel
|
|
97
|
+
catence-data update --check
|
|
98
|
+
catence-data update
|
|
99
|
+
|
|
96
100
|
# Choose a non-default catalog location
|
|
97
101
|
catence-data --home /srv/catence setup --athlete alex --label "Alex"
|
|
98
102
|
catence --home /srv/catence
|
|
99
103
|
```
|
|
100
104
|
|
|
105
|
+
`catence-data update` follows the channel of the installed runtime: a beta
|
|
106
|
+
install tracks the npm `beta` tag and the matching PyPI prereleases, a stable
|
|
107
|
+
install tracks `latest`. Pass `--channel stable` to move off a beta. The command
|
|
108
|
+
replaces the global `catence` package and installs or upgrades
|
|
109
|
+
`catence-console` with `uv tool` when `uv` is available; `--check` only reports.
|
|
110
|
+
|
|
101
111
|
Register `http://127.0.0.1:8765/strava/callback` with the Strava application for the callback flow. The manual `auth strava --code <authorization-code>` flow remains available for headless environments.
|
|
102
112
|
|
|
103
113
|
## Safe demo and Glama
|
|
@@ -186,6 +196,19 @@ docker run --rm -it -v catence-data:/data --entrypoint catence-data catence-cons
|
|
|
186
196
|
|
|
187
197
|
Use the same `--entrypoint catence-data` pattern for `secret set` and `sync`. Route a Cloudflare Tunnel to `http://127.0.0.1:8000`; do not run a tunnel client in this image. Use HTTPS at the tunnel and retain the Console login—the dashboard is fetched through the authenticated Console origin rather than directly from port 8787.
|
|
188
198
|
|
|
199
|
+
[`scripts/deploy-console.sh`](scripts/deploy-console.sh) deploys this stack from
|
|
200
|
+
the public registries and seeds a starter `config.json` (the Console model
|
|
201
|
+
profiles from [`config.example.json`](config.example.json)) into the data
|
|
202
|
+
volume, so the settings panel works before the first chat. Edit
|
|
203
|
+
`/data/config.json` in the volume (or the bind-mounted `--home` directory) to
|
|
204
|
+
change models and defaults, or discover OpenCode Go profiles from the image:
|
|
205
|
+
|
|
206
|
+
```sh
|
|
207
|
+
docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env run --rm \
|
|
208
|
+
--entrypoint node console \
|
|
209
|
+
/usr/local/lib/node_modules/catence/scripts/discover-opencode-go.mjs --write /data/config.json
|
|
210
|
+
```
|
|
211
|
+
|
|
189
212
|
## Configuration
|
|
190
213
|
|
|
191
214
|
[`config.example.json`](config.example.json) documents rate limits, Strava budgets, and Console model profiles. It intentionally contains environment-variable names rather than credential values. Keep per-athlete provider values in `catence-data secret set`, not in `.env` or `config.json`.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Keep these values synchronized with release/manifest.json; release checks
|
|
4
4
|
* deliberately reject drift before a package can be published.
|
|
5
5
|
*/
|
|
6
|
-
export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.
|
|
6
|
+
export declare const CATENCE_RUNTIME_VERSION = "0.2.0-beta.4";
|
|
7
7
|
export declare const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export type CatenceRuntimeCapabilities = {
|
|
9
9
|
mcp: true;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Keep these values synchronized with release/manifest.json; release checks
|
|
4
4
|
* deliberately reject drift before a package can be published.
|
|
5
5
|
*/
|
|
6
|
-
export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.
|
|
6
|
+
export const CATENCE_RUNTIME_VERSION = '0.2.0-beta.4';
|
|
7
7
|
export const CATENCE_PROTOCOL_VERSION = 1;
|
|
8
8
|
export function catenceRuntimeHealth() {
|
|
9
9
|
return {
|
|
@@ -40,9 +40,8 @@ export function athleteStorePaths(catalogPaths, athleteId) {
|
|
|
40
40
|
function legacyStoreMessage(paths) {
|
|
41
41
|
return `Catence 0.2 uses a catalog at ${paths.root}. The existing directory looks like a 0.1 data store and cannot be migrated automatically. Create a fresh catalog and re-sync each athlete.`;
|
|
42
42
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
43
|
+
/** Home-directory entries the Console creates on its own and must not block catalog initialization. */
|
|
44
|
+
const CONSOLE_ARTIFACTS = new Set(['config.json', 'console']);
|
|
46
45
|
export async function loadCatalog(paths = resolveCatalogPaths()) {
|
|
47
46
|
const contents = await readFile(paths.catalog, 'utf8').catch((error) => {
|
|
48
47
|
if (error.code !== 'ENOENT')
|
|
@@ -68,9 +67,19 @@ async function writeCatalog(paths, catalog) {
|
|
|
68
67
|
export async function initializeCatalog(paths = resolveCatalogPaths(), athlete) {
|
|
69
68
|
if (existsSync(paths.catalog))
|
|
70
69
|
return loadCatalog(paths);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
let entries;
|
|
71
|
+
try {
|
|
72
|
+
entries = await readdir(paths.root);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (error.code === 'ENOENT')
|
|
76
|
+
entries = [];
|
|
77
|
+
else
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
if (entries.includes('catence.duckdb'))
|
|
81
|
+
throw new Error(legacyStoreMessage(paths));
|
|
82
|
+
if (entries.some((entry) => !CONSOLE_ARTIFACTS.has(entry))) {
|
|
74
83
|
throw new Error(`Refusing to initialize ${paths.root}: choose an empty --home directory or remove its unrelated contents.`);
|
|
75
84
|
}
|
|
76
85
|
const id = athleteIdSchema.parse(athlete.id);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type UpdateChannel = 'stable' | 'beta';
|
|
2
|
+
export type ParsedRelease = {
|
|
3
|
+
major: number;
|
|
4
|
+
minor: number;
|
|
5
|
+
patch: number;
|
|
6
|
+
/** Present only for prerelease builds; a stable release beats every beta of the same tuple. */
|
|
7
|
+
beta?: number;
|
|
8
|
+
};
|
|
9
|
+
export type SelfUpdatePlan = {
|
|
10
|
+
channel: UpdateChannel;
|
|
11
|
+
runtime: {
|
|
12
|
+
current: string;
|
|
13
|
+
target?: string;
|
|
14
|
+
updateAvailable: boolean;
|
|
15
|
+
};
|
|
16
|
+
console: {
|
|
17
|
+
installed?: string;
|
|
18
|
+
target?: string;
|
|
19
|
+
updateAvailable: boolean;
|
|
20
|
+
};
|
|
21
|
+
/** True when neither component has anything to apply. */
|
|
22
|
+
ok: boolean;
|
|
23
|
+
};
|
|
24
|
+
export declare function parseReleaseVersion(version: string): ParsedRelease | null;
|
|
25
|
+
export declare function compareReleaseVersions(left: ParsedRelease, right: ParsedRelease): -1 | 0 | 1;
|
|
26
|
+
export declare function channelForVersion(version: string): UpdateChannel;
|
|
27
|
+
export declare function latestRelease(versions: string[], channel: UpdateChannel): string | undefined;
|
|
28
|
+
export declare function planSelfUpdate(options: {
|
|
29
|
+
runtimeVersion: string;
|
|
30
|
+
channel: UpdateChannel;
|
|
31
|
+
npmDistTags: Record<string, string>;
|
|
32
|
+
pypiReleases: string[];
|
|
33
|
+
consoleInstalled?: string;
|
|
34
|
+
}): SelfUpdatePlan;
|
|
35
|
+
export declare function fetchNpmDistTags(packageName: string, fetchImpl?: typeof fetch): Promise<Record<string, string>>;
|
|
36
|
+
export declare function fetchPypiReleases(packageName: string, fetchImpl?: typeof fetch): Promise<string[]>;
|
|
37
|
+
/** Run an update command (npm or uv) with the user's terminal attached. */
|
|
38
|
+
export declare function runSelfUpdateCommand(command: string, args: string[]): Promise<number>;
|
|
39
|
+
export declare function hasCommand(command: string): Promise<boolean>;
|
|
40
|
+
/** Read `catence-console --version` from PATH; undefined when the Console is not installed. */
|
|
41
|
+
export declare function readInstalledConsoleVersion(): Promise<string | undefined>;
|
|
42
|
+
/** Global npm package root, used to decide whether `catence` can update itself in place. */
|
|
43
|
+
export declare function npmGlobalPrefix(): Promise<string | undefined>;
|
|
44
|
+
export declare function isGlobalNpmInstall(moduleUrl: string, npmPrefix: string | undefined): boolean;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-update support for the Catence runtime and its Console.
|
|
3
|
+
*
|
|
4
|
+
* The runtime tracks npm dist-tags on the `catence` package; the Console
|
|
5
|
+
* tracks PEP 440 releases on PyPI (`catence-console`). A release is "newer"
|
|
6
|
+
* only when it strictly dominates the installed version inside the tracked
|
|
7
|
+
* channel: `stable` for non-prerelease installs, or the beta prerelease
|
|
8
|
+
* channel for beta installs. Channel names match npm dist-tags, so a beta
|
|
9
|
+
* runtime consults the `beta` tag and an explicit `--channel stable` moves a
|
|
10
|
+
* beta install onto the `latest` tag.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
const NPM_VERSION = /^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/;
|
|
15
|
+
const PEP440_BETA = /^(\d+)\.(\d+)\.(\d+)b(\d+)$/;
|
|
16
|
+
export function parseReleaseVersion(version) {
|
|
17
|
+
const npm = NPM_VERSION.exec(version.trim());
|
|
18
|
+
if (npm) {
|
|
19
|
+
return {
|
|
20
|
+
major: Number(npm[1]),
|
|
21
|
+
minor: Number(npm[2]),
|
|
22
|
+
patch: Number(npm[3]),
|
|
23
|
+
beta: npm[4] === undefined ? undefined : Number(npm[4]),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const pep440 = PEP440_BETA.exec(version.trim());
|
|
27
|
+
if (pep440) {
|
|
28
|
+
return { major: Number(pep440[1]), minor: Number(pep440[2]), patch: Number(pep440[3]), beta: Number(pep440[4]) };
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
export function compareReleaseVersions(left, right) {
|
|
33
|
+
for (const field of ['major', 'minor', 'patch']) {
|
|
34
|
+
if (left[field] !== right[field])
|
|
35
|
+
return left[field] < right[field] ? -1 : 1;
|
|
36
|
+
}
|
|
37
|
+
const leftBeta = left.beta ?? Number.POSITIVE_INFINITY;
|
|
38
|
+
const rightBeta = right.beta ?? Number.POSITIVE_INFINITY;
|
|
39
|
+
if (leftBeta === rightBeta)
|
|
40
|
+
return 0;
|
|
41
|
+
return leftBeta < rightBeta ? -1 : 1;
|
|
42
|
+
}
|
|
43
|
+
export function channelForVersion(version) {
|
|
44
|
+
return parseReleaseVersion(version)?.beta !== undefined ? 'beta' : 'stable';
|
|
45
|
+
}
|
|
46
|
+
export function latestRelease(versions, channel) {
|
|
47
|
+
let best;
|
|
48
|
+
for (const version of versions) {
|
|
49
|
+
const parsed = parseReleaseVersion(version);
|
|
50
|
+
if (!parsed)
|
|
51
|
+
continue;
|
|
52
|
+
const isBeta = parsed.beta !== undefined;
|
|
53
|
+
if (channel === 'beta' ? !isBeta : isBeta)
|
|
54
|
+
continue;
|
|
55
|
+
if (!best || compareReleaseVersions(parsed, best.parsed) > 0)
|
|
56
|
+
best = { version, parsed };
|
|
57
|
+
}
|
|
58
|
+
return best?.version;
|
|
59
|
+
}
|
|
60
|
+
export function planSelfUpdate(options) {
|
|
61
|
+
const distTag = options.channel === 'beta' ? 'beta' : 'latest';
|
|
62
|
+
const runtimeTarget = options.npmDistTags[distTag];
|
|
63
|
+
const consoleTarget = latestRelease(options.pypiReleases, options.channel);
|
|
64
|
+
const runtimeUpdateAvailable = isNewer(runtimeTarget, parseReleaseVersion(options.runtimeVersion));
|
|
65
|
+
const consoleUpdateAvailable = options.consoleInstalled
|
|
66
|
+
? isNewer(consoleTarget, parseReleaseVersion(options.consoleInstalled))
|
|
67
|
+
: consoleTarget !== undefined;
|
|
68
|
+
return {
|
|
69
|
+
channel: options.channel,
|
|
70
|
+
runtime: { current: options.runtimeVersion, target: runtimeTarget, updateAvailable: runtimeUpdateAvailable },
|
|
71
|
+
console: { installed: options.consoleInstalled, target: consoleTarget, updateAvailable: consoleUpdateAvailable },
|
|
72
|
+
ok: !runtimeUpdateAvailable && !consoleUpdateAvailable,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function isNewer(target, current) {
|
|
76
|
+
if (!target || !current)
|
|
77
|
+
return false;
|
|
78
|
+
const parsed = parseReleaseVersion(target);
|
|
79
|
+
return parsed !== null && compareReleaseVersions(parsed, current) > 0;
|
|
80
|
+
}
|
|
81
|
+
export async function fetchNpmDistTags(packageName, fetchImpl = fetch) {
|
|
82
|
+
const response = await fetchImpl(`https://registry.npmjs.org/${packageName}`);
|
|
83
|
+
if (!response.ok)
|
|
84
|
+
throw new Error(`npm registry returned HTTP ${response.status} for ${packageName}.`);
|
|
85
|
+
const payload = (await response.json());
|
|
86
|
+
return payload['dist-tags'] ?? {};
|
|
87
|
+
}
|
|
88
|
+
export async function fetchPypiReleases(packageName, fetchImpl = fetch) {
|
|
89
|
+
const response = await fetchImpl(`https://pypi.org/pypi/${packageName}/json`);
|
|
90
|
+
if (!response.ok)
|
|
91
|
+
throw new Error(`PyPI returned HTTP ${response.status} for ${packageName}.`);
|
|
92
|
+
const payload = (await response.json());
|
|
93
|
+
return Object.keys(payload.releases ?? {});
|
|
94
|
+
}
|
|
95
|
+
/** Run an update command (npm or uv) with the user's terminal attached. */
|
|
96
|
+
export function runSelfUpdateCommand(command, args) {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const child = spawn(command, args, { stdio: 'inherit' });
|
|
99
|
+
child.once('error', reject);
|
|
100
|
+
child.once('close', (code) => resolve(code ?? 1));
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
export function hasCommand(command) {
|
|
104
|
+
return new Promise((resolve) => {
|
|
105
|
+
const child = spawn(command, ['--version'], { stdio: 'ignore' });
|
|
106
|
+
child.once('error', () => resolve(false));
|
|
107
|
+
child.once('close', (code) => resolve(code === 0));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/** Read `catence-console --version` from PATH; undefined when the Console is not installed. */
|
|
111
|
+
export function readInstalledConsoleVersion() {
|
|
112
|
+
return new Promise((resolve) => {
|
|
113
|
+
const child = spawn('catence-console', ['--version'], { stdio: ['ignore', 'pipe', 'inherit'] });
|
|
114
|
+
let stdout = '';
|
|
115
|
+
child.stdout?.on('data', (chunk) => (stdout += chunk.toString('utf8')));
|
|
116
|
+
child.once('error', () => resolve(undefined));
|
|
117
|
+
child.once('close', (code) => {
|
|
118
|
+
const match = /^catence-console (\S+)/.exec(stdout.trim());
|
|
119
|
+
resolve(code === 0 && match ? match[1] : undefined);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/** Global npm package root, used to decide whether `catence` can update itself in place. */
|
|
124
|
+
export function npmGlobalPrefix() {
|
|
125
|
+
return new Promise((resolve) => {
|
|
126
|
+
const child = spawn('npm', ['prefix', '-g'], { stdio: ['ignore', 'pipe', 'inherit'] });
|
|
127
|
+
let stdout = '';
|
|
128
|
+
child.stdout?.on('data', (chunk) => (stdout += chunk.toString('utf8')));
|
|
129
|
+
child.once('error', () => resolve(undefined));
|
|
130
|
+
child.once('close', (code) => resolve(code === 0 ? stdout.trim() : undefined));
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
export function isGlobalNpmInstall(moduleUrl, npmPrefix) {
|
|
134
|
+
if (!npmPrefix)
|
|
135
|
+
return false;
|
|
136
|
+
return fileURLToPath(moduleUrl).startsWith(`${npmPrefix}/lib/node_modules/`);
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=self-update.js.map
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { createInterface } from 'node:readline/promises';
|
|
3
3
|
import { stdin as input, stdout as output } from 'node:process';
|
|
4
4
|
import { Command } from 'commander';
|
|
5
|
-
import { addAthlete, athleteStorePaths, createDemoStore, dataStatus, defaultCatalogHome, disconnectStravaAccount, initializeCatalog, linkActivity, loadCatalog, rebuildRetrievalIndex, resolveAthlete, resolveCatalogPaths, retryDataSync, setAthleteSecret, syncData, connectStrava, connectStravaWithCallback, unlinkActivity, } from '../../runtime/index.js';
|
|
5
|
+
import { addAthlete, athleteStorePaths, CATENCE_RUNTIME_VERSION, channelForVersion, createDemoStore, dataStatus, defaultCatalogHome, disconnectStravaAccount, fetchNpmDistTags, fetchPypiReleases, hasCommand, initializeCatalog, isGlobalNpmInstall, linkActivity, loadCatalog, npmGlobalPrefix, planSelfUpdate, readInstalledConsoleVersion, rebuildRetrievalIndex, resolveAthlete, resolveCatalogPaths, retryDataSync, runSelfUpdateCommand, setAthleteSecret, syncData, connectStrava, connectStravaWithCallback, unlinkActivity, } from '../../runtime/index.js';
|
|
6
6
|
const program = new Command()
|
|
7
7
|
.name('catence-data')
|
|
8
8
|
.description('Athlete-scoped Catence data setup, extraction, and normalization.')
|
|
@@ -158,6 +158,64 @@ program.command('status').action(async () => {
|
|
|
158
158
|
const { paths } = await currentAthlete();
|
|
159
159
|
process.stdout.write(`${JSON.stringify(await dataStatus(paths), null, 2)}\n`);
|
|
160
160
|
});
|
|
161
|
+
program.command('update')
|
|
162
|
+
.description('Check for and apply the newest Catence release on the tracked channel (runtime and Console).')
|
|
163
|
+
.option('--check', 'report available releases without changing anything')
|
|
164
|
+
.option('--channel <stable|beta>', 'release channel to track; defaults to the installed runtime channel')
|
|
165
|
+
.option('--runtime', 'only check or update the Catence runtime package')
|
|
166
|
+
.option('--console', 'only check or update the Catence Console package')
|
|
167
|
+
.action(async (options) => {
|
|
168
|
+
if (options.channel !== undefined && options.channel !== 'stable' && options.channel !== 'beta') {
|
|
169
|
+
throw new Error('--channel must be stable or beta.');
|
|
170
|
+
}
|
|
171
|
+
const channel = options.channel ?? channelForVersion(CATENCE_RUNTIME_VERSION);
|
|
172
|
+
const runtimeOnly = options.runtime ?? false;
|
|
173
|
+
const consoleOnly = options.console ?? false;
|
|
174
|
+
const updateRuntime = runtimeOnly || !consoleOnly;
|
|
175
|
+
const updateConsole = consoleOnly || !runtimeOnly;
|
|
176
|
+
const [npmDistTags, pypiReleases, consoleInstalled] = await Promise.all([
|
|
177
|
+
fetchNpmDistTags('catence'),
|
|
178
|
+
fetchPypiReleases('catence-console'),
|
|
179
|
+
readInstalledConsoleVersion(),
|
|
180
|
+
]);
|
|
181
|
+
const plan = planSelfUpdate({ runtimeVersion: CATENCE_RUNTIME_VERSION, channel, npmDistTags, pypiReleases, consoleInstalled });
|
|
182
|
+
const report = { channel, runtime: plan.runtime, console: plan.console };
|
|
183
|
+
if (options.check) {
|
|
184
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
185
|
+
process.exit(plan.ok ? 0 : 1);
|
|
186
|
+
}
|
|
187
|
+
const applied = [];
|
|
188
|
+
if (plan.runtime.updateAvailable && updateRuntime) {
|
|
189
|
+
const runtimeTarget = plan.runtime.target;
|
|
190
|
+
if (runtimeTarget === undefined)
|
|
191
|
+
throw new Error('No runtime update target is available.');
|
|
192
|
+
const npmPrefix = await npmGlobalPrefix();
|
|
193
|
+
if (isGlobalNpmInstall(import.meta.url, npmPrefix)) {
|
|
194
|
+
const exitCode = await runSelfUpdateCommand('npm', ['install', '--global', `catence@${runtimeTarget}`]);
|
|
195
|
+
if (exitCode !== 0)
|
|
196
|
+
throw new Error(`npm failed to install catence@${runtimeTarget} (exit ${exitCode}).`);
|
|
197
|
+
applied.push(`runtime: catence@${runtimeTarget}`);
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
process.stderr.write(`Runtime is not a global npm install; run: npm install --global catence@${runtimeTarget}\n`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (plan.console.updateAvailable && updateConsole) {
|
|
204
|
+
const consoleTarget = plan.console.target;
|
|
205
|
+
if (consoleTarget === undefined)
|
|
206
|
+
throw new Error('No Console update target is available.');
|
|
207
|
+
if (await hasCommand('uv')) {
|
|
208
|
+
const exitCode = await runSelfUpdateCommand('uv', ['tool', 'install', '--upgrade', `catence-console==${consoleTarget}`]);
|
|
209
|
+
if (exitCode !== 0)
|
|
210
|
+
throw new Error(`uv failed to install catence-console==${consoleTarget} (exit ${exitCode}).`);
|
|
211
|
+
applied.push(`console: catence-console==${consoleTarget}`);
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
process.stderr.write(`uv is not installed; run: pip install --upgrade 'catence-console==${consoleTarget}'\n`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
process.stdout.write(`${JSON.stringify({ ...report, applied }, null, 2)}\n`);
|
|
218
|
+
});
|
|
161
219
|
program.command('build-retrieval-index').description('Build derived local retrieval context for one athlete.').action(async () => {
|
|
162
220
|
const { paths } = await currentAthlete();
|
|
163
221
|
process.stdout.write(`${JSON.stringify(await rebuildRetrievalIndex(paths), null, 2)}\n`);
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* without changing the public product adapters.
|
|
8
8
|
*/
|
|
9
9
|
export type { CatencePaths } from '../contracts/runtime.js';
|
|
10
|
+
export { channelForVersion, compareReleaseVersions, fetchNpmDistTags, fetchPypiReleases, hasCommand, isGlobalNpmInstall, latestRelease, npmGlobalPrefix, parseReleaseVersion, planSelfUpdate, readInstalledConsoleVersion, runSelfUpdateCommand, } from '../core/runtime/self-update.js';
|
|
11
|
+
export type { SelfUpdatePlan, UpdateChannel } from '../core/runtime/self-update.js';
|
|
10
12
|
export { CATENCE_PROTOCOL_VERSION, CATENCE_RUNTIME_VERSION, catenceRuntimeHealth } from '../contracts/release.js';
|
|
11
13
|
export { configuredMcpRateLimit, loadCatenceConfig, resolvePaths } from '../core/runtime/configuration.js';
|
|
12
14
|
export { addAthlete, athleteStorePaths, defaultAthlete, defaultCatalogHome, initializeCatalog, loadCatalog, resolveAthlete, resolveCatalogPaths, } from '../core/runtime/catalog.js';
|
package/dist/runtime/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { channelForVersion, compareReleaseVersions, fetchNpmDistTags, fetchPypiReleases, hasCommand, isGlobalNpmInstall, latestRelease, npmGlobalPrefix, parseReleaseVersion, planSelfUpdate, readInstalledConsoleVersion, runSelfUpdateCommand, } from '../core/runtime/self-update.js';
|
|
1
2
|
export { CATENCE_PROTOCOL_VERSION, CATENCE_RUNTIME_VERSION, catenceRuntimeHealth } from '../contracts/release.js';
|
|
2
3
|
export { configuredMcpRateLimit, loadCatenceConfig, resolvePaths } from '../core/runtime/configuration.js';
|
|
3
4
|
export { addAthlete, athleteStorePaths, defaultAthlete, defaultCatalogHome, initializeCatalog, loadCatalog, resolveAthlete, resolveCatalogPaths, } from '../core/runtime/catalog.js';
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# Deploying Catence Console
|
|
2
|
+
|
|
3
|
+
Catence Console is a password-protected local web chat that starts a matching
|
|
4
|
+
Catence runtime on loopback. There are two supported ways to run it: **locally**
|
|
5
|
+
(npm package + Python Console on your machine) or **in Docker** (one container
|
|
6
|
+
built from the public registries). Both paths end with the same Console, data
|
|
7
|
+
directory layout, model configuration, and credentials model.
|
|
8
|
+
|
|
9
|
+
Everything below assumes a recent beta or stable release; channels are
|
|
10
|
+
explained in the [beta release notes](../README.md#beta-releases).
|
|
11
|
+
|
|
12
|
+
## Prerequisites
|
|
13
|
+
|
|
14
|
+
- Node.js 22+ and Python 3.12+ with [uv](https://docs.astral.sh/uv/)
|
|
15
|
+
(local path), or Docker Engine with the `docker compose` plugin (Docker path).
|
|
16
|
+
- Provider credentials for the athletes you choose to sync (Garmin,
|
|
17
|
+
Intervals.icu, Strava).
|
|
18
|
+
- One model-provider key (OpenAI, Anthropic, OpenCode Go, Azure, or any
|
|
19
|
+
OpenAI-compatible endpoint).
|
|
20
|
+
|
|
21
|
+
## What gets stored where
|
|
22
|
+
|
|
23
|
+
| What | Where | Example |
|
|
24
|
+
| --- | --- | --- |
|
|
25
|
+
| Catalog + athlete stores | data home | `~/.catence/` or `/data` in Docker |
|
|
26
|
+
| Model profiles (no secrets) | `config.json` in the data home | `console.profiles` |
|
|
27
|
+
| Provider credentials | per-athlete secret file, mode 0600 | `catence-data secret set` |
|
|
28
|
+
| API keys | process environment only | `.env` in Docker, shell exports locally |
|
|
29
|
+
| Chat history + preferences | data home | `console/chat-history.sqlite3` |
|
|
30
|
+
|
|
31
|
+
Credentials never land in `config.json`; profiles reference environment
|
|
32
|
+
variable *names* (`apiKeyEnv`, `apiBaseEnv`, `apiVersionEnv`). See
|
|
33
|
+
[`llm-providers.md`](llm-providers.md) for the profile reference and
|
|
34
|
+
[`config.example.json`](../config.example.json) for a complete example.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Path A — Local deployment
|
|
39
|
+
|
|
40
|
+
### 1. Install the runtime and the Console
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
npm install --global catence@beta # or catence@latest for stable
|
|
44
|
+
uv tool install catence-console # persistent `catence-console` command
|
|
45
|
+
# alternative to the last line: uvx catence-console@<version> serve (ephemeral)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`catence-console serve` launches a matching Catence runtime automatically: it
|
|
49
|
+
uses a globally installed `catence` if present, otherwise `npx catence@<pinned>`.
|
|
50
|
+
|
|
51
|
+
### 2. Create the catalog and first athlete
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
catence-data setup --athlete alex --label "Alex"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
This creates `~/.catence/` (or `$CATENCE_HOME`). It also tolerates a directory
|
|
58
|
+
that already holds only Console artifacts (`config.json`, `console/`).
|
|
59
|
+
|
|
60
|
+
### 3. Store provider credentials (stdin only, never shell history)
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
printf %s 'alex@example.com' | catence-data --athlete alex secret set --provider garmin --field email --value-stdin
|
|
64
|
+
printf %s 'your-garmin-password' | catence-data --athlete alex secret set --provider garmin --field password --value-stdin
|
|
65
|
+
printf %s 'intervals-api-key' | catence-data --athlete alex secret set --provider intervals --field apiKey --value-stdin
|
|
66
|
+
printf %s '12345' | catence-data --athlete alex secret set --provider intervals --field athleteId --value-stdin
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### 4. Sync data and build retrieval context
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
catence-data --athlete alex sync --provider all
|
|
73
|
+
catence-data --athlete alex build-retrieval-index
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 5. Configure the model
|
|
77
|
+
|
|
78
|
+
On the first chat, the Console wizard asks for a provider and model and writes
|
|
79
|
+
a starter profile. To start from the documented profiles instead, copy the
|
|
80
|
+
`console` section from [`config.example.json`](../config.example.json) into
|
|
81
|
+
`~/.catence/config.json`. To add OpenCode Go models, run the
|
|
82
|
+
[model discovery script](#model-discovery-opencode-go) below.
|
|
83
|
+
|
|
84
|
+
### 6. Set the model credentials and Console login
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
export OPENAI_API_KEY='…' # or ANTHROPIC_API_KEY / OPENCODE_GO_API_KEY …
|
|
88
|
+
export CATENCE_CONSOLE_USERNAME='coach'
|
|
89
|
+
export CATENCE_CONSOLE_PASSWORD_HASH="$(catence-console auth hash-password)"
|
|
90
|
+
export CHAINLIT_AUTH_SECRET="$(openssl rand -hex 32)"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 7. Start the Console
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
catence-console serve
|
|
97
|
+
# open http://127.0.0.1:8000
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The Model dropdown in the settings panel lists every profile's deployments.
|
|
101
|
+
Preflight with:
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
catence-console doctor
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Path B — Docker deployment
|
|
110
|
+
|
|
111
|
+
### 1. Deploy with the script
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
curl -fsSL https://raw.githubusercontent.com/rifusaki/catence/beta/scripts/deploy-console.sh | bash -s beta
|
|
115
|
+
# or from a checkout: ./scripts/deploy-console.sh beta
|
|
116
|
+
# use `stable` instead of `beta` for the stable channel
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The script resolves the newest matching versions from npm and PyPI, writes a
|
|
120
|
+
self-contained `catence-deploy/` project (Dockerfile, docker-compose.yml, and
|
|
121
|
+
`.env` with placeholders), builds the image, **seeds a starter
|
|
122
|
+
`/data/config.json`** into the data volume, and starts the stack — or, when
|
|
123
|
+
secrets are missing, prints the remaining steps and exits.
|
|
124
|
+
|
|
125
|
+
### 2. Fill in `catence-deploy/.env`
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
CATENCE_CONSOLE_USERNAME=coach
|
|
129
|
+
CATENCE_CONSOLE_PASSWORD_HASH='…' # generate: docker compose ... run --rm --entrypoint /opt/catence-console/bin/catence-console console auth hash-password
|
|
130
|
+
CHAINLIT_AUTH_SECRET='…' # generate: openssl rand -hex 32
|
|
131
|
+
OPENAI_API_KEY='…' # or ANTHROPIC_API_KEY / OPENCODE_GO_API_KEY …
|
|
132
|
+
OPENAI_API_BASE='' # only for OpenAI-compatible endpoints
|
|
133
|
+
OPENCODE_GO_API_KEY='…' # the OpenCode Go base URLs are pre-filled below
|
|
134
|
+
OPENCODE_GO_API_BASE='https://opencode.ai/zen/go/v1'
|
|
135
|
+
OPENCODE_GO_MESSAGES_API_BASE='https://opencode.ai/zen/go'
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Then start the stack (or re-run the script, which re-reads the saved `.env`):
|
|
139
|
+
|
|
140
|
+
```sh
|
|
141
|
+
docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env up -d
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### 3. Initialize an athlete store
|
|
145
|
+
|
|
146
|
+
The data volume already contains a seeded `config.json`, so `setup` accepts
|
|
147
|
+
it. Run through the container:
|
|
148
|
+
|
|
149
|
+
```sh
|
|
150
|
+
docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env run --rm \
|
|
151
|
+
--entrypoint catence-data console setup --athlete alex --label "Alex"
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### 4. Store provider credentials
|
|
155
|
+
|
|
156
|
+
```sh
|
|
157
|
+
printf %s 'value' | docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env run --rm -T \
|
|
158
|
+
--entrypoint catence-data console --athlete alex secret set \
|
|
159
|
+
--provider intervals --field apiKey --value-stdin
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Use the same `--entrypoint catence-data` pattern for `sync`, `backfill`, and
|
|
163
|
+
`auth strava`.
|
|
164
|
+
|
|
165
|
+
### 5. Configure the model
|
|
166
|
+
|
|
167
|
+
The seeded `config.json` already offers OpenAI, OpenAI-compatible, and
|
|
168
|
+
Anthropic starter profiles, so the settings panel works before the first chat.
|
|
169
|
+
Edit `/data/config.json` inside the volume:
|
|
170
|
+
|
|
171
|
+
```sh
|
|
172
|
+
docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env run --rm \
|
|
173
|
+
--entrypoint sh console -c 'cat > /data/config.json' # paste JSON, then Ctrl-D
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
With `--home <dir>` (bind mount), edit `<dir>/config.json` on the host
|
|
177
|
+
instead. To add OpenCode Go models, see [model discovery](#model-discovery-opencode-go).
|
|
178
|
+
|
|
179
|
+
### 6. Expose the Console safely
|
|
180
|
+
|
|
181
|
+
Keep the Console port bound to `127.0.0.1` (the default) and front it with a
|
|
182
|
+
Cloudflare Tunnel or reverse proxy at HTTPS. Never expose Catence's MCP port
|
|
183
|
+
8787 — it stays loopback-only inside the container. For remote MCP checks use
|
|
184
|
+
SSH port forwarding (`ssh -L 8787:127.0.0.1:8787 server`).
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Model discovery (OpenCode Go)
|
|
189
|
+
|
|
190
|
+
OpenCode Go publishes an OpenAI-compatible API at
|
|
191
|
+
`https://opencode.ai/zen/go/v1`; its model list is public. The discovery
|
|
192
|
+
script fetches it and merges two ready-made profiles into `config.json`:
|
|
193
|
+
`opencode-go` (chat + responses models, base `…/zen/go/v1`) and
|
|
194
|
+
`opencode-go-messages` (messages models, base `…/zen/go`). Existing profiles,
|
|
195
|
+
limits, and `defaultProfile` are preserved unless `--set-default` is passed.
|
|
196
|
+
|
|
197
|
+
```sh
|
|
198
|
+
# Local (from a checkout)
|
|
199
|
+
npm run discover:opencode-go -- --write ~/.catence/config.json
|
|
200
|
+
|
|
201
|
+
# Docker (the script ships inside the npm package in the image)
|
|
202
|
+
docker compose -f catence-deploy/docker-compose.yml --env-file catence-deploy/.env run --rm \
|
|
203
|
+
--entrypoint node console \
|
|
204
|
+
/usr/local/lib/node_modules/catence/scripts/discover-opencode-go.mjs --write /data/config.json
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Then set the credentials and verify. The generated `.env` already contains
|
|
208
|
+
`OPENCODE_GO_API_BASE` and `OPENCODE_GO_MESSAGES_API_BASE`, so on Docker only
|
|
209
|
+
the key is missing:
|
|
210
|
+
|
|
211
|
+
```sh
|
|
212
|
+
export OPENCODE_GO_API_KEY='…' # any non-empty value passes the key check
|
|
213
|
+
catence-console doctor
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
The `openai/responses/…` models (for example `grok-4.5`) go through LiteLLM's
|
|
217
|
+
responses bridge and are the least battle-tested path; smoke-test one chat turn
|
|
218
|
+
before relying on them.
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Updating
|
|
223
|
+
|
|
224
|
+
From `catence-data update`-capable releases (0.2.0-beta.3 and later), the
|
|
225
|
+
runtime updates both components on the tracked channel — beta installs follow
|
|
226
|
+
the npm `beta` tag and matching PyPI prereleases:
|
|
227
|
+
|
|
228
|
+
```sh
|
|
229
|
+
catence-data update --check # report only; exit 1 when updates are pending
|
|
230
|
+
catence-data update # npm runtime + uv tool catence-console upgrade
|
|
231
|
+
catence-data update --channel stable # move off a beta onto the stable channel
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Older betas (for example a beta 2 install) predate the command; upgrade them
|
|
235
|
+
manually once:
|
|
236
|
+
|
|
237
|
+
```sh
|
|
238
|
+
npm install --global catence@beta
|
|
239
|
+
uv tool install --upgrade catence-console
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Docker deployments: re-run `deploy-console.sh beta` (or `stable`); it
|
|
243
|
+
re-resolves the newest versions from the registries and rebuilds. The data
|
|
244
|
+
volume and your `config.json` are preserved.
|
|
245
|
+
|
|
246
|
+
## Troubleshooting
|
|
247
|
+
|
|
248
|
+
- **"No Catence config exists"** — should not happen after a seeded deploy or
|
|
249
|
+
wizard run; ensure `/data/config.json` (Docker) or `~/.catence/config.json`
|
|
250
|
+
exists and contains a `console` section.
|
|
251
|
+
- **"Refusing to initialize"** — the data home contains unrelated files;
|
|
252
|
+
`config.json` and `console/` are allowed, anything else blocks `setup`.
|
|
253
|
+
- **Settings panel has no Model dropdown** — the Console loads profiles from
|
|
254
|
+
`config.json`; write one via the wizard, a manual copy, or the discovery
|
|
255
|
+
script.
|
|
256
|
+
- **Profile not ready** — `catence-console doctor` lists the missing
|
|
257
|
+
environment variables; set them in `.env` (Docker) or the shell (local) and
|
|
258
|
+
restart the Console.
|
package/docs/llm-providers.md
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
Catence Console talks to a model through [LiteLLM](https://docs.litellm.ai/).
|
|
4
4
|
There are exactly three provider shapes: **OpenAI**, **Anthropic**, and a
|
|
5
|
-
generic **OpenAI-compatible** profile. Azure
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
generic **OpenAI-compatible** profile. Azure is not a special case in the code:
|
|
6
|
+
it is reached through the OpenAI-compatible profile. OpenCode Go can be reached
|
|
7
|
+
the same way through a local gateway, or directly at its own OpenAI-compatible
|
|
8
|
+
endpoint with native `openai/…` and `anthropic/…` model names; the steps for
|
|
9
|
+
each are documented below.
|
|
8
10
|
|
|
9
11
|
## How configuration is split
|
|
10
12
|
|
|
@@ -166,9 +168,77 @@ Notes:
|
|
|
166
168
|
|
|
167
169
|
### Opencode
|
|
168
170
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
171
|
+
[OpenCode Go](https://opencode.ai) publishes an OpenAI-compatible API at
|
|
172
|
+
`https://opencode.ai/zen/go/v1`; its `GET /v1/models` listing is public and does
|
|
173
|
+
not require a key. The Console can use it directly, so most setups do not need a
|
|
174
|
+
local gateway.
|
|
175
|
+
|
|
176
|
+
The repository includes a discovery script that fetches the live model list and
|
|
177
|
+
merges ready-made Console profiles into `config.json`. From a Catence checkout:
|
|
178
|
+
|
|
179
|
+
```sh
|
|
180
|
+
npm run discover:opencode-go -- --write ~/.catence/config.json
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The script adds two profiles, because OpenCode Go routes model families through
|
|
184
|
+
different API shapes:
|
|
185
|
+
|
|
186
|
+
- `opencode-go` — chat and responses models, base
|
|
187
|
+
`https://opencode.ai/zen/go/v1` (LiteLLM `openai/…` names).
|
|
188
|
+
- `opencode-go-messages` — messages models, base `https://opencode.ai/zen/go`
|
|
189
|
+
(LiteLLM `anthropic/…` names).
|
|
190
|
+
|
|
191
|
+
| Family | Profile prefix | Example |
|
|
192
|
+
| --- | --- | --- |
|
|
193
|
+
| Chat | `openai/<id>` | `openai/deepseek-v4-flash` |
|
|
194
|
+
| Responses | `openai/responses/<id>` | `openai/responses/grok-4.5` |
|
|
195
|
+
| Messages | `anthropic/<id>` | `anthropic/minimax-m2.5` |
|
|
196
|
+
|
|
197
|
+
The script warns on stderr and skips any model that does not fit one of these
|
|
198
|
+
families. Rerun it to refresh the model lists; existing profiles, limits, and
|
|
199
|
+
`defaultProfile` are preserved unless `--set-default` is passed.
|
|
200
|
+
|
|
201
|
+
```sh
|
|
202
|
+
export OPENCODE_GO_API_KEY='…' # any non-empty value satisfies the key check
|
|
203
|
+
export OPENCODE_GO_API_BASE='https://opencode.ai/zen/go/v1'
|
|
204
|
+
export OPENCODE_GO_MESSAGES_API_BASE='https://opencode.ai/zen/go'
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
```jsonc
|
|
208
|
+
{
|
|
209
|
+
"console": {
|
|
210
|
+
"defaultProfile": "opencode-go",
|
|
211
|
+
"profiles": {
|
|
212
|
+
"opencode-go": {
|
|
213
|
+
"label": "OpenCode Go",
|
|
214
|
+
"defaultModel": "flash",
|
|
215
|
+
"models": {
|
|
216
|
+
"flash": { "label": "DeepSeek V4 flash", "model": "openai/deepseek-v4-flash" },
|
|
217
|
+
"grok": { "label": "Grok 4.5", "model": "openai/responses/grok-4.5" }
|
|
218
|
+
},
|
|
219
|
+
"apiKeyEnv": "OPENCODE_GO_API_KEY",
|
|
220
|
+
"apiBaseEnv": "OPENCODE_GO_API_BASE"
|
|
221
|
+
},
|
|
222
|
+
"opencode-go-messages": {
|
|
223
|
+
"label": "OpenCode Go messages",
|
|
224
|
+
"defaultModel": "minimax",
|
|
225
|
+
"models": {
|
|
226
|
+
"minimax": { "label": "MiniMax M2.5", "model": "anthropic/minimax-m2.5" }
|
|
227
|
+
},
|
|
228
|
+
"apiKeyEnv": "OPENCODE_GO_API_KEY",
|
|
229
|
+
"apiBaseEnv": "OPENCODE_GO_MESSAGES_API_BASE"
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Verify with `catence-console doctor`. The `responses` models
|
|
237
|
+
(`openai/responses/…`) go through LiteLLM's responses bridge and are the least
|
|
238
|
+
battle-tested path; smoke-test one chat turn before relying on them.
|
|
239
|
+
|
|
240
|
+
If you instead want to reuse the providers already configured in your local
|
|
241
|
+
Opencode CLI, run an OpenAI-compatible gateway such as the
|
|
172
242
|
[`opencode-llm-proxy`](https://github.com/KochC/opencode-llm-proxy) plugin, which
|
|
173
243
|
serves Chat Completions at `http://127.0.0.1:4010/v1` and lists every configured
|
|
174
244
|
model as `provider/model`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "catence",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.4",
|
|
4
4
|
"description": "Local MCP access to normalized Garmin, Intervals.icu, and targeted Strava fitness data.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"README.md",
|
|
29
29
|
"config.example.json",
|
|
30
30
|
"docs",
|
|
31
|
+
"scripts/discover-opencode-go.mjs",
|
|
31
32
|
"pyproject.toml",
|
|
32
33
|
"uv.lock",
|
|
33
34
|
"python/__init__.py",
|
|
@@ -55,6 +56,7 @@
|
|
|
55
56
|
"mcp": "tsx src/interfaces/mcp/main.ts",
|
|
56
57
|
"console": "uv run --project console catence-console serve",
|
|
57
58
|
"console:doctor": "uv run --project console catence-console doctor",
|
|
59
|
+
"discover:opencode-go": "node scripts/discover-opencode-go.mjs",
|
|
58
60
|
"package:check": "npm pack --dry-run",
|
|
59
61
|
"release:check": "node scripts/release-artifacts.mjs check",
|
|
60
62
|
"mcpb:validate": "npm exec --yes --package @anthropic-ai/mcpb -- mcpb validate mcpb/manifest.json",
|
|
@@ -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
|
+
});
|