loopat 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 +201 -0
- package/README.md +194 -0
- package/bin/loopat.mjs +65 -0
- package/package.json +52 -0
- package/server/package.json +22 -0
- package/server/src/api-tokens.ts +161 -0
- package/server/src/api-v1-openapi.ts +363 -0
- package/server/src/api-v1.ts +681 -0
- package/server/src/auth.ts +309 -0
- package/server/src/bootstrap.ts +113 -0
- package/server/src/chat.ts +390 -0
- package/server/src/claude-binary.ts +68 -0
- package/server/src/compose.ts +474 -0
- package/server/src/config.ts +783 -0
- package/server/src/files.ts +173 -0
- package/server/src/git-crypt-key.ts +36 -0
- package/server/src/git-host.ts +104 -0
- package/server/src/github.ts +161 -0
- package/server/src/index.ts +3204 -0
- package/server/src/kanban.ts +810 -0
- package/server/src/loop-stats.ts +225 -0
- package/server/src/loop-status.ts +67 -0
- package/server/src/loops.ts +1832 -0
- package/server/src/mcp-oauth.ts +516 -0
- package/server/src/onboarding.ts +105 -0
- package/server/src/paths.ts +190 -0
- package/server/src/personal-keys.ts +60 -0
- package/server/src/plugin-installer.ts +287 -0
- package/server/src/podman.ts +1216 -0
- package/server/src/presets.ts +30 -0
- package/server/src/profiles.ts +177 -0
- package/server/src/providers.ts +45 -0
- package/server/src/serve.ts +275 -0
- package/server/src/session.ts +1496 -0
- package/server/src/system-prompt.ts +90 -0
- package/server/src/term.ts +211 -0
- package/server/src/tiers.ts +762 -0
- package/server/src/vaults.ts +189 -0
- package/server/src/workspace.ts +501 -0
- package/server/templates/.claude-plugin/marketplace.json +13 -0
- package/server/templates/CLAUDE.md +78 -0
- package/server/templates/loop-kinds/distill/CLAUDE.md +46 -0
- package/server/templates/plugins/loopat/.claude-plugin/plugin.json +5 -0
- package/server/templates/plugins/loopat/skills/onboarding/SKILL.md +266 -0
- package/server/templates/plugins/loopat/skills/promote/SKILL.md +53 -0
- package/server/templates/sandbox/Containerfile +113 -0
- package/web/dist/assets/CodeEditor-BGODueTo.js +49 -0
- package/web/dist/assets/Editor-DMS25Vve.js +1 -0
- package/web/dist/assets/Markdown-CnHbW7WK.js +5 -0
- package/web/dist/assets/MilkdownEditor-nqo9_0v5.js +123 -0
- package/web/dist/assets/Terminal-BrP-ENHg.css +1 -0
- package/web/dist/assets/Terminal-CYWvxYam.js +174 -0
- package/web/dist/assets/index-DM5eO-Tv.js +163 -0
- package/web/dist/assets/index-DxIFezwv.css +1 -0
- package/web/dist/assets/w3c-keyname-BOAvb0qz.js +1 -0
- package/web/dist/favicon.svg +1 -0
- package/web/dist/index.html +14 -0
- package/web/dist/logo.png +0 -0
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs"
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
|
3
|
+
import { dirname, join } from "node:path"
|
|
4
|
+
import {
|
|
5
|
+
personalLoopatConfigPath,
|
|
6
|
+
personalLoopatDir,
|
|
7
|
+
personalTokenUsagePath,
|
|
8
|
+
personalVaultDir,
|
|
9
|
+
personalVaultEnvPath,
|
|
10
|
+
personalVaultEnvsDir,
|
|
11
|
+
workspaceDir,
|
|
12
|
+
personalSettingsPath,
|
|
13
|
+
} from "./paths"
|
|
14
|
+
import { DEFAULT_VAULT, loadVaultEnvs } from "./vaults"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* MCP server config — shape matches Claude Agent SDK `McpServerConfig`.
|
|
18
|
+
* - stdio: spawn a command (binary must be reachable in sandbox PATH)
|
|
19
|
+
* - http/sse: connect to URL (network is shared with host, no extra bind needed)
|
|
20
|
+
*/
|
|
21
|
+
export type McpServerConfig =
|
|
22
|
+
| { type?: "stdio"; command: string; args?: string[]; env?: Record<string, string> }
|
|
23
|
+
| { type: "http"; url: string; headers?: Record<string, string> }
|
|
24
|
+
| { type: "sse"; url: string; headers?: Record<string, string> }
|
|
25
|
+
|
|
26
|
+
/** CC-native marketplace source. We support local + git + github in step 3. */
|
|
27
|
+
export type MarketplaceSource =
|
|
28
|
+
| { source: "local"; path: string }
|
|
29
|
+
| { source: "git"; url: string }
|
|
30
|
+
| { source: "github"; repo: string }
|
|
31
|
+
|
|
32
|
+
export type WorkspaceClaudeJson = {
|
|
33
|
+
mcpServers?: Record<string, McpServerConfig>
|
|
34
|
+
/** Marketplaces to register. CC-native shape: keyed by marketplace name. */
|
|
35
|
+
extraKnownMarketplaces?: Record<string, { source: MarketplaceSource }>
|
|
36
|
+
/** Plugins to enable. CC-native shape: { "name@market": true }. */
|
|
37
|
+
enabledPlugins?: Record<string, boolean>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A model entry within a provider's model list. */
|
|
41
|
+
export type ModelEntry = {
|
|
42
|
+
id: string
|
|
43
|
+
enabled?: boolean
|
|
44
|
+
/** Per-model context-window override (takes precedence over provider-level). */
|
|
45
|
+
maxContextTokens?: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type ProviderPreset = {
|
|
49
|
+
name: string
|
|
50
|
+
baseUrl: string
|
|
51
|
+
models: string[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type MiseToolPreset = {
|
|
55
|
+
name: string
|
|
56
|
+
suggestedVersion: string
|
|
57
|
+
description?: string
|
|
58
|
+
backend?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type PresetsData = {
|
|
62
|
+
providerPresets: ProviderPreset[]
|
|
63
|
+
miseToolPresets: MiseToolPreset[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* On-disk shape of a provider. `apiKey` is a plain string that may contain
|
|
68
|
+
* `${VAR}` references resolved against vault envs at load time. Empty / unset
|
|
69
|
+
* means no key (provider effectively disabled).
|
|
70
|
+
*/
|
|
71
|
+
export type ProviderConfigDisk = {
|
|
72
|
+
model?: string // legacy single-model; migrated to models[] on read
|
|
73
|
+
models?: ModelEntry[] // canonical multi-model format
|
|
74
|
+
baseUrl: string
|
|
75
|
+
apiKey?: string
|
|
76
|
+
maxContextTokens?: number
|
|
77
|
+
enabled?: boolean // provider-level toggle, default true
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Runtime/resolved shape — apiKey is the actual string after resolution. */
|
|
81
|
+
export type ProviderConfig = {
|
|
82
|
+
/** Canonical model list (at least one entry after migration). */
|
|
83
|
+
models: ModelEntry[]
|
|
84
|
+
baseUrl: string
|
|
85
|
+
/** Resolved at load time: `${VAR}` references in the disk apiKey are
|
|
86
|
+
* expanded against the active vault's envs/. Empty string if the
|
|
87
|
+
* referenced env doesn't exist (provider effectively disabled). */
|
|
88
|
+
apiKey: string
|
|
89
|
+
/**
|
|
90
|
+
* Override cli's context-window detection for this model. cli has a
|
|
91
|
+
* hardcoded list (DP / XV8 / coral_reef_sonnet predicates) of claude
|
|
92
|
+
* models that get 1M; everything else falls back to DR1=200000. For
|
|
93
|
+
* gateway-routed / non-claude models with larger windows, set this so
|
|
94
|
+
* auto-compact (92% × window) fires at the right point. Activated via
|
|
95
|
+
* env vars DISABLE_COMPACT=1 + CLAUDE_CODE_MAX_CONTEXT_TOKENS=<value>.
|
|
96
|
+
*/
|
|
97
|
+
maxContextTokens?: number
|
|
98
|
+
enabled: boolean
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type RemoteSpec = {
|
|
102
|
+
/** clone URL; empty string or omitted = local-only, don't clone */
|
|
103
|
+
git?: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A repo registered for spawn-loop use, cloned to context/repos/<name>/. */
|
|
107
|
+
export type RepoSpec = {
|
|
108
|
+
name: string
|
|
109
|
+
git: string
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Operator-side mount (workspace config). src is always a literal host path.
|
|
113
|
+
* Operator owns the host, so any path under `~/...`, `$HOME/...`, or `/...`
|
|
114
|
+
* is allowed (modulo `..` traversal). Used for cross-user shared caches
|
|
115
|
+
* (e.g. /etc/pki/ca-trust). */
|
|
116
|
+
export type OperatorMount = {
|
|
117
|
+
src: string
|
|
118
|
+
dst: string
|
|
119
|
+
rw?: boolean
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Workspace config (~/.loopat/config.json): workspace-shared, no per-user content.
|
|
124
|
+
* Hand this file to a clean machine and bootstrap can reconstruct the
|
|
125
|
+
* workspace: clone knowledge/notes/repos from remotes, seed doctrine.
|
|
126
|
+
*
|
|
127
|
+
* Per-user pieces (sandbox, providers, default provider) live in
|
|
128
|
+
* personal/<user>/.loopat/config.json — see PersonalConfig.
|
|
129
|
+
*/
|
|
130
|
+
export type WorkspaceConfig = {
|
|
131
|
+
knowledge?: RemoteSpec
|
|
132
|
+
notes?: RemoteSpec
|
|
133
|
+
repos?: RepoSpec[]
|
|
134
|
+
providers?: Record<string, ProviderConfig>
|
|
135
|
+
default?: string
|
|
136
|
+
/** Platform-level git host for personal onboarding. `provider` is a
|
|
137
|
+
* GitHostProvider id (= the extension filename, e.g. "code"); empty/absent
|
|
138
|
+
* means GitHub. `baseUrl` is the API host for self-hosted/internal. */
|
|
139
|
+
gitHost?: { provider?: string; baseUrl?: string; defaultRepo?: string }
|
|
140
|
+
/** Operator-level mounts — any host path. Shared across all loops on this
|
|
141
|
+
* workspace. Only the operator (the host shell user) can edit. */
|
|
142
|
+
mounts?: OperatorMount[]
|
|
143
|
+
/** Domain suffix for workspace serve (e.g. "nip.io"). Defaults to "nip.io". */
|
|
144
|
+
serveDomain?: string
|
|
145
|
+
/** Whether to include port in the share URL. */
|
|
146
|
+
serveWithPort?: boolean
|
|
147
|
+
/** Whether to use HTTPS for share URLs. */
|
|
148
|
+
serveHttps?: boolean
|
|
149
|
+
/** Custom port to show in share URL (does not affect actual server listen port). */
|
|
150
|
+
serveDisplayPort?: number
|
|
151
|
+
/** Enable standard serve (subdomain-based, serve-rs). */
|
|
152
|
+
serveEnabled?: boolean
|
|
153
|
+
/** Enable dynamic port forwarding (port-proxy). */
|
|
154
|
+
serveDynamicEnabled?: boolean
|
|
155
|
+
/** Domain or IP for dynamic port access URLs (empty = auto-detect IP). */
|
|
156
|
+
serveDynamicDomain?: string
|
|
157
|
+
/** Port range for dynamic port forwarding (e.g., "10000-20000"). */
|
|
158
|
+
serveDynamicPortRange?: string
|
|
159
|
+
/** Whether to allow UDP protocol in dynamic port forwarding. */
|
|
160
|
+
serveDynamicUdpEnabled?: boolean
|
|
161
|
+
/** Whether dynamic ports can serve static files from workdir. */
|
|
162
|
+
serveDynamicStaticEnabled?: boolean
|
|
163
|
+
/** Enable ephemeral-port mode: each loop container publishes its share
|
|
164
|
+
* port via `podman -p :<internal>`, kernel-assigned host port, fresh
|
|
165
|
+
* every container restart. No port-proxy involved. */
|
|
166
|
+
serveEphemeralEnabled?: boolean
|
|
167
|
+
/** Domain or IP for ephemeral-port access URLs (empty = auto-detect IP). */
|
|
168
|
+
serveEphemeralDomain?: string
|
|
169
|
+
/** Admin-managed presets for quick-add in provider/mise tool configs. */
|
|
170
|
+
presets?: PresetsData
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Personal config (personal/<user>/.loopat/config.json): per-user, kept in
|
|
175
|
+
* each driver's personal/ tree.
|
|
176
|
+
*
|
|
177
|
+
* On-disk layout:
|
|
178
|
+
* - `providers` is a heterogeneous map: a special key `"default"` carries
|
|
179
|
+
* a string (the active provider name); all other keys map to
|
|
180
|
+
* `ProviderConfigDisk`. We accept the slight type wobble in exchange
|
|
181
|
+
* for keeping every provider-related field under one section, which
|
|
182
|
+
* matches how the Settings UI groups them. No provider is allowed to
|
|
183
|
+
* be literally named "default".
|
|
184
|
+
* - `apiKey` is a plain string that may contain `${VAR}` references. At
|
|
185
|
+
* load time, each `${VAR}` is resolved against the active vault's
|
|
186
|
+
* `envs/<VAR>` file. Unset → empty string (provider effectively off).
|
|
187
|
+
* - Sandbox env vars and CLI config mounts are conventional, not declared:
|
|
188
|
+
* anything in `vault/envs/*` is auto-injected, anything in
|
|
189
|
+
* `vault/mounts/home/<rel>/...` is auto-bound at $HOME/<rel>/...
|
|
190
|
+
* There is no `envs` or `mounts` field — filesystem layout IS the spec.
|
|
191
|
+
*/
|
|
192
|
+
/**
|
|
193
|
+
* Onboarding state per user. Used by the Welcome card on Loops list to
|
|
194
|
+
* decide whether to show "start onboarding" / "continue" / nothing.
|
|
195
|
+
*
|
|
196
|
+
* - `started`: a loop was spawned, but the user hasn't marked finished
|
|
197
|
+
* (`loopId` points at the in-progress onboarding loop).
|
|
198
|
+
* - `done`: user clicked skip/complete OR finished naturally. Card hides.
|
|
199
|
+
*/
|
|
200
|
+
export type OnboardingState = {
|
|
201
|
+
status: "started" | "done"
|
|
202
|
+
loopId?: string
|
|
203
|
+
at: string
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export type PersonalConfigDisk = {
|
|
207
|
+
/** Mixed: "default" key is a string, all other keys are providers. */
|
|
208
|
+
providers: Record<string, ProviderConfigDisk | string>
|
|
209
|
+
/** PTY shell override (highest precedence). */
|
|
210
|
+
shell?: string
|
|
211
|
+
/** Optional. Missing = "fresh" (user hasn't started or dismissed yet). */
|
|
212
|
+
onboarding?: OnboardingState
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export type PersonalConfig = {
|
|
216
|
+
/** Active provider name. On disk this lives at `providers.default`. */
|
|
217
|
+
default: string
|
|
218
|
+
providers: Record<string, ProviderConfig>
|
|
219
|
+
/**
|
|
220
|
+
* Resolved env vars from the active vault's `envs/` dir. Filename → value.
|
|
221
|
+
* Used to (a) inject into spawn env so spawned binary's `${VAR}` substitution
|
|
222
|
+
* in mcpServers works, and (b) substitute `${VAR}` in provider.apiKey.
|
|
223
|
+
*/
|
|
224
|
+
vaultEnvs: Record<string, string>
|
|
225
|
+
shell?: string
|
|
226
|
+
onboarding?: OnboardingState
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Parse a default selector string. Supports two formats:
|
|
231
|
+
* - "providerName/modelId" (new) → { providerName, modelId }
|
|
232
|
+
* - "providerName" (legacy) → { providerName }
|
|
233
|
+
* Backward-compatible: if no "/" is present, the whole string is the provider name.
|
|
234
|
+
*/
|
|
235
|
+
export function parseDefault(raw: string): { providerName: string; modelId?: string } {
|
|
236
|
+
if (!raw) return { providerName: "" }
|
|
237
|
+
const slashIdx = raw.indexOf("/")
|
|
238
|
+
if (slashIdx <= 0) return { providerName: raw }
|
|
239
|
+
return {
|
|
240
|
+
providerName: raw.slice(0, slashIdx),
|
|
241
|
+
modelId: raw.slice(slashIdx + 1) || undefined,
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Preset providers with Anthropic-compatible endpoints. loopat uses the
|
|
246
|
+
* Claude Agent SDK which speaks the Anthropic Messages API — only providers
|
|
247
|
+
* that expose an Anthropic-compatible endpoint work directly.
|
|
248
|
+
* Each provider is disabled by default; the user supplies an API key. */
|
|
249
|
+
import { PROVIDER_PRESETS } from "./presets"
|
|
250
|
+
|
|
251
|
+
function buildPresetProviders(): Record<string, ProviderConfig> {
|
|
252
|
+
return Object.fromEntries(
|
|
253
|
+
PROVIDER_PRESETS.map(p => [
|
|
254
|
+
p.name,
|
|
255
|
+
{
|
|
256
|
+
models: p.models.map(id => ({ id, enabled: true })),
|
|
257
|
+
baseUrl: p.baseUrl,
|
|
258
|
+
apiKey: "",
|
|
259
|
+
enabled: false,
|
|
260
|
+
} satisfies ProviderConfig,
|
|
261
|
+
]),
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const WORKSPACE_TEMPLATE: WorkspaceConfig = {
|
|
266
|
+
knowledge: { git: "" },
|
|
267
|
+
notes: { git: "" },
|
|
268
|
+
repos: [
|
|
269
|
+
{ name: "loopat", git: "git@github.com:simpx/loopat.git" },
|
|
270
|
+
],
|
|
271
|
+
providers: buildPresetProviders(),
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const PERSONAL_TEMPLATE: PersonalConfig = {
|
|
275
|
+
default: PROVIDER_PRESETS[0] ? `${PROVIDER_PRESETS[0].name}/${PROVIDER_PRESETS[0].models[0]}` : "",
|
|
276
|
+
providers: buildPresetProviders(),
|
|
277
|
+
vaultEnvs: {},
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** On-disk shape used when a config.json is missing or malformed. Seeded
|
|
281
|
+
* with presets so the user has a populated model list immediately. */
|
|
282
|
+
const PERSONAL_DISK_TEMPLATE: PersonalConfigDisk = {
|
|
283
|
+
providers: (() => {
|
|
284
|
+
const providers: Record<string, ProviderConfigDisk | string> = {
|
|
285
|
+
default: PROVIDER_PRESETS[0] ? `${PROVIDER_PRESETS[0].name}/${PROVIDER_PRESETS[0].models[0]}` : "",
|
|
286
|
+
}
|
|
287
|
+
for (const p of PROVIDER_PRESETS) {
|
|
288
|
+
providers[p.name] = {
|
|
289
|
+
models: p.models.map(id => ({ id, enabled: true })),
|
|
290
|
+
baseUrl: p.baseUrl,
|
|
291
|
+
enabled: false,
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return providers
|
|
295
|
+
})(),
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export const configPath = () => join(workspaceDir(), "config.json")
|
|
299
|
+
|
|
300
|
+
let cachedWorkspace: WorkspaceConfig | null = null
|
|
301
|
+
let cachedWorkspaceMtimeMs = 0
|
|
302
|
+
|
|
303
|
+
export async function loadConfig(): Promise<WorkspaceConfig> {
|
|
304
|
+
const path = configPath()
|
|
305
|
+
if (!existsSync(path)) {
|
|
306
|
+
await mkdir(workspaceDir(), { recursive: true })
|
|
307
|
+
await writeFile(path, JSON.stringify(WORKSPACE_TEMPLATE, null, 2) + "\n")
|
|
308
|
+
console.warn(`[loopat] config: created template at ${path}`)
|
|
309
|
+
cachedWorkspace = WORKSPACE_TEMPLATE
|
|
310
|
+
cachedWorkspaceMtimeMs = statSync(path).mtimeMs
|
|
311
|
+
return cachedWorkspace
|
|
312
|
+
}
|
|
313
|
+
// Re-read on mtime change so edits take effect on next attach without a
|
|
314
|
+
// server restart.
|
|
315
|
+
const mtimeMs = statSync(path).mtimeMs
|
|
316
|
+
if (cachedWorkspace && mtimeMs === cachedWorkspaceMtimeMs) return cachedWorkspace
|
|
317
|
+
const raw = await readFile(path, "utf8")
|
|
318
|
+
const parsed = JSON.parse(raw) as WorkspaceConfig
|
|
319
|
+
// Normalize legacy single-model providers to canonical models[] format.
|
|
320
|
+
if (parsed.providers) {
|
|
321
|
+
for (const [name, p] of Object.entries(parsed.providers)) {
|
|
322
|
+
const disk = p as any
|
|
323
|
+
if (!disk.models && disk.model) {
|
|
324
|
+
;(p as any).models = [{ id: disk.model, enabled: true }]
|
|
325
|
+
}
|
|
326
|
+
if (p.enabled === undefined) (p as any).enabled = true
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
cachedWorkspace = parsed
|
|
330
|
+
cachedWorkspaceMtimeMs = mtimeMs
|
|
331
|
+
return cachedWorkspace
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Cache key = `${user}|${vault}` so per-vault apiKey/env resolutions don't
|
|
335
|
+
// clobber each other.
|
|
336
|
+
const personalCache = new Map<string, {
|
|
337
|
+
cfg: PersonalConfig
|
|
338
|
+
configMtimeMs: number
|
|
339
|
+
/** Snapshot of the vault envs dir mtime; if the dir changes (file added /
|
|
340
|
+
* removed / value edited) we re-resolve. We don't track per-file mtimes
|
|
341
|
+
* because vault envs are small enough to re-walk cheaply on miss. */
|
|
342
|
+
envsDirMtimeMs: number
|
|
343
|
+
}>()
|
|
344
|
+
|
|
345
|
+
export function clearPersonalCache(user: string): void {
|
|
346
|
+
for (const k of personalCache.keys()) {
|
|
347
|
+
if (k === user || k.startsWith(`${user}|`)) personalCache.delete(k)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const VAR_REF_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g
|
|
352
|
+
|
|
353
|
+
/** Substitute every `${VAR}` in a template against the env map. Unknown
|
|
354
|
+
* vars resolve to empty string. Literal strings (no $) pass through. */
|
|
355
|
+
export function expandVars(template: string, envs: Record<string, string>): string {
|
|
356
|
+
if (!template || !template.includes("${")) return template
|
|
357
|
+
return template.replace(VAR_REF_RE, (_, name) => envs[name] ?? "")
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Load personal config from personal/<user>/.loopat/config.json. Resolves
|
|
362
|
+
* each provider's apiKey + every env entry against the selected vault.
|
|
363
|
+
*
|
|
364
|
+
* Missing config.json → in-memory empty template (do NOT lazy-write it; the
|
|
365
|
+
* vault may have been intentionally deleted).
|
|
366
|
+
*/
|
|
367
|
+
export async function loadPersonalConfig(
|
|
368
|
+
user: string,
|
|
369
|
+
vault: string = DEFAULT_VAULT,
|
|
370
|
+
): Promise<PersonalConfig> {
|
|
371
|
+
const path = personalLoopatConfigPath(user)
|
|
372
|
+
if (!existsSync(path)) {
|
|
373
|
+
return JSON.parse(JSON.stringify(PERSONAL_TEMPLATE)) as PersonalConfig
|
|
374
|
+
}
|
|
375
|
+
const configMtimeMs = statSync(path).mtimeMs
|
|
376
|
+
const envsDir = personalVaultEnvsDir(user, vault)
|
|
377
|
+
const envsDirMtimeMs = existsSync(envsDir) ? statSync(envsDir).mtimeMs : 0
|
|
378
|
+
const cacheKey = `${user}|${vault}`
|
|
379
|
+
const cached = personalCache.get(cacheKey)
|
|
380
|
+
if (
|
|
381
|
+
cached &&
|
|
382
|
+
cached.configMtimeMs === configMtimeMs &&
|
|
383
|
+
cached.envsDirMtimeMs === envsDirMtimeMs
|
|
384
|
+
) {
|
|
385
|
+
return cached.cfg
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const raw = await readFile(path, "utf8")
|
|
389
|
+
let disk: PersonalConfigDisk
|
|
390
|
+
try {
|
|
391
|
+
disk = JSON.parse(raw) as PersonalConfigDisk
|
|
392
|
+
if (!disk.providers || typeof disk.providers !== "object") {
|
|
393
|
+
throw new Error(`missing providers`)
|
|
394
|
+
}
|
|
395
|
+
} catch (e: any) {
|
|
396
|
+
console.warn(`[loopat] personal config: ${path} is malformed (${e?.message ?? e}), rewriting template`)
|
|
397
|
+
await writeFile(path, JSON.stringify(PERSONAL_DISK_TEMPLATE, null, 2) + "\n")
|
|
398
|
+
disk = JSON.parse(JSON.stringify(PERSONAL_DISK_TEMPLATE)) as PersonalConfigDisk
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Vault envs feed both the spawn env and ${VAR} substitution in apiKey.
|
|
402
|
+
const vaultEnvs = await loadVaultEnvs(user, vault)
|
|
403
|
+
|
|
404
|
+
// Split the heterogeneous providers map: pull out the special "default"
|
|
405
|
+
// string key, leave the rest as provider entries.
|
|
406
|
+
const rawDefault = typeof disk.providers.default === "string" ? disk.providers.default : ""
|
|
407
|
+
const { providerName: defaultProviderName } = parseDefault(rawDefault)
|
|
408
|
+
const providerEntries: Array<[string, ProviderConfigDisk]> = []
|
|
409
|
+
for (const [name, val] of Object.entries(disk.providers)) {
|
|
410
|
+
if (name === "default") continue
|
|
411
|
+
if (val && typeof val === "object") providerEntries.push([name, val as ProviderConfigDisk])
|
|
412
|
+
}
|
|
413
|
+
if (defaultProviderName && !providerEntries.some(([n]) => n === defaultProviderName)) {
|
|
414
|
+
console.warn(`[loopat] personal config: default "${rawDefault}" provider "${defaultProviderName}" not in providers (ignored)`)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const providers: Record<string, ProviderConfig> = {}
|
|
418
|
+
for (const [name, p] of providerEntries) {
|
|
419
|
+
let apiKey = ""
|
|
420
|
+
if (typeof p.apiKey === "string") {
|
|
421
|
+
apiKey = expandVars(p.apiKey, vaultEnvs)
|
|
422
|
+
} else if (p.apiKey && typeof (p.apiKey as any).vault === "string") {
|
|
423
|
+
// Resolve { vault: "provider-keys/DeepSeek" } format
|
|
424
|
+
const vaultPath = join(personalVaultDir(user, vault), (p.apiKey as any).vault as string)
|
|
425
|
+
try { apiKey = (await readFile(vaultPath, "utf8")).trim() } catch {}
|
|
426
|
+
}
|
|
427
|
+
// Normalize legacy single-model to canonical models[] format.
|
|
428
|
+
const models: ModelEntry[] = p.models && p.models.length > 0
|
|
429
|
+
? p.models.map(m => ({ id: m.id, enabled: m.enabled !== false }))
|
|
430
|
+
: (p.model ? [{ id: p.model, enabled: true }] : [])
|
|
431
|
+
providers[name] = {
|
|
432
|
+
models,
|
|
433
|
+
baseUrl: p.baseUrl,
|
|
434
|
+
apiKey,
|
|
435
|
+
enabled: p.enabled !== false,
|
|
436
|
+
...(p.maxContextTokens ? { maxContextTokens: p.maxContextTokens } : {}),
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const cfg: PersonalConfig = {
|
|
441
|
+
default: defaultProviderName && providers[defaultProviderName] ? rawDefault : "",
|
|
442
|
+
providers,
|
|
443
|
+
vaultEnvs,
|
|
444
|
+
...(disk.shell ? { shell: disk.shell } : {}),
|
|
445
|
+
...(disk.onboarding ? { onboarding: disk.onboarding } : {}),
|
|
446
|
+
}
|
|
447
|
+
personalCache.set(cacheKey, { cfg, configMtimeMs, envsDirMtimeMs })
|
|
448
|
+
return cfg
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function getActiveProvider(cfg: PersonalConfig): { name: string; provider: ProviderConfig } | null {
|
|
452
|
+
const raw = cfg.default
|
|
453
|
+
if (!raw) return null
|
|
454
|
+
const { providerName } = parseDefault(raw)
|
|
455
|
+
if (!providerName || !cfg.providers[providerName]) return null
|
|
456
|
+
return { name: providerName, provider: cfg.providers[providerName] }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Per-user Claude config. Same JSON shape as workspace claude.json. Personal
|
|
461
|
+
* `mcpServers[<name>]` entries shadow workspace entries by name (user-tier
|
|
462
|
+
* wins over admin-tier — consistent with the skill/plugin compose model).
|
|
463
|
+
*/
|
|
464
|
+
export async function loadPersonalClaudeJson(user: string): Promise<WorkspaceClaudeJson> {
|
|
465
|
+
const p = personalSettingsPath(user)
|
|
466
|
+
if (!existsSync(p)) return {}
|
|
467
|
+
try {
|
|
468
|
+
return JSON.parse(await readFile(p, "utf8")) as WorkspaceClaudeJson
|
|
469
|
+
} catch (e: any) {
|
|
470
|
+
console.warn(`[loopat] personal claude.json malformed at ${p}: ${e?.message ?? e}`)
|
|
471
|
+
return {}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ── token usage ──
|
|
476
|
+
|
|
477
|
+
export type TokenUsage = Record<string, { inputTokens: number; outputTokens: number }>
|
|
478
|
+
|
|
479
|
+
export async function loadTokenUsage(user: string): Promise<TokenUsage> {
|
|
480
|
+
const p = personalTokenUsagePath(user)
|
|
481
|
+
if (!existsSync(p)) return {}
|
|
482
|
+
try {
|
|
483
|
+
return JSON.parse(await readFile(p, "utf8")) as TokenUsage
|
|
484
|
+
} catch {
|
|
485
|
+
return {}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export async function saveTokenUsage(user: string, usage: TokenUsage): Promise<void> {
|
|
490
|
+
await mkdir(personalLoopatDir(user), { recursive: true })
|
|
491
|
+
await writeFile(personalTokenUsagePath(user), JSON.stringify(usage, null, 2) + "\n")
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export async function addTokenUsage(user: string, model: string, inputTokens: number, outputTokens: number): Promise<void> {
|
|
495
|
+
if (!model || (inputTokens === 0 && outputTokens === 0)) return
|
|
496
|
+
const usage = await loadTokenUsage(user)
|
|
497
|
+
const entry = usage[model] ?? { inputTokens: 0, outputTokens: 0 }
|
|
498
|
+
entry.inputTokens += inputTokens
|
|
499
|
+
entry.outputTokens += outputTokens
|
|
500
|
+
usage[model] = entry
|
|
501
|
+
await saveTokenUsage(user, usage)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ── config persistence ──
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Read the raw on-disk shape (without resolving any references). Used by
|
|
508
|
+
* savers that need to preserve existing apiKey/env reference structure.
|
|
509
|
+
*/
|
|
510
|
+
export async function readPersonalDiskRaw(user: string): Promise<PersonalConfigDisk> {
|
|
511
|
+
return readPersonalDisk(user)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* For an apiKey string that may contain `${VAR}` references, describe the
|
|
516
|
+
* shape so the Settings UI can render "✓ exists / ✗ missing" indicators
|
|
517
|
+
* without leaking the value.
|
|
518
|
+
*
|
|
519
|
+
* - "literal" : no `${VAR}` ref; value is the literal text (or empty)
|
|
520
|
+
* - "var" : exactly one `${VAR}` ref; reports whether the vault env
|
|
521
|
+
* file `envs/<VAR>` exists
|
|
522
|
+
* - "mixed" : multiple refs or template+text; existence not surfaced
|
|
523
|
+
*/
|
|
524
|
+
export function describeApiKeyRef(
|
|
525
|
+
apiKey: string | undefined,
|
|
526
|
+
user: string,
|
|
527
|
+
vault: string = DEFAULT_VAULT,
|
|
528
|
+
): { kind: "literal" | "var" | "mixed" | "empty"; varName?: string; path?: string; exists: boolean } {
|
|
529
|
+
// Handle { vault: "..." } object format
|
|
530
|
+
if (typeof apiKey !== "string") {
|
|
531
|
+
if (apiKey && typeof (apiKey as any).vault === "string") {
|
|
532
|
+
const vaultPath = join(personalVaultDir(user, vault), (apiKey as any).vault as string)
|
|
533
|
+
return { kind: "var", varName: (apiKey as any).vault, path: vaultPath, exists: existsSync(vaultPath) }
|
|
534
|
+
}
|
|
535
|
+
return { kind: "empty", exists: false }
|
|
536
|
+
}
|
|
537
|
+
if (!apiKey) return { kind: "empty", exists: false }
|
|
538
|
+
const matches = [...apiKey.matchAll(VAR_REF_RE)]
|
|
539
|
+
if (matches.length === 0) return { kind: "literal", exists: true }
|
|
540
|
+
if (matches.length === 1 && matches[0][0] === apiKey) {
|
|
541
|
+
const name = matches[0][1]
|
|
542
|
+
const path = personalVaultEnvPath(user, vault, name)
|
|
543
|
+
return { kind: "var", varName: name, path, exists: existsSync(path) }
|
|
544
|
+
}
|
|
545
|
+
return { kind: "mixed", exists: false }
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Apply a structural patch to personal/<user>/.loopat/config.json. Accepts
|
|
550
|
+
* partial fields from `PersonalConfigDisk`; only fields present on the
|
|
551
|
+
* patch are touched. Does NOT write any secret values — apiKey values
|
|
552
|
+
* referenced as `${VAR}` are managed via `writeVaultEnv()`.
|
|
553
|
+
*/
|
|
554
|
+
export async function savePersonalDisk(
|
|
555
|
+
user: string,
|
|
556
|
+
patch: Partial<PersonalConfigDisk>,
|
|
557
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
558
|
+
const disk = await readPersonalDisk(user)
|
|
559
|
+
if (patch.providers !== undefined) {
|
|
560
|
+
for (const [name, val] of Object.entries(patch.providers)) {
|
|
561
|
+
if (name === "default") {
|
|
562
|
+
if (typeof val !== "string") return { ok: false, error: `providers.default must be a string` }
|
|
563
|
+
continue
|
|
564
|
+
}
|
|
565
|
+
if (!val || typeof val !== "object" || Array.isArray(val)) {
|
|
566
|
+
return { ok: false, error: `provider "${name}" must be an object` }
|
|
567
|
+
}
|
|
568
|
+
const p = val as ProviderConfigDisk
|
|
569
|
+
const hasModels = Array.isArray(p.models) && p.models.length > 0
|
|
570
|
+
const hasModel = typeof p.model === "string"
|
|
571
|
+
if (!hasModels && !hasModel) {
|
|
572
|
+
return { ok: false, error: `provider "${name}" missing models (or legacy model)` }
|
|
573
|
+
}
|
|
574
|
+
if (typeof p.baseUrl !== "string") {
|
|
575
|
+
return { ok: false, error: `provider "${name}" missing baseUrl` }
|
|
576
|
+
}
|
|
577
|
+
if (p.apiKey !== undefined && typeof p.apiKey !== "string" && !(typeof p.apiKey === "object" && typeof (p.apiKey as any).vault === "string")) {
|
|
578
|
+
return { ok: false, error: `provider "${name}" apiKey must be a string or { vault }` }
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
const defName = patch.providers.default
|
|
582
|
+
if (typeof defName === "string" && defName) {
|
|
583
|
+
const { providerName } = parseDefault(defName)
|
|
584
|
+
const exists = Object.entries(patch.providers).some(([n, v]) => n !== "default" && n === providerName && typeof v === "object")
|
|
585
|
+
if (!exists) return { ok: false, error: `default "${defName}" provider "${providerName}" not in providers` }
|
|
586
|
+
}
|
|
587
|
+
// Force enabled: false for providers without an apiKey reference.
|
|
588
|
+
for (const [name, val] of Object.entries(patch.providers)) {
|
|
589
|
+
if (name === "default" || !val || typeof val !== "object") continue
|
|
590
|
+
const p = val as ProviderConfigDisk
|
|
591
|
+
if (p.enabled !== false) {
|
|
592
|
+
const hasNewKey = (typeof p.apiKey === "string" && p.apiKey.length > 0) || (p.apiKey && typeof (p.apiKey as any).vault === "string")
|
|
593
|
+
const existingEntry = disk.providers[name]
|
|
594
|
+
const existingKey = (existingEntry && typeof existingEntry === "object") ? (existingEntry as ProviderConfigDisk).apiKey : undefined
|
|
595
|
+
if (!hasNewKey && !existingKey) {
|
|
596
|
+
p.enabled = false
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
disk.providers = patch.providers
|
|
601
|
+
}
|
|
602
|
+
if (patch.shell !== undefined) {
|
|
603
|
+
if (typeof patch.shell !== "string") return { ok: false, error: `shell must be a string` }
|
|
604
|
+
disk.shell = patch.shell || undefined
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
await mkdir(personalLoopatDir(user), { recursive: true })
|
|
608
|
+
await writeFile(personalLoopatConfigPath(user), JSON.stringify(disk, null, 2) + "\n")
|
|
609
|
+
clearPersonalCache(user)
|
|
610
|
+
return { ok: true }
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Write a value to the vault's `envs/<NAME>` file. Used when the Settings UI
|
|
617
|
+
* stores a fresh apiKey / token value. Caller chooses the variable name; we
|
|
618
|
+
* just validate and write. Re-reading the personal config picks up the value
|
|
619
|
+
* automatically via `${VAR}` substitution.
|
|
620
|
+
*/
|
|
621
|
+
export async function writeVaultEnv(
|
|
622
|
+
user: string,
|
|
623
|
+
vault: string,
|
|
624
|
+
name: string,
|
|
625
|
+
value: string,
|
|
626
|
+
): Promise<{ ok: true; path: string } | { ok: false; error: string }> {
|
|
627
|
+
if (!ENV_NAME_RE.test(name)) return { ok: false, error: `invalid env name "${name}"` }
|
|
628
|
+
const writeAt = personalVaultEnvPath(user, vault, name)
|
|
629
|
+
await mkdir(dirname(writeAt), { recursive: true })
|
|
630
|
+
await writeFile(writeAt, value.replace(/\r?\n+$/, "") + "\n")
|
|
631
|
+
clearPersonalCache(user)
|
|
632
|
+
return { ok: true, path: writeAt }
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Delete a vault env file. No-op if missing. */
|
|
636
|
+
export async function deleteVaultEnv(user: string, vault: string, name: string): Promise<void> {
|
|
637
|
+
if (!ENV_NAME_RE.test(name)) return
|
|
638
|
+
const p = personalVaultEnvPath(user, vault, name)
|
|
639
|
+
if (existsSync(p)) {
|
|
640
|
+
const { rm } = await import("node:fs/promises")
|
|
641
|
+
await rm(p, { force: true })
|
|
642
|
+
}
|
|
643
|
+
clearPersonalCache(user)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async function readPersonalDisk(user: string): Promise<PersonalConfigDisk> {
|
|
647
|
+
const path = personalLoopatConfigPath(user)
|
|
648
|
+
if (!existsSync(path)) {
|
|
649
|
+
return JSON.parse(JSON.stringify(PERSONAL_DISK_TEMPLATE)) as PersonalConfigDisk
|
|
650
|
+
}
|
|
651
|
+
try {
|
|
652
|
+
const parsed = JSON.parse(await readFile(path, "utf8")) as PersonalConfigDisk
|
|
653
|
+
if (!parsed.providers || typeof parsed.providers !== "object") parsed.providers = {}
|
|
654
|
+
return parsed
|
|
655
|
+
} catch {
|
|
656
|
+
return JSON.parse(JSON.stringify(PERSONAL_DISK_TEMPLATE)) as PersonalConfigDisk
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Save personal config to disk. Provider apiKey values are stored in vault
|
|
662
|
+
* envs/<NAME>_API_KEY (NAME = uppercase provider name); config.json carries
|
|
663
|
+
* a `${VAR}` reference. `default` lives at `providers.default` inside the
|
|
664
|
+
* providers map.
|
|
665
|
+
*/
|
|
666
|
+
export async function savePersonalConfig(user: string, cfg: {
|
|
667
|
+
default?: string
|
|
668
|
+
providers?: Record<string, { model?: string; models?: ModelEntry[]; baseUrl: string; apiKey?: string; maxContextTokens?: number; enabled?: boolean }>
|
|
669
|
+
}): Promise<void> {
|
|
670
|
+
const disk = await readPersonalDisk(user)
|
|
671
|
+
const existingDefault = typeof disk.providers.default === "string" ? disk.providers.default : ""
|
|
672
|
+
|
|
673
|
+
if (cfg.providers !== undefined) {
|
|
674
|
+
const rebuilt: Record<string, ProviderConfigDisk | string> = {}
|
|
675
|
+
const nextDefault = cfg.default !== undefined ? cfg.default : existingDefault
|
|
676
|
+
if (nextDefault) rebuilt.default = nextDefault
|
|
677
|
+
for (const [name, p] of Object.entries(cfg.providers)) {
|
|
678
|
+
if (name === "default") {
|
|
679
|
+
console.warn(`[loopat] savePersonalConfig: ignored provider named "default" (reserved key)`)
|
|
680
|
+
continue
|
|
681
|
+
}
|
|
682
|
+
const existingEntry = disk.providers[name]
|
|
683
|
+
const existingKey = (existingEntry && typeof existingEntry === "object") ? existingEntry.apiKey : undefined
|
|
684
|
+
// Decide the apiKey field for disk:
|
|
685
|
+
// - If the user passed a new value, derive the env var name and stash
|
|
686
|
+
// the literal value into vault envs/<VAR>, then write a `${VAR}` ref.
|
|
687
|
+
// - Else keep whatever was there.
|
|
688
|
+
const defaultVar = providerEnvVarName(name)
|
|
689
|
+
let apiKeyField: string | undefined = existingKey
|
|
690
|
+
const hasNewKey = p.apiKey !== undefined && p.apiKey.trim() !== ""
|
|
691
|
+
if (hasNewKey) {
|
|
692
|
+
// If existing ref is a `${VAR}` template, reuse its var name; otherwise
|
|
693
|
+
// pick a deterministic default like ANTHROPIC_API_KEY for "Anthropic".
|
|
694
|
+
const targetVar = (existingKey && extractSingleVarName(existingKey)) ?? defaultVar
|
|
695
|
+
await writeVaultEnv(user, DEFAULT_VAULT, targetVar, p.apiKey!.trim())
|
|
696
|
+
apiKeyField = `\${${targetVar}}`
|
|
697
|
+
} else if (!apiKeyField) {
|
|
698
|
+
// No new key, no existing key → leave field unset (provider disabled).
|
|
699
|
+
apiKeyField = undefined
|
|
700
|
+
}
|
|
701
|
+
const models: ModelEntry[] = p.models && p.models.length > 0
|
|
702
|
+
? p.models.map(m => ({ id: m.id, ...(m.enabled === false ? { enabled: false } : {}) }))
|
|
703
|
+
: (p.model ? [{ id: p.model, enabled: true }] : [])
|
|
704
|
+
rebuilt[name] = {
|
|
705
|
+
baseUrl: p.baseUrl,
|
|
706
|
+
...(apiKeyField !== undefined ? { apiKey: apiKeyField } : {}),
|
|
707
|
+
...(models.length > 0 ? { models } : {}),
|
|
708
|
+
...(p.maxContextTokens ? { maxContextTokens: p.maxContextTokens } : {}),
|
|
709
|
+
...(p.enabled === false ? { enabled: false } : {}),
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
disk.providers = rebuilt
|
|
713
|
+
} else if (cfg.default !== undefined) {
|
|
714
|
+
const rebuilt: Record<string, ProviderConfigDisk | string> = {}
|
|
715
|
+
if (cfg.default) rebuilt.default = cfg.default
|
|
716
|
+
for (const [name, val] of Object.entries(disk.providers)) {
|
|
717
|
+
if (name === "default") continue
|
|
718
|
+
rebuilt[name] = val
|
|
719
|
+
}
|
|
720
|
+
disk.providers = rebuilt
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
await mkdir(personalLoopatDir(user), { recursive: true })
|
|
724
|
+
await writeFile(personalLoopatConfigPath(user), JSON.stringify(disk, null, 2) + "\n")
|
|
725
|
+
clearPersonalCache(user)
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Derive a default vault env var name from a provider name.
|
|
729
|
+
* "Anthropic" → "ANTHROPIC_API_KEY"; "DeepSeek" → "DEEPSEEK_API_KEY". */
|
|
730
|
+
export function providerEnvVarName(providerName: string): string {
|
|
731
|
+
const sanitized = providerName.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase()
|
|
732
|
+
return `${sanitized || "PROVIDER"}_API_KEY`
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** If `template` is exactly `${X}` (one ref, nothing else), return X. Else null. */
|
|
736
|
+
function extractSingleVarName(template: string): string | null {
|
|
737
|
+
const m = template.match(/^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/)
|
|
738
|
+
return m ? m[1] : null
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** Save workspace config to disk. Only provided fields are overwritten.
|
|
742
|
+
* Preserves existing apiKeys unless explicitly replaced. */
|
|
743
|
+
export async function saveWorkspaceConfig(cfg: Partial<WorkspaceConfig>): Promise<void> {
|
|
744
|
+
const existing = await loadConfig()
|
|
745
|
+
const merged: WorkspaceConfig = { ...existing }
|
|
746
|
+
if (cfg.providers !== undefined) {
|
|
747
|
+
merged.providers = merged.providers ?? {}
|
|
748
|
+
for (const [name, p] of Object.entries(cfg.providers)) {
|
|
749
|
+
const existingProv = merged.providers[name]
|
|
750
|
+
const incoming = p as any
|
|
751
|
+
// Normalize to canonical models[] format.
|
|
752
|
+
const models: ModelEntry[] = incoming.models?.length > 0
|
|
753
|
+
? incoming.models.map((m: any) => ({ id: m.id, ...(m.enabled === false ? { enabled: false } : {}) }))
|
|
754
|
+
: existingProv?.models ?? (incoming.model ? [{ id: incoming.model, enabled: true }] : [])
|
|
755
|
+
merged.providers[name] = {
|
|
756
|
+
models,
|
|
757
|
+
baseUrl: incoming.baseUrl ?? existingProv?.baseUrl ?? "",
|
|
758
|
+
...(incoming.maxContextTokens ? { maxContextTokens: incoming.maxContextTokens } : {}),
|
|
759
|
+
apiKey: incoming.apiKey || existingProv?.apiKey || "",
|
|
760
|
+
enabled: incoming.enabled !== undefined ? incoming.enabled : (existingProv?.enabled ?? true),
|
|
761
|
+
} as any
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
if (cfg.default !== undefined) merged.default = cfg.default
|
|
765
|
+
if (cfg.knowledge !== undefined) merged.knowledge = cfg.knowledge
|
|
766
|
+
if (cfg.notes !== undefined) merged.notes = cfg.notes
|
|
767
|
+
if (cfg.repos !== undefined) merged.repos = cfg.repos
|
|
768
|
+
if (cfg.serveDomain !== undefined) merged.serveDomain = cfg.serveDomain
|
|
769
|
+
if (cfg.serveWithPort !== undefined) merged.serveWithPort = cfg.serveWithPort
|
|
770
|
+
if (cfg.serveHttps !== undefined) merged.serveHttps = cfg.serveHttps
|
|
771
|
+
if (cfg.serveDisplayPort !== undefined) merged.serveDisplayPort = cfg.serveDisplayPort
|
|
772
|
+
if (cfg.serveEnabled !== undefined) merged.serveEnabled = cfg.serveEnabled
|
|
773
|
+
if (cfg.serveDynamicEnabled !== undefined) merged.serveDynamicEnabled = cfg.serveDynamicEnabled
|
|
774
|
+
if (cfg.serveDynamicDomain !== undefined) merged.serveDynamicDomain = cfg.serveDynamicDomain
|
|
775
|
+
if (cfg.serveDynamicPortRange !== undefined) merged.serveDynamicPortRange = cfg.serveDynamicPortRange
|
|
776
|
+
if (cfg.serveDynamicUdpEnabled !== undefined) merged.serveDynamicUdpEnabled = cfg.serveDynamicUdpEnabled
|
|
777
|
+
if (cfg.serveDynamicStaticEnabled !== undefined) merged.serveDynamicStaticEnabled = cfg.serveDynamicStaticEnabled
|
|
778
|
+
if (cfg.serveEphemeralEnabled !== undefined) merged.serveEphemeralEnabled = cfg.serveEphemeralEnabled
|
|
779
|
+
if (cfg.serveEphemeralDomain !== undefined) merged.serveEphemeralDomain = cfg.serveEphemeralDomain
|
|
780
|
+
if (cfg.presets !== undefined) merged.presets = cfg.presets
|
|
781
|
+
await writeFile(configPath(), JSON.stringify(merged, null, 2) + "\n")
|
|
782
|
+
cachedWorkspace = null
|
|
783
|
+
}
|