@lunchbox-labs/opencode-model-profiles 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +90 -2
- package/dist/apply.d.ts +11 -0
- package/dist/apply.js +70 -0
- package/dist/model-config-view.d.ts +36 -0
- package/dist/model-config-view.js +128 -0
- package/dist/models-file.d.ts +22 -0
- package/dist/models-file.js +155 -0
- package/dist/report.d.ts +17 -0
- package/dist/report.js +73 -0
- package/dist/resolution.d.ts +27 -0
- package/dist/resolution.js +140 -0
- package/dist/selection.d.ts +17 -0
- package/dist/selection.js +95 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.js +40 -0
- package/dist/tui.d.ts +6 -0
- package/dist/tui.js +204 -0
- package/package.json +73 -4
- package/schema.json +97 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { builtInAgentNames, isRecord, ownValue, } from "./models-file.js";
|
|
2
|
+
const profileOrigins = {
|
|
3
|
+
environment: "OPENCODE_MODEL_PROFILE",
|
|
4
|
+
saved: "the saved worktree selection",
|
|
5
|
+
defaultProfile: "defaultProfile",
|
|
6
|
+
};
|
|
7
|
+
/** Takes the first choice that names a defined profile (or "defaults only"); undefined profiles fall through to the next. */
|
|
8
|
+
export function selectProfile(document, environmentProfile, savedProfile) {
|
|
9
|
+
const candidates = [
|
|
10
|
+
[environmentProfile, "environment"],
|
|
11
|
+
[savedProfile, "saved"],
|
|
12
|
+
[document.defaultProfile, "defaultProfile"],
|
|
13
|
+
];
|
|
14
|
+
const skipped = [];
|
|
15
|
+
let selected = {
|
|
16
|
+
profile: null,
|
|
17
|
+
source: "none",
|
|
18
|
+
};
|
|
19
|
+
for (const [requested, source] of candidates) {
|
|
20
|
+
if (requested === undefined || source === "none") {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (requested === null || Object.hasOwn(document.profiles, requested)) {
|
|
24
|
+
selected = { profile: requested, source };
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
skipped.push([requested, source]);
|
|
28
|
+
}
|
|
29
|
+
const using = selected.profile === null
|
|
30
|
+
? "defaults only"
|
|
31
|
+
: `profile "${selected.profile}"`;
|
|
32
|
+
return {
|
|
33
|
+
...selected,
|
|
34
|
+
notices: skipped.map(([requested, source]) => `Profile "${requested}" from ${profileOrigins[source]} is not defined in models.jsonc; using ${using}.`),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function resolveRef(document, profile, ref) {
|
|
38
|
+
const profileRefs = profile === null ? undefined : ownValue(document.profiles, profile);
|
|
39
|
+
const profileChoice = profileRefs === undefined ? undefined : ownValue(profileRefs, ref);
|
|
40
|
+
if (profileChoice !== undefined) {
|
|
41
|
+
return { choice: profileChoice, source: "profile" };
|
|
42
|
+
}
|
|
43
|
+
const defaultChoice = ownValue(document.defaults, ref);
|
|
44
|
+
return defaultChoice === undefined
|
|
45
|
+
? undefined
|
|
46
|
+
: { choice: defaultChoice, source: "defaults" };
|
|
47
|
+
}
|
|
48
|
+
function withoutModelRef(agent) {
|
|
49
|
+
const { modelRef: topLevel, ...rest } = agent;
|
|
50
|
+
let declared = topLevel;
|
|
51
|
+
if (isRecord(rest.options) && Object.hasOwn(rest.options, "modelRef")) {
|
|
52
|
+
const { modelRef: fromOptions, ...options } = rest.options;
|
|
53
|
+
declared ??= fromOptions;
|
|
54
|
+
rest.options = options;
|
|
55
|
+
}
|
|
56
|
+
return { agent: rest, declared };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Returns a new agent map and leaves the given one untouched: OpenCode keeps the global config's
|
|
60
|
+
* agent objects for the whole process, so mutating them would lose each modelRef on the next reload.
|
|
61
|
+
*/
|
|
62
|
+
export function applyModelRefs(configuredAgents, document, profile) {
|
|
63
|
+
const notices = [];
|
|
64
|
+
const agents = {};
|
|
65
|
+
const refAgents = new Map();
|
|
66
|
+
const builtIns = builtInAgentNames;
|
|
67
|
+
const sourceAgents = isRecord(configuredAgents) ? configuredAgents : {};
|
|
68
|
+
const names = [
|
|
69
|
+
...new Set([
|
|
70
|
+
...Object.keys(sourceAgents),
|
|
71
|
+
...Object.keys(document.builtins),
|
|
72
|
+
]),
|
|
73
|
+
];
|
|
74
|
+
for (const name of names) {
|
|
75
|
+
const original = ownValue(sourceAgents, name);
|
|
76
|
+
if (original !== undefined && !isRecord(original)) {
|
|
77
|
+
agents[name] = original;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const { agent, declared } = withoutModelRef(original ?? {});
|
|
81
|
+
const builtInRef = builtIns.includes(name) && agent.model === undefined
|
|
82
|
+
? ownValue(document.builtins, name)
|
|
83
|
+
: undefined;
|
|
84
|
+
if (declared !== undefined &&
|
|
85
|
+
(typeof declared !== "string" || declared.trim() === "")) {
|
|
86
|
+
notices.push(`Agent ${name} has a modelRef that is not a ref name; it keeps OpenCode's model.`);
|
|
87
|
+
}
|
|
88
|
+
const ref = declared === undefined
|
|
89
|
+
? builtInRef
|
|
90
|
+
: typeof declared === "string" && declared.trim() !== ""
|
|
91
|
+
? declared
|
|
92
|
+
: undefined;
|
|
93
|
+
if (original === undefined && ref === undefined) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (ref === undefined || agent.disable === true) {
|
|
97
|
+
agents[name] = agent;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
refAgents.set(ref, [...(refAgents.get(ref) ?? []), name]);
|
|
101
|
+
const resolved = resolveRef(document, profile, ref);
|
|
102
|
+
if (resolved !== undefined) {
|
|
103
|
+
agent.model = resolved.choice.model;
|
|
104
|
+
if (resolved.choice.variant !== undefined) {
|
|
105
|
+
agent.variant = resolved.choice.variant;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (original !== undefined || resolved !== undefined) {
|
|
109
|
+
agents[name] = agent;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const refNames = [
|
|
113
|
+
...new Set([
|
|
114
|
+
...Object.keys(document.defaults),
|
|
115
|
+
...(profile === null
|
|
116
|
+
? []
|
|
117
|
+
: Object.keys(ownValue(document.profiles, profile) ?? {})),
|
|
118
|
+
...refAgents.keys(),
|
|
119
|
+
]),
|
|
120
|
+
].sort();
|
|
121
|
+
const refs = refNames.map((ref) => {
|
|
122
|
+
const users = refAgents.get(ref) ?? [];
|
|
123
|
+
const resolved = resolveRef(document, profile, ref);
|
|
124
|
+
if (resolved === undefined) {
|
|
125
|
+
const where = profile === null ? "defaults" : `profile "${profile}" or defaults`;
|
|
126
|
+
notices.push(`Ref "${ref}" is not defined in ${where}; ${users.join(", ")} ${users.length === 1 ? "keeps" : "keep"} OpenCode's model.`);
|
|
127
|
+
return { ref, source: "unresolved", agents: users };
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
ref,
|
|
131
|
+
model: resolved.choice.model,
|
|
132
|
+
...(resolved.choice.variant === undefined
|
|
133
|
+
? {}
|
|
134
|
+
: { variant: resolved.choice.variant }),
|
|
135
|
+
source: resolved.source,
|
|
136
|
+
agents: users,
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
return { agents, refs, notices };
|
|
140
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const processProfileVariable = "OPENCODE_MODEL_PROFILE";
|
|
2
|
+
export declare function processProfile(environment?: NodeJS.ProcessEnv): string | undefined;
|
|
3
|
+
export declare function configurationDirectory(environment?: NodeJS.ProcessEnv): string;
|
|
4
|
+
/** OpenCode 1.19 uses xdg-basedir for its state directory on every platform, including Windows. */
|
|
5
|
+
export declare function stateDirectory(environment?: NodeJS.ProcessEnv): string;
|
|
6
|
+
/** Outside a git repository OpenCode reports the worktree as "/", so the directory stands in for it. */
|
|
7
|
+
export declare function worktreeRoot(worktree: unknown, directory: unknown): string;
|
|
8
|
+
export declare function selectionFilePath(state: string, worktree: string, platform?: NodeJS.Platform): string;
|
|
9
|
+
/** `null` saves "defaults only"; `undefined` means nothing is saved for this worktree. */
|
|
10
|
+
export type SavedProfile = string | null | undefined;
|
|
11
|
+
export interface SavedSelection {
|
|
12
|
+
profile: SavedProfile;
|
|
13
|
+
notice?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function readSavedSelection(file: string): Promise<SavedSelection>;
|
|
16
|
+
export declare function saveSelection(file: string, worktree: string, profile: string | null): Promise<void>;
|
|
17
|
+
export declare function clearSelection(file: string): Promise<void>;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { isRecord } from "./models-file.js";
|
|
6
|
+
export const processProfileVariable = "OPENCODE_MODEL_PROFILE";
|
|
7
|
+
export function processProfile(environment = process.env) {
|
|
8
|
+
const value = environment[processProfileVariable]?.trim();
|
|
9
|
+
return value === undefined || value === "" ? undefined : value;
|
|
10
|
+
}
|
|
11
|
+
export function configurationDirectory(environment = process.env) {
|
|
12
|
+
const configured = environment.OPENCODE_CONFIG_DIR;
|
|
13
|
+
if (configured !== undefined && configured !== "") {
|
|
14
|
+
return path.resolve(configured);
|
|
15
|
+
}
|
|
16
|
+
const xdg = environment.XDG_CONFIG_HOME;
|
|
17
|
+
return xdg !== undefined && path.isAbsolute(xdg)
|
|
18
|
+
? path.join(xdg, "opencode")
|
|
19
|
+
: path.join(homedir(), ".config", "opencode");
|
|
20
|
+
}
|
|
21
|
+
/** OpenCode 1.19 uses xdg-basedir for its state directory on every platform, including Windows. */
|
|
22
|
+
export function stateDirectory(environment = process.env) {
|
|
23
|
+
const xdg = environment.XDG_STATE_HOME;
|
|
24
|
+
return path.join(xdg !== undefined && path.isAbsolute(xdg)
|
|
25
|
+
? xdg
|
|
26
|
+
: path.join(homedir(), ".local", "state"), "opencode");
|
|
27
|
+
}
|
|
28
|
+
/** Outside a git repository OpenCode reports the worktree as "/", so the directory stands in for it. */
|
|
29
|
+
export function worktreeRoot(worktree, directory) {
|
|
30
|
+
if (typeof worktree === "string" && worktree !== "" && worktree !== "/") {
|
|
31
|
+
return worktree;
|
|
32
|
+
}
|
|
33
|
+
return typeof directory === "string" && directory !== ""
|
|
34
|
+
? directory
|
|
35
|
+
: process.cwd();
|
|
36
|
+
}
|
|
37
|
+
/** `resolve` already drops trailing separators except at a root, so equivalent spellings share a key. */
|
|
38
|
+
function worktreeKey(worktree, platform) {
|
|
39
|
+
return platform === "win32"
|
|
40
|
+
? path.win32.resolve(worktree).toLowerCase()
|
|
41
|
+
: path.posix.resolve(worktree);
|
|
42
|
+
}
|
|
43
|
+
export function selectionFilePath(state, worktree, platform = process.platform) {
|
|
44
|
+
const key = createHash("sha256")
|
|
45
|
+
.update(worktreeKey(worktree, platform))
|
|
46
|
+
.digest("hex")
|
|
47
|
+
.slice(0, 32);
|
|
48
|
+
const platformPath = platform === "win32" ? path.win32 : path.posix;
|
|
49
|
+
return platformPath.join(state, "model-profiles", "worktrees", `${key}.json`);
|
|
50
|
+
}
|
|
51
|
+
export async function readSavedSelection(file) {
|
|
52
|
+
let text;
|
|
53
|
+
try {
|
|
54
|
+
text = await readFile(file, "utf8");
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
58
|
+
return { profile: undefined };
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
profile: undefined,
|
|
62
|
+
notice: `Could not read the saved profile at ${file}; using the default selection.`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const value = JSON.parse(text);
|
|
67
|
+
if (isRecord(value) &&
|
|
68
|
+
value.version === 1 &&
|
|
69
|
+
(value.profile === null ||
|
|
70
|
+
(typeof value.profile === "string" && value.profile !== ""))) {
|
|
71
|
+
return { profile: value.profile };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// An unreadable selection is reported below like any other invalid content.
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
profile: undefined,
|
|
79
|
+
notice: `The saved profile at ${file} is invalid; using the default selection. Pick a profile with /model-config to replace it.`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export async function saveSelection(file, worktree, profile) {
|
|
83
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
84
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
85
|
+
try {
|
|
86
|
+
await writeFile(temporary, `${JSON.stringify({ version: 1, worktree, profile }, null, 2)}\n`, "utf8");
|
|
87
|
+
await rename(temporary, file);
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
await rm(temporary, { force: true });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export async function clearSelection(file) {
|
|
94
|
+
await rm(file, { force: true });
|
|
95
|
+
}
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { modelsFilePath, resolveModelProfiles, stripModelRefs, } from "./apply.js";
|
|
2
|
+
import { publishReport } from "./report.js";
|
|
3
|
+
import { selectionFilePath, stateDirectory, worktreeRoot, } from "./selection.js";
|
|
4
|
+
const ModelProfilesServer = (input, options) => {
|
|
5
|
+
const worktree = worktreeRoot(input.worktree, input.directory);
|
|
6
|
+
return Promise.resolve({
|
|
7
|
+
config: async (config) => {
|
|
8
|
+
const target = config;
|
|
9
|
+
const configFile = modelsFilePath(options);
|
|
10
|
+
try {
|
|
11
|
+
publishReport(target, options, await resolveModelProfiles(target, { configFile, worktree }));
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
try {
|
|
15
|
+
stripModelRefs(target);
|
|
16
|
+
publishReport(target, options, {
|
|
17
|
+
version: 1,
|
|
18
|
+
configFile,
|
|
19
|
+
selectionFile: selectionFilePath(stateDirectory(), worktree),
|
|
20
|
+
worktree,
|
|
21
|
+
profiles: [],
|
|
22
|
+
profile: null,
|
|
23
|
+
profileSource: "none",
|
|
24
|
+
refs: [],
|
|
25
|
+
notices: [
|
|
26
|
+
`Model refs were not applied (${error instanceof Error ? error.message : String(error)}); agents keep OpenCode's models.`,
|
|
27
|
+
],
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// A failing config hook must never stop OpenCode; agents then keep OpenCode's models.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
export default {
|
|
38
|
+
id: "model-profiles",
|
|
39
|
+
server: ModelProfilesServer,
|
|
40
|
+
};
|
package/dist/tui.d.ts
ADDED
package/dist/tui.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { fallbackToastLines, modelConfigOptions, profileLabel, selectedChoice, selectedNotice, unavailableModelNotices, } from "./model-config-view.js";
|
|
2
|
+
import { loadModelsDocument } from "./models-file.js";
|
|
3
|
+
import { readReport } from "./report.js";
|
|
4
|
+
import { clearSelection, processProfileVariable, readSavedSelection, saveSelection, } from "./selection.js";
|
|
5
|
+
const toastTitle = "Model Profiles";
|
|
6
|
+
function errorText(error) {
|
|
7
|
+
return error instanceof Error ? error.message : String(error);
|
|
8
|
+
}
|
|
9
|
+
function registerModelConfig(api) {
|
|
10
|
+
const directory = () => api.state.path.directory;
|
|
11
|
+
let shownNotices = new Set();
|
|
12
|
+
let reading = Promise.resolve();
|
|
13
|
+
let reloadsInFlight = 0;
|
|
14
|
+
const toast = (variant, lines) => {
|
|
15
|
+
api.ui.toast({
|
|
16
|
+
variant,
|
|
17
|
+
title: toastTitle,
|
|
18
|
+
message: lines.join("\n"),
|
|
19
|
+
duration: variant === "warning" ? 10_000 : 5_000,
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
/** Reads what the server applied; a fallback is new until it disappears from a later report. */
|
|
23
|
+
const readApplied = () => {
|
|
24
|
+
const next = reading.then(async () => {
|
|
25
|
+
const response = await api.client.config.get({ directory: directory() });
|
|
26
|
+
const report = response.error === undefined ? readReport(response.data) : undefined;
|
|
27
|
+
if (report === undefined) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const notices = [
|
|
31
|
+
...unavailableModelNotices(report.refs, api.state.provider),
|
|
32
|
+
...report.notices,
|
|
33
|
+
];
|
|
34
|
+
const newNotices = notices.filter((notice) => !shownNotices.has(notice));
|
|
35
|
+
shownNotices = new Set(notices);
|
|
36
|
+
return { report, notices, newNotices };
|
|
37
|
+
});
|
|
38
|
+
reading = next.catch(() => undefined);
|
|
39
|
+
return next;
|
|
40
|
+
};
|
|
41
|
+
const announceFallbacks = async () => {
|
|
42
|
+
const applied = await readApplied();
|
|
43
|
+
if (applied !== undefined && applied.newNotices.length > 0) {
|
|
44
|
+
toast("warning", fallbackToastLines(applied.newNotices));
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const reload = async (label) => {
|
|
48
|
+
const status = await api.client.session.status({ directory: directory() });
|
|
49
|
+
if (status.error !== undefined) {
|
|
50
|
+
toast("warning", [
|
|
51
|
+
`Saved ${label} for this worktree, but OpenCode could not report whether a session is busy, so it did not reload. It applies on the next reload.`,
|
|
52
|
+
]);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (Object.values(status.data).some((session) => session.type !== "idle")) {
|
|
56
|
+
toast("info", [
|
|
57
|
+
`Saved ${label} for this worktree. A session is busy, so it applies the next time OpenCode reloads this project, or pick it again in /model-config once the session is idle.`,
|
|
58
|
+
]);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
reloadsInFlight += 1;
|
|
62
|
+
const disposed = await api.client.instance
|
|
63
|
+
.dispose({ directory: directory() })
|
|
64
|
+
.finally(() => {
|
|
65
|
+
reloadsInFlight -= 1;
|
|
66
|
+
});
|
|
67
|
+
if (disposed.error !== undefined) {
|
|
68
|
+
toast("warning", [
|
|
69
|
+
`Saved ${label}, but OpenCode did not reload. It applies on the next reload.`,
|
|
70
|
+
]);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const applied = await readApplied();
|
|
74
|
+
if (applied === undefined) {
|
|
75
|
+
toast("success", [`Saved ${label}.`]);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
toast(applied.newNotices.length > 0 ? "warning" : "success", [
|
|
79
|
+
`Using ${profileLabel(applied.report.profile)}.`,
|
|
80
|
+
...fallbackToastLines(applied.newNotices),
|
|
81
|
+
]);
|
|
82
|
+
};
|
|
83
|
+
const choose = async (report, choice, defaultProfile) => {
|
|
84
|
+
const label = choice.kind === "save"
|
|
85
|
+
? profileLabel(choice.profile)
|
|
86
|
+
: `defaultProfile (${defaultProfile ?? "none"})`;
|
|
87
|
+
try {
|
|
88
|
+
await (choice.kind === "save"
|
|
89
|
+
? saveSelection(report.selectionFile, report.worktree, choice.profile)
|
|
90
|
+
: clearSelection(report.selectionFile));
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
toast("warning", [`Could not save ${label}: ${errorText(error)}`]);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (report.environmentProfile !== undefined) {
|
|
97
|
+
toast("info", [
|
|
98
|
+
`Saved ${label} for this worktree. ${processProfileVariable}=${report.environmentProfile} still applies to this OpenCode process.`,
|
|
99
|
+
]);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
await reload(label);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
toast("warning", [
|
|
107
|
+
`Saved ${label}, but could not reload OpenCode (${errorText(error)}). It applies on the next reload.`,
|
|
108
|
+
]);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const open = async () => {
|
|
112
|
+
const applied = await readApplied().catch(() => undefined);
|
|
113
|
+
if (applied === undefined) {
|
|
114
|
+
toast("warning", [
|
|
115
|
+
"The server plugin has not reported model refs. Add @lunchbox-labs/opencode-model-profiles to plugin in opencode.jsonc.",
|
|
116
|
+
]);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const { report, notices } = applied;
|
|
120
|
+
const [current, saved] = await Promise.all([
|
|
121
|
+
loadModelsDocument(report.configFile),
|
|
122
|
+
readSavedSelection(report.selectionFile),
|
|
123
|
+
]);
|
|
124
|
+
const loaded = current.status === "loaded";
|
|
125
|
+
const defaultProfile = loaded ? current.document.defaultProfile : undefined;
|
|
126
|
+
api.ui.dialog.replace(() => api.ui.DialogSelect({
|
|
127
|
+
title: "Model config",
|
|
128
|
+
options: modelConfigOptions({
|
|
129
|
+
report,
|
|
130
|
+
profiles: loaded
|
|
131
|
+
? Object.keys(current.document.profiles)
|
|
132
|
+
: report.profiles,
|
|
133
|
+
savedProfile: saved.profile,
|
|
134
|
+
defaultProfile,
|
|
135
|
+
notices,
|
|
136
|
+
}),
|
|
137
|
+
current: report.profile === null ? "defaults" : `profile:${report.profile}`,
|
|
138
|
+
onSelect: (option) => {
|
|
139
|
+
const notice = selectedNotice(option.value, notices);
|
|
140
|
+
if (notice !== undefined) {
|
|
141
|
+
toast("warning", [notice]);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const choice = selectedChoice(option.value);
|
|
145
|
+
if (choice === undefined) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
api.ui.dialog.clear();
|
|
149
|
+
choose(report, choice, defaultProfile).catch(() => undefined);
|
|
150
|
+
},
|
|
151
|
+
}));
|
|
152
|
+
api.ui.dialog.setSize("large");
|
|
153
|
+
};
|
|
154
|
+
const unregister = api.keymap.registerLayer({
|
|
155
|
+
commands: [
|
|
156
|
+
{
|
|
157
|
+
name: "model-profiles.config",
|
|
158
|
+
title: "Model config",
|
|
159
|
+
category: "Model",
|
|
160
|
+
namespace: "palette",
|
|
161
|
+
slashName: "model-config",
|
|
162
|
+
run: () => {
|
|
163
|
+
open().catch(() => undefined);
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
});
|
|
168
|
+
const unsubscribeInstance = api.event.on("server.instance.disposed", (event) => {
|
|
169
|
+
if (reloadsInFlight === 0 && event.properties.directory === directory()) {
|
|
170
|
+
announceFallbacks().catch(() => undefined);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
const unsubscribeGlobal = api.event.on("global.disposed", () => {
|
|
174
|
+
announceFallbacks().catch(() => undefined);
|
|
175
|
+
});
|
|
176
|
+
/** Models are checked against connected providers, which may not have loaded when the TUI starts. */
|
|
177
|
+
let providerChecks = 0;
|
|
178
|
+
const providerTimer = setInterval(() => {
|
|
179
|
+
providerChecks += 1;
|
|
180
|
+
if (api.state.provider.length > 0) {
|
|
181
|
+
clearInterval(providerTimer);
|
|
182
|
+
announceFallbacks().catch(() => undefined);
|
|
183
|
+
}
|
|
184
|
+
else if (providerChecks >= 60) {
|
|
185
|
+
clearInterval(providerTimer);
|
|
186
|
+
}
|
|
187
|
+
}, 500);
|
|
188
|
+
providerTimer.unref();
|
|
189
|
+
api.lifecycle.onDispose(() => {
|
|
190
|
+
clearInterval(providerTimer);
|
|
191
|
+
unregister();
|
|
192
|
+
unsubscribeInstance();
|
|
193
|
+
unsubscribeGlobal();
|
|
194
|
+
});
|
|
195
|
+
announceFallbacks().catch(() => undefined);
|
|
196
|
+
}
|
|
197
|
+
const ModelProfilesTui = (api) => {
|
|
198
|
+
registerModelConfig(api);
|
|
199
|
+
return Promise.resolve();
|
|
200
|
+
};
|
|
201
|
+
export default {
|
|
202
|
+
id: "model-profiles",
|
|
203
|
+
tui: ModelProfilesTui,
|
|
204
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,75 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lunchbox-labs/opencode-model-profiles",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pick agent models by name, with defaults and per-worktree profiles",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/lunchbox-labs/opencode-model-profiles.git"
|
|
10
|
+
},
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=22.18.0"
|
|
13
|
+
},
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/server.d.ts",
|
|
17
|
+
"default": "./dist/server.js"
|
|
18
|
+
},
|
|
19
|
+
"./server": {
|
|
20
|
+
"types": "./dist/server.d.ts",
|
|
21
|
+
"default": "./dist/server.js"
|
|
22
|
+
},
|
|
23
|
+
"./tui": {
|
|
24
|
+
"types": "./dist/tui.d.ts",
|
|
25
|
+
"default": "./dist/tui.js"
|
|
26
|
+
},
|
|
27
|
+
"./schema.json": "./schema.json"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"schema.json",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc -p tsconfig.build.json",
|
|
37
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
38
|
+
"test": "node --experimental-strip-types --test tests/*.test.ts tests/*.test.mjs",
|
|
39
|
+
"lint": "eslint . --max-warnings 0",
|
|
40
|
+
"format": "prettier --write src tests scripts package.json tsconfig*.json *.config.mjs schema.json",
|
|
41
|
+
"format:check": "prettier --check src tests scripts package.json tsconfig*.json *.config.mjs schema.json",
|
|
42
|
+
"check": "npm run check:static && npm test",
|
|
43
|
+
"prepack": "npm run build",
|
|
44
|
+
"test:package": "npm run build && node --experimental-strip-types --test tests/package.integration.ts",
|
|
45
|
+
"check:static": "concurrently --names typecheck,lint,format --kill-others-on-fail \"npm run typecheck\" \"npm run lint\" \"npm run format:check\"",
|
|
46
|
+
"prebuild": "node --input-type=module -e \"import { rm } from 'node:fs/promises'; await rm('dist', { recursive: true, force: true });\""
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@eslint-community/eslint-plugin-eslint-comments": "4.8.1",
|
|
50
|
+
"@eslint/js": "10.0.1",
|
|
51
|
+
"@opencode-ai/plugin": "1.19.5",
|
|
52
|
+
"@opencode-ai/sdk": "1.19.5",
|
|
53
|
+
"@opentui/core": "0.4.5",
|
|
54
|
+
"@opentui/keymap": "0.4.5",
|
|
55
|
+
"@opentui/solid": "0.4.5",
|
|
56
|
+
"@types/node": "24.19.2",
|
|
57
|
+
"concurrently": "10.0.6",
|
|
58
|
+
"eslint": "10.14.0",
|
|
59
|
+
"eslint-config-prettier": "10.1.8",
|
|
60
|
+
"eslint-plugin-regexp": "3.3.3",
|
|
61
|
+
"globals": "17.14.0",
|
|
62
|
+
"prettier": "3.9.10",
|
|
63
|
+
"solid-js": "1.9.12",
|
|
64
|
+
"typescript": "6.0.3",
|
|
65
|
+
"typescript-eslint": "8.72.1",
|
|
66
|
+
"yaml": "2.9.1"
|
|
67
|
+
},
|
|
68
|
+
"publishConfig": {
|
|
69
|
+
"access": "public",
|
|
70
|
+
"registry": "https://registry.npmjs.org"
|
|
71
|
+
},
|
|
72
|
+
"dependencies": {
|
|
73
|
+
"jsonc-parser": "3.3.1"
|
|
74
|
+
}
|
|
75
|
+
}
|