@skaile/forge-settings 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +94 -2
  4. package/dist/module.d.mts +17 -0
  5. package/dist/module.json +9 -0
  6. package/dist/module.mjs +23 -0
  7. package/dist/runtime/ai-sdk/index.d.ts +45 -0
  8. package/dist/runtime/ai-sdk/index.js +39 -0
  9. package/dist/runtime/components/ForgeProvidersPanel.d.vue.ts +21 -0
  10. package/dist/runtime/components/ForgeProvidersPanel.vue +435 -0
  11. package/dist/runtime/components/ForgeProvidersPanel.vue.d.ts +21 -0
  12. package/dist/runtime/editor/index.d.ts +58 -0
  13. package/dist/runtime/editor/index.js +83 -0
  14. package/dist/runtime/model/errors.d.ts +24 -0
  15. package/dist/runtime/model/errors.js +10 -0
  16. package/dist/runtime/server/credential-policy.d.ts +60 -0
  17. package/dist/runtime/server/credential-policy.js +64 -0
  18. package/dist/runtime/server/credential-store.d.ts +184 -0
  19. package/dist/runtime/server/credential-store.js +234 -0
  20. package/dist/runtime/server/env-layers.d.ts +62 -0
  21. package/dist/runtime/server/env-layers.js +185 -0
  22. package/dist/runtime/server/env.d.ts +30 -0
  23. package/dist/runtime/server/env.js +15 -0
  24. package/dist/runtime/server/harness-settings.d.ts +150 -0
  25. package/dist/runtime/server/harness-settings.js +166 -0
  26. package/dist/runtime/server/index.d.ts +23 -0
  27. package/dist/runtime/server/index.js +14 -0
  28. package/dist/runtime/server/provider-discovery.d.ts +73 -0
  29. package/dist/runtime/server/provider-discovery.js +77 -0
  30. package/dist/runtime/server/provider-layers.d.ts +27 -0
  31. package/dist/runtime/server/provider-layers.js +37 -0
  32. package/dist/runtime/server/provider-service.d.ts +126 -0
  33. package/dist/runtime/server/provider-service.js +87 -0
  34. package/dist/runtime/server/provider-settings.d.ts +78 -0
  35. package/dist/runtime/server/provider-settings.js +81 -0
  36. package/dist/runtime/server/provider-write.d.ts +154 -0
  37. package/dist/runtime/server/provider-write.js +223 -0
  38. package/dist/runtime/server/settings-defaults.d.ts +26 -0
  39. package/dist/runtime/server/settings-defaults.js +46 -0
  40. package/dist/runtime/shared/env-vars.d.ts +55 -0
  41. package/dist/runtime/shared/env-vars.js +22 -0
  42. package/dist/runtime/shared/harness-providers.d.ts +62 -0
  43. package/dist/runtime/shared/harness-providers.js +38 -0
  44. package/dist/runtime/shared/harnesses.d.ts +19 -0
  45. package/dist/runtime/shared/harnesses.js +15 -0
  46. package/dist/runtime/shared/provider-registry.d.ts +255 -0
  47. package/dist/runtime/shared/provider-registry.js +112 -0
  48. package/dist/runtime/shared/settings-types.d.ts +84 -0
  49. package/dist/runtime/shared/settings-types.js +0 -0
  50. package/dist/runtime/shims-vue.d.ts +8 -0
  51. package/dist/runtime/vue/index.d.ts +14 -0
  52. package/dist/runtime/vue/index.js +6 -0
  53. package/dist/runtime/vue/settings-modal-scope.d.ts +62 -0
  54. package/dist/runtime/vue/settings-modal-scope.js +2 -0
  55. package/dist/runtime/vue/useProvidersPanel.d.ts +232 -0
  56. package/dist/runtime/vue/useProvidersPanel.js +655 -0
  57. package/dist/types.d.mts +9 -0
  58. package/package.json +117 -4
  59. package/src/runtime/ai-sdk/index.ts +91 -0
  60. package/src/runtime/components/ForgeProvidersPanel.vue +449 -0
  61. package/src/runtime/editor/index.ts +157 -0
  62. package/src/runtime/model/errors.ts +31 -0
  63. package/src/runtime/server/credential-policy.ts +137 -0
  64. package/src/runtime/server/credential-store.ts +473 -0
  65. package/src/runtime/server/env-layers.ts +290 -0
  66. package/src/runtime/server/env.ts +46 -0
  67. package/src/runtime/server/harness-settings.ts +373 -0
  68. package/src/runtime/server/index.ts +24 -0
  69. package/src/runtime/server/provider-discovery.ts +164 -0
  70. package/src/runtime/server/provider-layers.ts +70 -0
  71. package/src/runtime/server/provider-service.ts +244 -0
  72. package/src/runtime/server/provider-settings.ts +192 -0
  73. package/src/runtime/server/provider-write.ts +438 -0
  74. package/src/runtime/server/settings-defaults.ts +87 -0
  75. package/src/runtime/shared/env-vars.ts +82 -0
  76. package/src/runtime/shared/harness-providers.ts +118 -0
  77. package/src/runtime/shared/harnesses.ts +34 -0
  78. package/src/runtime/shared/provider-registry.ts +391 -0
  79. package/src/runtime/shared/settings-types.ts +87 -0
  80. package/src/runtime/shims-vue.d.ts +8 -0
  81. package/src/runtime/vue/index.ts +15 -0
  82. package/src/runtime/vue/settings-modal-scope.ts +66 -0
  83. package/src/runtime/vue/useProvidersPanel.ts +1087 -0
