@skaile/forge-settings 0.0.0-stage → 1.0.1

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 (89) hide show
  1. package/CHANGELOG.md +28 -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 +46 -0
  8. package/dist/runtime/ai-sdk/index.js +31 -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 +72 -0
  14. package/dist/runtime/model/errors.d.ts +32 -0
  15. package/dist/runtime/model/errors.js +19 -0
  16. package/dist/runtime/server/credential-policy.d.ts +61 -0
  17. package/dist/runtime/server/credential-policy.js +65 -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 +183 -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 +35 -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 +221 -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/guards.d.ts +2 -0
  43. package/dist/runtime/shared/guards.js +3 -0
  44. package/dist/runtime/shared/harness-providers.d.ts +62 -0
  45. package/dist/runtime/shared/harness-providers.js +38 -0
  46. package/dist/runtime/shared/harnesses.d.ts +19 -0
  47. package/dist/runtime/shared/harnesses.js +15 -0
  48. package/dist/runtime/shared/provider-registry.d.ts +255 -0
  49. package/dist/runtime/shared/provider-registry.js +112 -0
  50. package/dist/runtime/shared/settings-types.d.ts +84 -0
  51. package/dist/runtime/shared/settings-types.js +0 -0
  52. package/dist/runtime/shims-vue.d.ts +8 -0
  53. package/dist/runtime/vue/index.d.ts +14 -0
  54. package/dist/runtime/vue/index.js +6 -0
  55. package/dist/runtime/vue/providers-panel-model.d.ts +147 -0
  56. package/dist/runtime/vue/providers-panel-model.js +37 -0
  57. package/dist/runtime/vue/settings-modal-scope.d.ts +62 -0
  58. package/dist/runtime/vue/settings-modal-scope.js +2 -0
  59. package/dist/runtime/vue/useProvidersPanel.d.ts +115 -0
  60. package/dist/runtime/vue/useProvidersPanel.js +633 -0
  61. package/dist/types.d.mts +9 -0
  62. package/package.json +117 -4
  63. package/src/runtime/ai-sdk/index.ts +88 -0
  64. package/src/runtime/components/ForgeProvidersPanel.vue +449 -0
  65. package/src/runtime/editor/index.ts +146 -0
  66. package/src/runtime/model/errors.ts +52 -0
  67. package/src/runtime/server/credential-policy.ts +139 -0
  68. package/src/runtime/server/credential-store.ts +473 -0
  69. package/src/runtime/server/env-layers.ts +287 -0
  70. package/src/runtime/server/env.ts +46 -0
  71. package/src/runtime/server/harness-settings.ts +373 -0
  72. package/src/runtime/server/index.ts +24 -0
  73. package/src/runtime/server/provider-discovery.ts +164 -0
  74. package/src/runtime/server/provider-layers.ts +67 -0
  75. package/src/runtime/server/provider-service.ts +244 -0
  76. package/src/runtime/server/provider-settings.ts +192 -0
  77. package/src/runtime/server/provider-write.ts +433 -0
  78. package/src/runtime/server/settings-defaults.ts +87 -0
  79. package/src/runtime/shared/env-vars.ts +82 -0
  80. package/src/runtime/shared/guards.ts +4 -0
  81. package/src/runtime/shared/harness-providers.ts +118 -0
  82. package/src/runtime/shared/harnesses.ts +34 -0
  83. package/src/runtime/shared/provider-registry.ts +391 -0
  84. package/src/runtime/shared/settings-types.ts +87 -0
  85. package/src/runtime/shims-vue.d.ts +8 -0
  86. package/src/runtime/vue/index.ts +15 -0
  87. package/src/runtime/vue/providers-panel-model.ts +203 -0
  88. package/src/runtime/vue/settings-modal-scope.ts +66 -0
  89. package/src/runtime/vue/useProvidersPanel.ts +930 -0
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Which registry providers a harness can run against, and which of
3
+ * them a session on it will use.
4
+ *
5
+ * One rule, applied identically by the settings dialog and the spawner, so the
6
+ * dialog never promises a provider the spawner will not deliver.
7
+ *
8
+ * Eligibility is by wire surface, never by vendor name: an entry is eligible
9
+ * for a harness when it is an enabled `llm` entry whose `surface` the harness
10
+ * declares. A harness that declares no surfaces accepts every `llm` entry.
11
+ *
12
+ * Browser-safe on purpose — no `node:` imports.
13
+ */
14
+
15
+ /** How many registry entries a harness can run at once (`@skaile/workspaces` `HarnessInfo.providerPlurality`). */
16
+ export type HarnessProviderPlurality = "registry" | "credentials-n" | "single";
17
+
18
+ /** The slice of a harness the rule reads. `GET /api/agents` rows satisfy it. */
19
+ export interface HarnessProviderInfo {
20
+ id: string;
21
+ /** Wire surfaces the harness speaks. Absent: every `llm` entry is eligible. */
22
+ surfaces?: readonly string[];
23
+ /** Absent means `"single"`. */
24
+ providerPlurality?: HarnessProviderPlurality;
25
+ }
26
+
27
+ /** The slice of a registry entry the rule reads. `GET /api/providers` rows satisfy it. */
28
+ export interface HarnessProviderCandidate {
29
+ id: string;
30
+ /** Absent means `llm`. */
31
+ kind?: string;
32
+ /** Wire protocol of an `llm` entry. */
33
+ surface?: string;
34
+ /** Absent means enabled. */
35
+ enabled?: boolean;
36
+ }
37
+
38
+ /**
39
+ * - `chosen`: the stored pick, narrowed to what is still eligible, is used.
40
+ * - `auto`: nothing usable picked; the only eligible entry — or, for a multi
41
+ * harness, every eligible entry — is used.
42
+ * - `none-eligible`: no entry fits; the harness runs on its native login.
43
+ * - `needs-choice`: a single-provider harness with several eligible entries and
44
+ * no usable pick — the harness is not usable until one is chosen.
45
+ */
46
+ export type HarnessProviderState = "chosen" | "auto" | "none-eligible" | "needs-choice";
47
+
48
+ export interface HarnessProviderResolution<
49
+ C extends HarnessProviderCandidate = HarnessProviderCandidate,
50
+ > {
51
+ /** Eligible candidates, in candidate (registry) order. */
52
+ eligible: C[];
53
+ /** Ids the session uses, in registry order; empty for `none-eligible` and `needs-choice`. */
54
+ selected: string[];
55
+ state: HarnessProviderState;
56
+ /** The harness can run several providers at once. */
57
+ multiple: boolean;
58
+ /** Stored pick ids that are no longer eligible, deduplicated, in stored order. */
59
+ stale: string[];
60
+ }
61
+
62
+ /** Whether `candidate` is an enabled `llm` entry on a surface `harness` speaks. */
63
+ export function isProviderEligibleForHarness(
64
+ harness: HarnessProviderInfo,
65
+ candidate: HarnessProviderCandidate,
66
+ ): boolean {
67
+ if ((candidate.kind ?? "llm") !== "llm") return false;
68
+ if (candidate.enabled === false) return false;
69
+ if (!harness.surfaces) return true;
70
+ return candidate.surface !== undefined && harness.surfaces.includes(candidate.surface);
71
+ }
72
+
73
+ /**
74
+ * Resolve the providers a harness runs against.
75
+ *
76
+ * @param candidates Registry entries in registry order.
77
+ * @param selection The stored per-harness pick (`SkaileSettings.harnessProviders[harness.id]`).
78
+ */
79
+ export function resolveHarnessProviders<C extends HarnessProviderCandidate>(
80
+ harness: HarnessProviderInfo,
81
+ candidates: readonly C[],
82
+ selection: readonly string[] | undefined,
83
+ ): HarnessProviderResolution<C> {
84
+ const multiple =
85
+ harness.providerPlurality === "registry" || harness.providerPlurality === "credentials-n";
86
+ const eligible = candidates.filter((candidate) =>
87
+ isProviderEligibleForHarness(harness, candidate),
88
+ );
89
+ const eligibleIds = new Set(eligible.map((candidate) => candidate.id));
90
+ const picked = new Set(selection ?? []);
91
+ const stale = [...picked].filter((id) => !eligibleIds.has(id));
92
+ const usable = eligible
93
+ .filter((candidate) => picked.has(candidate.id))
94
+ .map((candidate) => candidate.id);
95
+
96
+ if (usable.length > 0) {
97
+ return {
98
+ eligible,
99
+ selected: multiple ? usable : usable.slice(0, 1),
100
+ state: "chosen",
101
+ multiple,
102
+ stale,
103
+ };
104
+ }
105
+ if (eligible.length === 0) {
106
+ return { eligible, selected: [], state: "none-eligible", multiple, stale };
107
+ }
108
+ if (eligible.length === 1 || multiple) {
109
+ return {
110
+ eligible,
111
+ selected: eligible.map((candidate) => candidate.id),
112
+ state: "auto",
113
+ multiple,
114
+ stale,
115
+ };
116
+ }
117
+ return { eligible, selected: [], state: "needs-choice", multiple, stale };
118
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Harness catalog vocabulary shared by the server and the settings UI.
3
+ *
4
+ * A harness is a coding-agent product; `@skaile/workspaces` 6 names the adapter
5
+ * that runs it the same way, so these ids are the ids `listHarnesses()` reports
6
+ * (`--harness <id>`).
7
+ *
8
+ * {@link DEFAULT_ENABLED_HARNESSES} are the harnesses an instance offers when
9
+ * its settings name no `enabledHarnesses`; every other harness is experimental
10
+ * and must be enabled explicitly.
11
+ *
12
+ * Browser-safe on purpose — no `node:` imports.
13
+ */
14
+
15
+ /** Display labels keyed by harness id. Unknown ids fall back to the harness's own name. */
16
+ export const HARNESS_LABELS: Record<string, string> = {
17
+ omp: "oh-my-pi",
18
+ "claude-sdk": "Claude Code",
19
+ codex: "Codex",
20
+ gemini: "Gemini CLI",
21
+ qwen: "Qwen Code",
22
+ opencode: "OpenCode",
23
+ goose: "Goose",
24
+ continue: "Continue",
25
+ echo: "Echo (E2E mock)",
26
+ };
27
+
28
+ /** Harness ids an instance enables when its settings carry no `enabledHarnesses`. */
29
+ export const DEFAULT_ENABLED_HARNESSES = ["omp", "claude-sdk", "codex"] as const;
30
+
31
+ /** Whether `id` is outside {@link DEFAULT_ENABLED_HARNESSES}, i.e. opt-in only. */
32
+ export function isExperimentalHarness(id: string): boolean {
33
+ return !(DEFAULT_ENABLED_HARNESSES as readonly string[]).includes(id);
34
+ }
@@ -0,0 +1,391 @@
1
+ /**
2
+ * The AI provider registry vocabulary, as forge-settings speaks it.
3
+ *
4
+ * `@skaile/workspaces` owns this schema (`core/src/provider-registry.ts`): the
5
+ * validator, the layer merge, the catalog presets and the model-reference
6
+ * resolver live there and are deliberately NOT reimplemented here. What follows
7
+ * is a *structural* mirror of the record so browser bundles do not import the
8
+ * workspaces core. Field names, union members and optionality match the
9
+ * upstream record exactly, so an entry produced by the runtime is assignable to
10
+ * these types and vice versa.
11
+ *
12
+ * Browser-safe on purpose — no `node:` imports — so the settings modal renders
13
+ * registry entries with the same types the server resolves them with.
14
+ *
15
+ * Vocabulary: these are **AI provider** records. They are unrelated to the
16
+ * platform's connector `ProviderType`/`ProviderLink` (git, SharePoint, Drive).
17
+ */
18
+
19
+ // ── Enums ──────────────────────────────────────────────────────────────────
20
+
21
+ /** What an entry produces. `stt`/`tts` use the reduced voice record. */
22
+ export const AI_PROVIDER_KINDS = ["llm", "stt", "tts", "embed"] as const;
23
+
24
+ /** Union of {@link AI_PROVIDER_KINDS}. */
25
+ export type AIProviderKind = (typeof AI_PROVIDER_KINDS)[number];
26
+
27
+ /** Kinds that use the voice subset of the record. */
28
+ export const AI_PROVIDER_VOICE_KINDS = ["stt", "tts"] as const;
29
+
30
+ /** Union of {@link AI_PROVIDER_VOICE_KINDS}. */
31
+ export type AIProviderVoiceKind = (typeof AI_PROVIDER_VOICE_KINDS)[number];
32
+
33
+ /** Wire protocol of an `llm`/`embed` entry — omp's `api` values verbatim. */
34
+ export const AI_PROVIDER_SURFACES = [
35
+ "anthropic-messages",
36
+ "openai-completions",
37
+ "openai-responses",
38
+ "openai-codex-responses",
39
+ "azure-openai-responses",
40
+ "bedrock-converse-stream",
41
+ "google-generative-ai",
42
+ "google-vertex",
43
+ ] as const;
44
+
45
+ /** Union of {@link AI_PROVIDER_SURFACES}. */
46
+ export type AIProviderSurface = (typeof AI_PROVIDER_SURFACES)[number];
47
+
48
+ /** How the request reaches the surface — selects the `params` shape. */
49
+ export const AI_PROVIDER_TRANSPORTS = [
50
+ "direct",
51
+ "bedrock",
52
+ "vertex",
53
+ "foundry",
54
+ "azure-openai",
55
+ ] as const;
56
+
57
+ /** Union of {@link AI_PROVIDER_TRANSPORTS}. */
58
+ export type AIProviderTransport = (typeof AI_PROVIDER_TRANSPORTS)[number];
59
+
60
+ /** How the credential is presented. */
61
+ export const AI_PROVIDER_CREDENTIAL_KINDS = [
62
+ "api_key_header",
63
+ "bearer",
64
+ "sigv4",
65
+ "google_adc",
66
+ "azure_entra",
67
+ "oauth",
68
+ "command",
69
+ "none",
70
+ ] as const;
71
+
72
+ /** Union of {@link AI_PROVIDER_CREDENTIAL_KINDS}. */
73
+ export type AIProviderCredentialKind = (typeof AI_PROVIDER_CREDENTIAL_KINDS)[number];
74
+
75
+ // ── Record ─────────────────────────────────────────────────────────────────
76
+
77
+ /** `transport: bedrock` routing parameters. */
78
+ export interface AIProviderBedrockParams {
79
+ /** AWS region, e.g. `us-east-1`. */
80
+ region: string;
81
+ }
82
+
83
+ /** `transport: vertex` routing parameters. */
84
+ export interface AIProviderVertexParams {
85
+ /** GCP project id. */
86
+ project: string;
87
+ /** Vertex location, e.g. `europe-west4`. */
88
+ location: string;
89
+ /** Model publisher; defaults to `google`. */
90
+ publisher?: string;
91
+ }
92
+
93
+ /** `transport: foundry` routing parameters. */
94
+ export interface AIProviderFoundryParams {
95
+ /** Azure AI Foundry resource name. */
96
+ resource: string;
97
+ }
98
+
99
+ /** `transport: azure-openai` routing parameters. */
100
+ export interface AIProviderAzureOpenAIParams {
101
+ /** Azure OpenAI resource name. */
102
+ resource: string;
103
+ /** Deployment the request is routed to. */
104
+ deployment: string;
105
+ /** `api-version` query value. */
106
+ api_version: string;
107
+ }
108
+
109
+ /** Voice parameters, typed per preset: Deepgram `{model, language}`, ElevenLabs `{voice_id, output_format}`. */
110
+ export type AIProviderVoiceParams = Record<string, string | number | boolean>;
111
+
112
+ /** The `transport` → `params` discriminated union. */
113
+ export type AIProviderTransportSlice =
114
+ | { transport?: "direct"; params?: Record<string, never> }
115
+ | { transport: "bedrock"; params: AIProviderBedrockParams }
116
+ | { transport: "vertex"; params: AIProviderVertexParams }
117
+ | { transport: "foundry"; params: AIProviderFoundryParams }
118
+ | { transport: "azure-openai"; params: AIProviderAzureOpenAIParams };
119
+
120
+ /** Reference to a secret — never the secret itself. See {@link parseCredentialRef}. */
121
+ export interface AIProviderCredential {
122
+ kind: AIProviderCredentialKind;
123
+ /**
124
+ * `env:NAME` · `cmd:<shell>` · `platform:<configId>` · `keyring:<service>/<account>`.
125
+ * A literal secret here is a validation error, not a warning.
126
+ */
127
+ ref?: string;
128
+ }
129
+
130
+ /** Per-model capability facts, keyed by the logical (`model_map`) id. */
131
+ export interface AIProviderCapabilities {
132
+ context_window?: number;
133
+ tools?: boolean;
134
+ images?: boolean;
135
+ thinking_max?: number;
136
+ }
137
+
138
+ /** omp-compatible discovery descriptor. */
139
+ export interface AIProviderDiscovery {
140
+ type: string;
141
+ /** Path appended to `base_url` when the surface default does not apply. */
142
+ path?: string;
143
+ }
144
+
145
+ /** Fields every entry carries, whatever its `kind`. */
146
+ export interface AIProviderEntryCommon {
147
+ /** Display name. Never load-bearing. */
148
+ label?: string;
149
+ /** Catalog preset this entry inherits from. */
150
+ preset?: string;
151
+ /** Literal endpoint; wins over the preset value and the transport derivation. */
152
+ base_url?: string;
153
+ /** Static, non-secret request headers. */
154
+ headers?: Record<string, string>;
155
+ credential?: AIProviderCredential;
156
+ /** Default `true`; read it through {@link isEntryEnabled}. */
157
+ enabled?: boolean;
158
+ /** Set by the control plane: lower layers cannot override this entry. */
159
+ locked?: boolean;
160
+ }
161
+
162
+ /** An `llm`/`embed` entry — the full record. */
163
+ export type AIProviderLlmEntry = AIProviderEntryCommon &
164
+ AIProviderTransportSlice & {
165
+ kind: "llm" | "embed";
166
+ /** Required unless a `preset` supplies it. */
167
+ surface?: AIProviderSurface;
168
+ /** Azure's `api-version` lives here. */
169
+ query_params?: Record<string, string>;
170
+ /** Logical id → provider-side id. */
171
+ model_map?: Record<string, string>;
172
+ /** Keyed by the logical id, i.e. the `model_map` key. */
173
+ capabilities?: Record<string, AIProviderCapabilities>;
174
+ discovery?: AIProviderDiscovery;
175
+ };
176
+
177
+ /**
178
+ * An `stt`/`tts` entry — the reduced record. `surface`, `transport`,
179
+ * `model_map`, `capabilities` and `discovery` are `llm`-only: a TTS voice is
180
+ * not a wire protocol. The voice defaults forge apps used to hardcode at their
181
+ * call sites (Deepgram model/language, ElevenLabs voice id/output format) live
182
+ * in `params`.
183
+ */
184
+ export type AIProviderVoiceEntry = AIProviderEntryCommon & {
185
+ kind: AIProviderVoiceKind;
186
+ params?: AIProviderVoiceParams;
187
+ };
188
+
189
+ /** One registry entry. */
190
+ export type AIProviderEntry = AIProviderLlmEntry | AIProviderVoiceEntry;
191
+
192
+ /** Entry id → entry. Iteration order is registry order. */
193
+ export type AIProviderRegistry = Record<string, AIProviderEntry>;
194
+
195
+ /** Configuration layers, lowest precedence first. */
196
+ export type AIProviderLayerId = "catalog" | "user" | "workspace" | "runtime" | "session";
197
+
198
+ /** The merged, layered view the runtime hands to a session. */
199
+ export interface MergedAIProviderRegistry {
200
+ version: 1;
201
+ providers: AIProviderRegistry;
202
+ /** Registry order: highest-precedence layer first, document order within a layer. */
203
+ order: string[];
204
+ /** Highest-precedence layer that contributed to each entry. */
205
+ origin: Record<string, AIProviderLayerId>;
206
+ /** Entries frozen by `locked: true`. */
207
+ locked: string[];
208
+ }
209
+
210
+ /** Either shape a consumer may be handed. */
211
+ export type AIProviderRegistryView = AIProviderRegistry | MergedAIProviderRegistry;
212
+
213
+ // ── Reading a registry ─────────────────────────────────────────────────────
214
+
215
+ /**
216
+ * Whether a view is the layered shape rather than a bare entry map.
217
+ *
218
+ * Exported because anything that *rebuilds* a registry (rather than just
219
+ * reading it) has to put the entries back in the shape it was handed.
220
+ */
221
+ export function isMergedRegistry(
222
+ registry: AIProviderRegistryView,
223
+ ): registry is MergedAIProviderRegistry {
224
+ return Array.isArray((registry as MergedAIProviderRegistry).order);
225
+ }
226
+
227
+ /** `enabled` defaults to `true`. */
228
+ export function isEntryEnabled(entry: AIProviderEntry): boolean {
229
+ return entry.enabled !== false;
230
+ }
231
+
232
+ /** Entries in registry order, as `[id, entry]` pairs. Both registry shapes accepted. */
233
+ export function registryEntries(
234
+ registry: AIProviderRegistryView,
235
+ ): Array<[string, AIProviderEntry]> {
236
+ if (isMergedRegistry(registry)) {
237
+ return registry.order
238
+ .filter((id) => registry.providers[id] !== undefined)
239
+ .map((id) => [id, registry.providers[id]] as [string, AIProviderEntry]);
240
+ }
241
+ return Object.entries(registry);
242
+ }
243
+
244
+ /**
245
+ * Enabled entries of one `kind`, in registry order.
246
+ *
247
+ * This is the replacement for every hardcoded provider-id list in the forge
248
+ * apps: which providers exist, and whether one does speech or chat, is a fact
249
+ * of the registry record, not of an app-local table.
250
+ */
251
+ export function entriesOfKind<K extends AIProviderKind>(
252
+ registry: AIProviderRegistryView,
253
+ kind: K,
254
+ ): Array<[string, K extends AIProviderVoiceKind ? AIProviderVoiceEntry : AIProviderLlmEntry]> {
255
+ return registryEntries(registry).filter(
256
+ ([, entry]) => entry.kind === kind && isEntryEnabled(entry),
257
+ ) as Array<[string, K extends AIProviderVoiceKind ? AIProviderVoiceEntry : AIProviderLlmEntry]>;
258
+ }
259
+
260
+ /** Whether an entry uses the reduced voice record. Narrows `params` into view. */
261
+ export function isVoiceEntry(entry: AIProviderEntry): entry is AIProviderVoiceEntry {
262
+ return entry.kind === "stt" || entry.kind === "tts";
263
+ }
264
+
265
+ // ── Voice selection ────────────────────────────────────────────────────────
266
+
267
+ /**
268
+ * The one `params` key a settings override replaces, per voice kind.
269
+ *
270
+ * Shared so the server's `resolveVoiceProvider` and a settings UI cannot
271
+ * disagree about which parameter the user is allowed to override — the rest of
272
+ * an entry's `params` are registry facts.
273
+ */
274
+ export const VOICE_OVERRIDE_PARAM = { stt: "language", tts: "voice_id" } as const;
275
+
276
+ /** The settings field carrying each kind's {@link VOICE_OVERRIDE_PARAM} override. */
277
+ export const VOICE_OVERRIDE_SETTING = { stt: "sttLanguage", tts: "ttsVoice" } as const;
278
+
279
+ /**
280
+ * Pick which voice entry serves a kind, given the ids on offer in registry
281
+ * order and the id the settings name.
282
+ *
283
+ * A setting naming an entry the registry no longer has falls back to the first
284
+ * one rather than silencing the app. Both the server resolver and the settings
285
+ * modal go through this, so what the modal shows is what the request will use.
286
+ *
287
+ * @returns The chosen id, or `undefined` when no entry of that kind exists.
288
+ */
289
+ export function selectVoiceEntryId(ids: readonly string[], wanted?: string): string | undefined {
290
+ const trimmed = wanted?.trim();
291
+ if (trimmed && ids.includes(trimmed)) return trimmed;
292
+ return ids[0];
293
+ }
294
+
295
+ // ── Credential references ──────────────────────────────────────────────────
296
+
297
+ /** POSIX environment variable name. */
298
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
299
+
300
+ /** The four reference forms. */
301
+ const CREDENTIAL_REF = /^(env|cmd|platform|keyring):([\s\S]+)$/;
302
+
303
+ /** A parsed `credential.ref`. */
304
+ export type AIProviderCredentialRef =
305
+ | { form: "env"; name: string }
306
+ | { form: "cmd"; command: string }
307
+ | { form: "platform"; configId: string }
308
+ | { form: "keyring"; service: string; account: string };
309
+
310
+ /**
311
+ * Parse a `credential.ref`.
312
+ *
313
+ * Returns `undefined` for anything that is not one of the four reference forms
314
+ * — which, in a registry entry or a credential editor, means someone typed a
315
+ * literal secret.
316
+ */
317
+ export function parseCredentialRef(ref: string): AIProviderCredentialRef | undefined {
318
+ const match = CREDENTIAL_REF.exec(ref.trim());
319
+ if (!match) return undefined;
320
+ const body = match[2].trim();
321
+ if (body === "") return undefined;
322
+ switch (match[1]) {
323
+ case "env":
324
+ return ENV_NAME.test(body) ? { form: "env", name: body } : undefined;
325
+ case "cmd":
326
+ return { form: "cmd", command: body };
327
+ case "platform":
328
+ return { form: "platform", configId: body };
329
+ case "keyring": {
330
+ const slash = body.indexOf("/");
331
+ if (slash <= 0 || slash === body.length - 1) return undefined;
332
+ return { form: "keyring", service: body.slice(0, slash), account: body.slice(slash + 1) };
333
+ }
334
+ default:
335
+ return undefined;
336
+ }
337
+ }
338
+
339
+ /** Render a parsed reference back to its `<form>:<body>` string. */
340
+ export function formatCredentialRef(ref: AIProviderCredentialRef): string {
341
+ switch (ref.form) {
342
+ case "env":
343
+ return `env:${ref.name}`;
344
+ case "cmd":
345
+ return `cmd:${ref.command}`;
346
+ case "platform":
347
+ return `platform:${ref.configId}`;
348
+ case "keyring":
349
+ return `keyring:${ref.service}/${ref.account}`;
350
+ }
351
+ }
352
+
353
+ /** The reference forms, spelled the way a credential editor offers them. */
354
+ export const CREDENTIAL_REF_FORMS = [
355
+ "env:NAME",
356
+ "keyring:service/account",
357
+ "cmd:<shell command>",
358
+ "platform:<configId>",
359
+ ] as const;
360
+
361
+ /** What a credential editor tells a user who typed something that is not a reference. */
362
+ export const CREDENTIAL_REF_GUIDANCE = `Enter a reference, not a key — ${CREDENTIAL_REF_FORMS.join(
363
+ " · ",
364
+ )}.`;
365
+
366
+ /**
367
+ * Check what a credential editor holds.
368
+ *
369
+ * An empty field is valid: it means "no override, use the entry's own
370
+ * reference". Anything else must parse as one of the four forms — a pasted
371
+ * secret does not, and that is the case this exists for.
372
+ *
373
+ * The message never echoes the value: a user who pasted a live key must not
374
+ * see it rendered back into the page (or into a screenshot of it).
375
+ *
376
+ * @returns The refusal to show, or `undefined` when the field is acceptable.
377
+ */
378
+ export function validateCredentialRef(value: string): string | undefined {
379
+ const trimmed = value.trim();
380
+ if (trimmed === "" || parseCredentialRef(trimmed)) return undefined;
381
+ // `cmd:` and `platform:` take any non-empty body, so a prefix with nothing
382
+ // usable behind it does not match at all and lands in the default.
383
+ switch (CREDENTIAL_REF.exec(trimmed)?.[1]) {
384
+ case "env":
385
+ return "An env: reference takes a variable name such as env:ANTHROPIC_API_KEY.";
386
+ case "keyring":
387
+ return "A keyring: reference takes <service>/<account>, such as keyring:forge/anthropic.";
388
+ default:
389
+ return `A credential is stored as a reference, never as a key. ${CREDENTIAL_REF_GUIDANCE}`;
390
+ }
391
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The settings document type forge apps share.
3
+ *
4
+ * A leaf module: it imports types only, so the harness and provider settings
5
+ * modules read `SkaileSettings` from here instead of from the broad server
6
+ * entry. Browser-safe.
7
+ */
8
+
9
+ import type { SkaileSettings as CoreSettings } from "@skaile/workspaces/sdk/core";
10
+
11
+ /** Skill domain enablement configuration for a forge app installation. */
12
+ export interface DomainConfig {
13
+ /** Domain identifiers that are active for this installation. */
14
+ enabled: string[];
15
+ /** Domain shown by default for users with multi-domain access. */
16
+ default: string;
17
+ }
18
+
19
+ /**
20
+ * Full settings type for forge apps.
21
+ *
22
+ * Extends the core personal settings with forge-app-specific fields. The core
23
+ * type's legacy `apiKeys` field is deliberately omitted: a provider is selected
24
+ * as part of `model`, and a credential is a reference held by the registry
25
+ * entry, never a settings value.
26
+ */
27
+ export interface SkaileSettings extends Omit<CoreSettings, "apiKeys"> {
28
+ /**
29
+ * Model selection as `"<providerId>/<modelId>"`, where `providerId` is a
30
+ * registry entry id. A bare model id and an `@role` alias also resolve — the
31
+ * runtime does that against the session's registry.
32
+ */
33
+ model?: string;
34
+ /** Registry id of the `kind: stt` entry that transcribes speech. */
35
+ sttProvider?: string;
36
+ /** Registry id of the `kind: tts` entry that synthesizes speech. */
37
+ ttsProvider?: string;
38
+ /**
39
+ * Per-request override of the STT entry's `params.language`. Absent means the
40
+ * entry's own parameter wins — the endpoint's defaults are registry facts, not
41
+ * app settings.
42
+ */
43
+ sttLanguage?: string;
44
+ /** Per-request override of the TTS entry's `params.voice_id`. */
45
+ ttsVoice?: string;
46
+ /**
47
+ * Per-harness default model selector (`"<providerId>/<modelId>"`), keyed by
48
+ * harness id. Picked when a session starts on that harness without an
49
+ * explicit model. The default harness itself is the workspaces settings key
50
+ * `harness`, which selects a harness by id.
51
+ */
52
+ harnessModels?: Record<string, string>;
53
+ /**
54
+ * Per-harness provider pick: registry entry ids, keyed by harness id. Narrowed
55
+ * to the entries still eligible for that harness (enabled `llm` entries on a
56
+ * surface the harness speaks) by `resolveHarnessProviders`; absent or empty
57
+ * means "no pick" — the only eligible entry (or, for a multi-provider
58
+ * harness, all of them) is used automatically.
59
+ */
60
+ harnessProviders?: Record<string, string[]>;
61
+ /**
62
+ * Instance allow-list of harness ids the harness picker offers. Absent means
63
+ * `DEFAULT_ENABLED_HARNESSES`.
64
+ */
65
+ enabledHarnesses?: string[];
66
+ /**
67
+ * Admin locks, instance `settings.json` only (there is no environment
68
+ * override): when true, the instance's harness / provider pick / model /
69
+ * audio choice applies to every workspace and user. Read through
70
+ * `resolveLockFlags`.
71
+ */
72
+ lockHarness?: boolean;
73
+ lockProvider?: boolean;
74
+ lockModel?: boolean;
75
+ lockAudio?: boolean;
76
+ /** Active skill domain configuration. */
77
+ domains?: DomainConfig;
78
+ /** Optional display name for the agent shown in the UI. */
79
+ agentName?: string;
80
+ /** Optional user-supplied addendum materialized into the agent's system prompt. */
81
+ systemPromptExtension?: string;
82
+ /**
83
+ * When true the onboarding wizard has been completed (or skipped).
84
+ * Absent / false on a fresh install — triggers the first-run wizard.
85
+ */
86
+ hasCompletedOnboarding?: boolean;
87
+ }
@@ -0,0 +1,8 @@
1
+ // Ambient module declaration so `tsc --noEmit` resolves `*.vue` imports.
2
+ // Runtime resolution is handled by Vite / @vitejs/plugin-vue at build time.
3
+ declare module "*.vue" {
4
+ import type { DefineComponent } from "vue";
5
+ // biome-ignore lint/suspicious/noExplicitAny: catch-all for SFC components
6
+ const component: DefineComponent<object, object, any>;
7
+ export default component;
8
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `@skaile/forge-settings/vue` — browser-safe settings UI logic: the
3
+ * providers-panel composable, the modal scope contract and the registry /
4
+ * harness vocabulary the UI renders. No `node:` imports.
5
+ *
6
+ * `ForgeProvidersPanel` itself is registered as a component by
7
+ * `@skaile/forge-settings/nuxt`.
8
+ */
9
+
10
+ export * from "../shared/env-vars";
11
+ export * from "../shared/harness-providers";
12
+ export * from "../shared/harnesses";
13
+ export * from "../shared/provider-registry";
14
+ export * from "./settings-modal-scope";
15
+ export * from "./useProvidersPanel";