@@ -0,0 +1,185 @@
1
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { AI_PROVIDER_PRESETS } from "@skaile/workspaces/sdk/bridge";
4
+ import { parseCredentialRef, registryEntries } from "../shared/provider-registry.js";
5
+ import {
6
+ ENV_LAYER_ORDER,
7
+ validateEnvVarName
8
+ } from "../shared/env-vars.js";
9
+ export * from "../shared/env-vars.js";
10
+ const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=(.*)$/;
11
+ const NEEDS_QUOTES = /[\s#"'=\\]/;
12
+ function parseValue(raw) {
13
+ const value = raw.trim();
14
+ if (value.startsWith('"')) {
15
+ for (let i = 1; i < value.length; i++) {
16
+ if (value[i] === "\\") {
17
+ i++;
18
+ continue;
19
+ }
20
+ if (value[i] === '"') {
21
+ return value.slice(1, i).replace(/\\([nr"\\])/g, (_, ch) => ch === "n" ? "\n" : ch === "r" ? "\r" : ch);
22
+ }
23
+ }
24
+ return value;
25
+ }
26
+ if (value.startsWith("'")) {
27
+ const end = value.indexOf("'", 1);
28
+ return end === -1 ? value : value.slice(1, end);
29
+ }
30
+ const comment = value.search(/\s#/);
31
+ return (comment === -1 ? value : value.slice(0, comment)).trim();
32
+ }
33
+ export function parseDotenv(text) {
34
+ const env = {};
35
+ for (const line of text.split(/\r?\n/)) {
36
+ const match = ASSIGNMENT.exec(line);
37
+ if (match) env[match[1]] = parseValue(match[2]);
38
+ }
39
+ return env;
40
+ }
41
+ export function readDotenvFile(path) {
42
+ try {
43
+ return parseDotenv(readFileSync(path, "utf-8"));
44
+ } catch {
45
+ return {};
46
+ }
47
+ }
48
+ function serializeValue(value) {
49
+ if (!NEEDS_QUOTES.test(value)) return value;
50
+ const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r");
51
+ return `"${escaped}"`;
52
+ }
53
+ function readTextIfExists(path) {
54
+ try {
55
+ return readFileSync(path, "utf-8");
56
+ } catch (err) {
57
+ if (err.code === "ENOENT") return "";
58
+ throw err;
59
+ }
60
+ }
61
+ function writeFileAtomic(path, content) {
62
+ mkdirSync(dirname(path), { recursive: true });
63
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
64
+ try {
65
+ writeFileSync(tmp, content, { mode: 384 });
66
+ renameSync(tmp, path);
67
+ } catch (err) {
68
+ rmSync(tmp, { force: true });
69
+ throw err;
70
+ }
71
+ chmodSync(path, 384);
72
+ }
73
+ export function updateDotenvFile(path, patch) {
74
+ const text = readTextIfExists(path);
75
+ const lines = text === "" ? [] : text.replace(/\r?\n$/, "").split("\n");
76
+ const written = /* @__PURE__ */ new Set();
77
+ const out = [];
78
+ for (const line of lines) {
79
+ const key = ASSIGNMENT.exec(line)?.[1];
80
+ if (key === void 0 || !Object.hasOwn(patch, key)) {
81
+ out.push(line);
82
+ continue;
83
+ }
84
+ if (written.has(key)) continue;
85
+ written.add(key);
86
+ const value = patch[key];
87
+ if (value === null) continue;
88
+ const prefix = /^\s*export\s/.test(line) ? "export " : "";
89
+ out.push(`${prefix}${key}=${serializeValue(value)}`);
90
+ }
91
+ for (const [key, value] of Object.entries(patch)) {
92
+ if (written.has(key) || value === null) continue;
93
+ out.push(`${key}=${serializeValue(value)}`);
94
+ }
95
+ writeFileAtomic(path, out.length > 0 ? `${out.join("\n")}
96
+ ` : "");
97
+ }
98
+ const DOTENV_IGNORE_LINES = {
99
+ ".env": true,
100
+ "/.env": true,
101
+ "*.env": true,
102
+ ".env*": true
103
+ };
104
+ export function ensureDotenvIgnored(dir) {
105
+ const path = join(dir, ".gitignore");
106
+ const text = readTextIfExists(path);
107
+ if (text.split(/\r?\n/).some((line) => DOTENV_IGNORE_LINES[line.trim()])) return;
108
+ const separator = text !== "" && !text.endsWith("\n") ? "\n" : "";
109
+ writeFileSync(path, `${text}${separator}.env
110
+ `);
111
+ }
112
+ export function buildEnvSettingsDocument(input) {
113
+ const { layers } = input;
114
+ const referencedBy = /* @__PURE__ */ new Map();
115
+ for (const [id, entry] of registryEntries(input.registry)) {
116
+ const ref = entry.credential?.ref;
117
+ const parsed = ref ? parseCredentialRef(ref) : void 0;
118
+ if (parsed?.form !== "env") continue;
119
+ const ids = referencedBy.get(parsed.name) ?? [];
120
+ ids.push(id);
121
+ referencedBy.set(parsed.name, ids);
122
+ }
123
+ const names = new Set(referencedBy.keys());
124
+ for (const layer of ["instance", "workspace"]) {
125
+ for (const name of Object.keys(layers[layer] ?? {})) names.add(name);
126
+ }
127
+ for (const preset of Object.values(AI_PROVIDER_PRESETS)) {
128
+ const name = preset.credential.env;
129
+ if (name && ENV_LAYER_ORDER.some((layer) => layers[layer]?.[name])) names.add(name);
130
+ }
131
+ const vars = [];
132
+ const missing = [];
133
+ for (const name of [...names].sort()) {
134
+ const refs = [...referencedBy.get(name) ?? []].sort();
135
+ const defining = ENV_LAYER_ORDER.filter((layer) => {
136
+ const value2 = layers[layer]?.[name];
137
+ return value2 !== void 0 && value2 !== "";
138
+ });
139
+ const source = defining.at(-1);
140
+ if (source === void 0) {
141
+ if (refs.length > 0) missing.push({ name, referencedBy: refs });
142
+ continue;
143
+ }
144
+ const value = layers[source]?.[name] ?? "";
145
+ vars.push({
146
+ name,
147
+ hint: value.length >= 8 ? value.slice(-4) : "",
148
+ length: value.length,
149
+ source,
150
+ shadows: defining.slice(0, -1),
151
+ referencedBy: refs
152
+ });
153
+ }
154
+ return { scope: input.scope, editable: input.editable, layer: input.scope, vars, missing };
155
+ }
156
+ function isPlainObject(value) {
157
+ return typeof value === "object" && value !== null && !Array.isArray(value);
158
+ }
159
+ export function validateEnvSettingsUpdate(body) {
160
+ if (!isPlainObject(body)) return "Body must be an object with `set` and/or `unset`";
161
+ const set = {};
162
+ if (body.set !== void 0) {
163
+ if (!isPlainObject(body.set)) return "`set` must be an object of variable names to values";
164
+ for (const [name, value] of Object.entries(body.set)) {
165
+ const refusal = validateEnvVarName(name);
166
+ if (refusal) return refusal;
167
+ if (typeof value !== "string") return `Value of "${name}" must be a string`;
168
+ if (/[\r\n]/.test(value)) return `Value of "${name}" must be a single line`;
169
+ set[name] = value;
170
+ }
171
+ }
172
+ const unset = [];
173
+ if (body.unset !== void 0) {
174
+ if (!Array.isArray(body.unset)) return "`unset` must be an array of variable names";
175
+ for (const name of body.unset) {
176
+ if (typeof name !== "string") return "`unset` must be an array of variable names";
177
+ const refusal = validateEnvVarName(name);
178
+ if (refusal) return refusal;
179
+ if (Object.hasOwn(set, name)) return `"${name}" cannot be both set and unset`;
180
+ if (!unset.includes(name)) unset.push(name);
181
+ }
182
+ }
183
+ if (Object.keys(set).length === 0 && unset.length === 0) return "Nothing to change";
184
+ return { set, unset };
185
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * .env file loading utilities for forge apps.
3
+ *
4
+ * Nuxt's built-in dotenv only injects NUXT_* variables into runtime config.
5
+ * API keys and provider preferences (ANTHROPIC_API_KEY, PREFERRED_PROVIDER,
6
+ * PREFERRED_MODEL, etc.) are plain env vars read directly via process.env, so
7
+ * forge apps need to load them explicitly at server startup.
8
+ *
9
+ * Usage pattern (in a Nitro plugin named 00.env-load.ts):
10
+ *
11
+ * import { injectEnvFiles } from '@skaile/forge-settings/server'
12
+ * injectEnvFiles([appRoot])
13
+ *
14
+ * ~/.env is always included as the lowest-priority source automatically.
15
+ */
16
+ /**
17
+ * Parse the `.env` file in the given directory (see `parseDotenv` for the
18
+ * syntax). Returns an empty object if the file does not exist or cannot be read.
19
+ */
20
+ export declare function loadEnvFile(dir: string): Record<string, string>;
21
+ /**
22
+ * Load `.env` files and inject missing keys into process.env.
23
+ *
24
+ * Directories are merged in order — later directories take precedence.
25
+ * `~/.env` is always prepended as the lowest-priority source.
26
+ * Real environment variables (already in process.env) are never overwritten.
27
+ *
28
+ * @param appDirs - App-specific directories to scan (e.g. app root, project root)
29
+ */
30
+ export declare function injectEnvFiles(appDirs: string[]): void;
@@ -0,0 +1,15 @@
1
+ import { homedir } from "node:os";
2
+ import path from "node:path";
3
+ import { readDotenvFile } from "./env-layers.js";
4
+ export function loadEnvFile(dir) {
5
+ return readDotenvFile(path.join(dir, ".env"));
6
+ }
7
+ export function injectEnvFiles(appDirs) {
8
+ const merged = {};
9
+ for (const dir of [homedir(), ...appDirs]) {
10
+ Object.assign(merged, loadEnvFile(dir));
11
+ }
12
+ for (const [k, v] of Object.entries(merged)) {
13
+ if (!process.env[k] && v) process.env[k] = v;
14
+ }
15
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Harness settings — the one implementation both forge hosts share.
3
+ *
4
+ * A harness is a coding-agent product; `@skaile/workspaces` 6 names the adapter
5
+ * that runs it the same way (`listHarnesses()`), and the selecting settings key
6
+ * is `harness`.
7
+ *
8
+ * What lives here, so hosts only read and write their own storage:
9
+ *
10
+ * - {@link validateHarnessSettingsPatch} — the rule set for a harness settings
11
+ * write (`harness`, `harnessModels`, `harnessProviders`, `enabledHarnesses`,
12
+ * `lock*`), as a pure function over the request body and the stored state.
13
+ * - {@link resolveLockFlags} — the admin lock flags in force. Locks are
14
+ * `settings.json` fields only; there is no environment override.
15
+ * - {@link applyHarnessLayers} — which harness, model and provider pick a
16
+ * session starts with, given the settings layers the host read.
17
+ * - {@link enabledHarnessIds} / {@link buildHarnessRows} — the harness catalog
18
+ * `GET /api/agents` serves.
19
+ *
20
+ * Pure functions over plain values: no Node built-ins, no environment, no file access.
21
+ */
22
+ import type { HarnessInfo } from "@skaile/workspaces/types";
23
+ import type { SkaileSettings } from "../shared/settings-types.js";
24
+ /** The slice of {@link SkaileSettings} that selects, configures and locks harnesses. */
25
+ export type HarnessSettings = Pick<SkaileSettings, "harness" | "harnessModels" | "harnessProviders" | "enabledHarnesses" | "lockHarness" | "lockProvider" | "lockModel" | "lockAudio">;
26
+ /** One lock flag and where its value comes from. */
27
+ export type LockFlagStatus = {
28
+ value: boolean;
29
+ source: "settings" | "default";
30
+ };
31
+ /** The admin lock flags in force on an instance. */
32
+ export type LockFlags = {
33
+ harness: LockFlagStatus;
34
+ provider: LockFlagStatus;
35
+ model: LockFlagStatus;
36
+ audio: LockFlagStatus;
37
+ };
38
+ /**
39
+ * The lock flags a stored settings document carries: a boolean `lock*` field is
40
+ * `{ value, source: "settings" }`, anything else `{ value: false, source:
41
+ * "default" }`. Pass the instance settings (locks describe the instance, not a
42
+ * workspace or a user).
43
+ */
44
+ export declare function resolveLockFlags(stored: Partial<HarnessSettings> | null | undefined): LockFlags;
45
+ /**
46
+ * The harness ids this instance offers: its `enabledHarnesses`, else
47
+ * {@link DEFAULT_ENABLED_HARNESSES}. A missing or empty list means the default
48
+ * set.
49
+ */
50
+ export declare function enabledHarnessIds(s: Pick<HarnessSettings, "enabledHarnesses">): string[];
51
+ /** A `GET /api/agents` row: the harness's own info plus how this instance presents it. */
52
+ export type HarnessRow = HarnessInfo & {
53
+ /** Display label ({@link HARNESS_LABELS}, else the harness's own `name`). */
54
+ label: string;
55
+ /** Outside {@link DEFAULT_ENABLED_HARNESSES}: opt-in only. */
56
+ experimental: boolean;
57
+ /** Whether the instance's `enabledHarnesses` offers it. */
58
+ enabled: boolean;
59
+ };
60
+ /**
61
+ * The rows of `GET /api/agents`: one per harness, in `harnesses` order. Hosts
62
+ * filter to `enabled` unless the request asked for all (`?all=1`).
63
+ * `surfaces` and `providerPlurality` ride along from {@link HarnessInfo} so the
64
+ * Harness tab can offer each harness only the providers it can use.
65
+ */
66
+ export declare function buildHarnessRows(harnesses: HarnessInfo[], s: Pick<HarnessSettings, "enabledHarnesses">): HarnessRow[];
67
+ /** What {@link validateHarnessSettingsPatch} hands back for a valid body. */
68
+ export type HarnessSettingsPatch = {
69
+ /**
70
+ * The fields to write; a key whose value is `undefined` is removed from the
71
+ * stored document. Only fields that carry something to write appear.
72
+ */
73
+ patch: Partial<HarnessSettings>;
74
+ /**
75
+ * Whether the effective `harness`, `harnessModels`, `harnessProviders` or
76
+ * `enabledHarnesses` changed, i.e. whether running sessions now run on stale
77
+ * defaults and the host should stop them.
78
+ */
79
+ sessionAffecting: boolean;
80
+ };
81
+ /**
82
+ * Validate the harness part of a settings write and compute what to store.
83
+ * Keys outside the harness set are ignored — the host validates its own.
84
+ *
85
+ * - `model`: refused — the model is set per harness (`harnessModels`).
86
+ * - `harness`: a catalog harness id that is enabled; empty/null removes it
87
+ * (→ `"omp"`). The body key `driver` (the pre-6.0 spelling) is an alias for
88
+ * one major: it is validated the same way and stored as `harness`. When both
89
+ * keys are present `harness` wins.
90
+ * - `harnessModels`: patched per harness id (catalog ids only); a value is a
91
+ * `providerId/modelId` selector, an empty string removes that harness's entry.
92
+ * - `harnessProviders`: the registry entry ids picked per harness id. The modal
93
+ * sends the whole map, so it replaces the stored one: each value must be an
94
+ * array of strings (empty strings are dropped, duplicates collapse, order is
95
+ * kept), a harness whose array ends up empty is removed from the map, and the
96
+ * key appears in `patch` only when the map changed. Ids are not checked
97
+ * against the registry — a pick that is no longer eligible is shown as stale
98
+ * in the modal.
99
+ * - `enabledHarnesses`: catalog ids, always including the resulting `harness`.
100
+ * - `lockHarness` / `lockProvider` / `lockModel` / `lockAudio`: booleans,
101
+ * written only when they differ from `ctx.locks`.
102
+ *
103
+ * `ctx.locks` is used only to skip no-op lock writes; it is **not** enforced
104
+ * here. Whether the caller may write at all (admin, instance scope) and
105
+ * whether a locked field may be overridden is the host's decision, made before
106
+ * this runs.
107
+ *
108
+ * @param body the request body, untrusted.
109
+ * @param ctx.catalog valid harness ids (`listHarnesses().map(d => d.id)`).
110
+ * @param ctx.current the stored settings the patch applies to (the instance file).
111
+ * @param ctx.locks the lock flags in force ({@link resolveLockFlags} of `current`).
112
+ * @returns the patch, or the error message of the first violated rule (a
113
+ * string, mirroring `validateEnvSettingsUpdate`; hosts answer 400 with it).
114
+ */
115
+ export declare function validateHarnessSettingsPatch(body: unknown, ctx: {
116
+ catalog: Iterable<string>;
117
+ current: HarnessSettings;
118
+ locks: LockFlags;
119
+ }): HarnessSettingsPatch | string;
120
+ /** One settings layer's say in which harness, model and provider pick a session gets. */
121
+ export type HarnessLayer = {
122
+ /** The harness id (the workspaces settings key `harness`). */
123
+ harness?: string;
124
+ /** An explicit model selector; wins over this layer's `harnessModels` entry. */
125
+ model?: string;
126
+ harnessModels?: Record<string, string>;
127
+ harnessProviders?: Record<string, string[]>;
128
+ };
129
+ /**
130
+ * Resolve the harness, model and provider pick a session starts with.
131
+ *
132
+ * `layers[0]` is the instance layer; later layers (workspace file, workspace
133
+ * overrides, chat, …) are more specific and override it per field — the last
134
+ * layer that sets a field wins. A locked field (`locks.harness` → `harness`,
135
+ * `locks.model` → `model`, `locks.provider` → `providers`) ignores every later
136
+ * layer: layer 0's value is the answer, even when it is unset.
137
+ *
138
+ * "Sets" means a non-empty `harness`/`model` string or a non-empty provider
139
+ * array; `""` and `[]` inherit. A layer's model is `layer.model`, else
140
+ * `layer.harnessModels[harness]`; its providers are
141
+ * `layer.harnessProviders[harness]` — both keyed by the *resolved* harness, so
142
+ * a pick stored for another harness never applies.
143
+ *
144
+ * Fields no layer sets come back `undefined`; the host supplies its defaults.
145
+ */
146
+ export declare function applyHarnessLayers(layers: HarnessLayer[], locks: LockFlags): {
147
+ harness: string | undefined;
148
+ model: string | undefined;
149
+ providers: string[] | undefined;
150
+ };
@@ -0,0 +1,166 @@
1
+ import {
2
+ DEFAULT_ENABLED_HARNESSES,
3
+ HARNESS_LABELS,
4
+ isExperimentalHarness
5
+ } from "../shared/harnesses.js";
6
+ const LOCK_FIELDS = [
7
+ ["lockHarness", "harness"],
8
+ ["lockProvider", "provider"],
9
+ ["lockModel", "model"],
10
+ ["lockAudio", "audio"]
11
+ ];
12
+ export function resolveLockFlags(stored) {
13
+ const flag = (value) => typeof value === "boolean" ? { value, source: "settings" } : { value: false, source: "default" };
14
+ return {
15
+ harness: flag(stored?.lockHarness),
16
+ provider: flag(stored?.lockProvider),
17
+ model: flag(stored?.lockModel),
18
+ audio: flag(stored?.lockAudio)
19
+ };
20
+ }
21
+ export function enabledHarnessIds(s) {
22
+ return s.enabledHarnesses?.length ? s.enabledHarnesses : [...DEFAULT_ENABLED_HARNESSES];
23
+ }
24
+ export function buildHarnessRows(harnesses, s) {
25
+ const enabled = new Set(enabledHarnessIds(s));
26
+ return harnesses.map((info) => ({
27
+ ...info,
28
+ label: HARNESS_LABELS[info.id] ?? info.name,
29
+ experimental: isExperimentalHarness(info.id),
30
+ enabled: enabled.has(info.id)
31
+ }));
32
+ }
33
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
34
+ const nonEmpty = (value) => typeof value === "string" && value ? value : void 0;
35
+ function isModelSelector(value) {
36
+ const slash = value.indexOf("/");
37
+ return slash > 0 && slash < value.length - 1;
38
+ }
39
+ function stringEntries(value) {
40
+ const entries = {};
41
+ if (!isObject(value)) return entries;
42
+ for (const [id, entry] of Object.entries(value))
43
+ if (typeof entry === "string" && entry) entries[id] = entry;
44
+ return entries;
45
+ }
46
+ function sameEntries(a, b) {
47
+ const keys = Object.keys(a);
48
+ return keys.length === Object.keys(b).length && keys.every((key) => a[key] === b[key]);
49
+ }
50
+ function idLists(value) {
51
+ const lists = {};
52
+ if (!isObject(value)) return lists;
53
+ for (const [harnessId, ids] of Object.entries(value)) {
54
+ if (!Array.isArray(ids)) continue;
55
+ const kept = [
56
+ ...new Set(ids.filter((id) => typeof id === "string" && id !== ""))
57
+ ];
58
+ if (kept.length) lists[harnessId] = kept;
59
+ }
60
+ return lists;
61
+ }
62
+ function sameIdLists(a, b) {
63
+ const keys = Object.keys(a);
64
+ return keys.length === Object.keys(b).length && keys.every(
65
+ (key) => b[key] !== void 0 && a[key].length === b[key].length && a[key].every((id, i) => id === b[key][i])
66
+ );
67
+ }
68
+ export function validateHarnessSettingsPatch(body, ctx) {
69
+ if (!isObject(body)) return "body must be an object";
70
+ if ("model" in body) return "model is set per harness - use harnessModels";
71
+ const { current, locks } = ctx;
72
+ const catalog = new Set(ctx.catalog);
73
+ const patch = {};
74
+ let sessionAffecting = false;
75
+ const currentHarness = nonEmpty(current.harness) ?? "omp";
76
+ let nextHarness = currentHarness;
77
+ const harnessKey = "harness" in body ? "harness" : "driver" in body ? "driver" : void 0;
78
+ if (harnessKey) {
79
+ const raw = body[harnessKey];
80
+ if (raw !== null && raw !== void 0 && typeof raw !== "string")
81
+ return `${harnessKey} must be a string`;
82
+ const value = typeof raw === "string" ? raw.trim() : "";
83
+ if (value && !catalog.has(value)) return `Unknown harness "${value}"`;
84
+ patch.harness = value || void 0;
85
+ nextHarness = value || "omp";
86
+ if (nextHarness !== currentHarness) sessionAffecting = true;
87
+ }
88
+ if ("harnessModels" in body) {
89
+ const raw = body.harnessModels;
90
+ if (!isObject(raw)) return "harnessModels must be an object";
91
+ const currentModels = stringEntries(current.harnessModels);
92
+ const nextModels = { ...currentModels };
93
+ for (const [id, entry] of Object.entries(raw)) {
94
+ if (!catalog.has(id)) return `harnessModels: unknown harness "${id}"`;
95
+ if (typeof entry !== "string") return `harnessModels.${id} must be a string`;
96
+ const value = entry.trim();
97
+ if (!value) {
98
+ delete nextModels[id];
99
+ continue;
100
+ }
101
+ if (!isModelSelector(value))
102
+ return `harnessModels.${id} must be a "providerId/modelId" selector`;
103
+ nextModels[id] = value;
104
+ }
105
+ patch.harnessModels = Object.keys(nextModels).length ? nextModels : void 0;
106
+ if (!sameEntries(currentModels, nextModels)) sessionAffecting = true;
107
+ }
108
+ if ("harnessProviders" in body) {
109
+ const raw = body.harnessProviders;
110
+ if (!isObject(raw)) return "harnessProviders must be an object";
111
+ for (const [id, ids] of Object.entries(raw)) {
112
+ if (!Array.isArray(ids) || !ids.every((entry) => typeof entry === "string")) {
113
+ return `harnessProviders.${id} must be an array of provider ids`;
114
+ }
115
+ }
116
+ const next = idLists(raw);
117
+ if (!sameIdLists(idLists(current.harnessProviders), next)) {
118
+ patch.harnessProviders = Object.keys(next).length ? next : void 0;
119
+ sessionAffecting = true;
120
+ }
121
+ }
122
+ if ("enabledHarnesses" in body) {
123
+ const raw = body.enabledHarnesses;
124
+ if (!Array.isArray(raw) || !raw.every((id) => typeof id === "string")) {
125
+ return "enabledHarnesses must be an array of harness ids";
126
+ }
127
+ const unknownId = raw.find((id) => !catalog.has(id));
128
+ if (unknownId !== void 0) return `enabledHarnesses: unknown harness "${unknownId}"`;
129
+ const enabled = [...new Set(raw)];
130
+ if (!enabled.includes(nextHarness))
131
+ return `enabledHarnesses must include the active harness "${nextHarness}"`;
132
+ patch.enabledHarnesses = enabled;
133
+ const before = enabledHarnessIds(current);
134
+ if (enabled.length !== before.length || enabled.some((id) => !before.includes(id)))
135
+ sessionAffecting = true;
136
+ } else if (harnessKey && !enabledHarnessIds(current).includes(nextHarness)) {
137
+ return `Harness "${nextHarness}" is not enabled`;
138
+ }
139
+ for (const [field, lock] of LOCK_FIELDS) {
140
+ const wanted = body[field];
141
+ if (wanted === void 0) continue;
142
+ if (typeof wanted !== "boolean") return `${field} must be a boolean`;
143
+ if (wanted !== locks[lock].value) patch[field] = wanted;
144
+ }
145
+ return { patch, sessionAffecting };
146
+ }
147
+ export function applyHarnessLayers(layers, locks) {
148
+ const pick = (locked, read) => {
149
+ if (locked) return layers[0] ? read(layers[0]) : void 0;
150
+ for (let i = layers.length - 1; i >= 0; i--) {
151
+ const value = read(layers[i]);
152
+ if (value !== void 0) return value;
153
+ }
154
+ return void 0;
155
+ };
156
+ const harness = pick(locks.harness.value, (layer) => nonEmpty(layer.harness));
157
+ const model = pick(
158
+ locks.model.value,
159
+ (layer) => nonEmpty(layer.model) ?? (harness === void 0 ? void 0 : nonEmpty(layer.harnessModels?.[harness]))
160
+ );
161
+ const providers = pick(locks.provider.value, (layer) => {
162
+ const ids = harness === void 0 ? void 0 : layer.harnessProviders?.[harness];
163
+ return ids?.length ? ids : void 0;
164
+ });
165
+ return { harness, model, providers };
166
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `@skaile/forge-settings/server` — provider and settings operations for a
3
+ * host server: the registry vocabulary and layer editing, the credential
4
+ * store, env layers, harness settings, model discovery.
5
+ *
6
+ * Imports no Vue, no editor, no AI SDK and no agent session code. Storage,
7
+ * authentication and workspace permissions stay with the host.
8
+ */
9
+ export * from "../shared/harness-providers.js";
10
+ export * from "../shared/harnesses.js";
11
+ export * from "../shared/provider-registry.js";
12
+ export type { DomainConfig, SkaileSettings } from "../shared/settings-types.js";
13
+ export * from "./credential-policy.js";
14
+ export * from "./credential-store.js";
15
+ export * from "./env.js";
16
+ export * from "./env-layers.js";
17
+ export * from "./harness-settings.js";
18
+ export * from "./provider-discovery.js";
19
+ export * from "./provider-layers.js";
20
+ export * from "./provider-service.js";
21
+ export * from "./provider-settings.js";
22
+ export * from "./provider-write.js";
23
+ export * from "./settings-defaults.js";
@@ -0,0 +1,14 @@
1
+ export * from "../shared/harness-providers.js";
2
+ export * from "../shared/harnesses.js";
3
+ export * from "../shared/provider-registry.js";
4
+ export * from "./credential-policy.js";
5
+ export * from "./credential-store.js";
6
+ export * from "./env.js";
7
+ export * from "./env-layers.js";
8
+ export * from "./harness-settings.js";
9
+ export * from "./provider-discovery.js";
10
+ export * from "./provider-layers.js";
11
+ export * from "./provider-service.js";
12
+ export * from "./provider-settings.js";
13
+ export * from "./provider-write.js";
14
+ export * from "./settings-defaults.js";
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Live model discovery for one registry entry.
3
+ *
4
+ * `listAIProviderModels` (provider-service) answers from the registry's own
5
+ * `model_map`; this module asks the provider's REST API. The two are different
6
+ * facts and the result says which one it is:
7
+ *
8
+ * - `live` — the provider answered; `models` is its list.
9
+ * - `static` — the live request failed or the entry has no key, and `models` is
10
+ * the offline catalog of its preset. It is a fallback, never a live answer.
11
+ * - `unsupported` — the entry has no catalog preset, so there is nothing to ask;
12
+ * a host lets the admin type a model id.
13
+ * - `error` — nothing usable. The message is scrubbed.
14
+ *
15
+ * The error text is built here, never forwarded: a remote response body is
16
+ * never part of it, and every secret this call resolved (or the host names in
17
+ * `redact`) is removed from whatever text remains before it leaves the module.
18
+ *
19
+ * The credential policy (see `credential-policy.ts`) is applied to the entry
20
+ * before its credential is resolved, and `env:` references read from the
21
+ * explicit `env` only.
22
+ */
23
+ import type { AIProviderRegistryView } from "../shared/provider-registry.js";
24
+ import { type CredentialPolicy } from "./credential-policy.js";
25
+ /** One model a provider offers. */
26
+ export interface DiscoveredModel {
27
+ id: string;
28
+ name: string;
29
+ }
30
+ /** Outcome of {@link discoverProviderModels}. */
31
+ export type ModelDiscoveryResult = {
32
+ status: "live";
33
+ providerId: string;
34
+ models: DiscoveredModel[];
35
+ } | {
36
+ status: "static";
37
+ providerId: string;
38
+ models: DiscoveredModel[];
39
+ /** Why the live request did not produce the list. Scrubbed. */
40
+ reason: string;
41
+ } | {
42
+ status: "unsupported";
43
+ providerId: string;
44
+ reason: string;
45
+ } | {
46
+ status: "error";
47
+ providerId: string;
48
+ error: string;
49
+ };
50
+ /** Options for {@link discoverProviderModels}. */
51
+ export interface DiscoverProviderModelsOptions {
52
+ /** Environment `env:` references and base-URL variables are read from. Required: no ambient process. */
53
+ env: Record<string, string | undefined>;
54
+ /** Which credential references may be resolved. Absent: no restriction. */
55
+ policy?: CredentialPolicy;
56
+ /** Extra strings to remove from error text, besides the secrets this call resolves. */
57
+ redact?: readonly string[];
58
+ /** Local encrypted store for `keyring:` references. */
59
+ key?: string;
60
+ path?: string;
61
+ }
62
+ /**
63
+ * Remove every occurrence of each secret (verbatim, URL-encoded and
64
+ * base64-encoded) from `text`, then cap its length.
65
+ */
66
+ export declare function scrubSecrets(text: string, secrets: readonly (string | undefined)[]): string;
67
+ /**
68
+ * Ask one registry entry's provider for its models.
69
+ *
70
+ * Never throws for a provider-side failure: that is the `error` / `static`
71
+ * result. A host maps `error` to its own status.
72
+ */
73
+ export declare function discoverProviderModels(providerId: string, registry: AIProviderRegistryView, options: DiscoverProviderModelsOptions): Promise<ModelDiscoveryResult>;