@wenathlan/devthink 1.1.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.
package/README.md ADDED
@@ -0,0 +1,170 @@
1
+ # DevThink
2
+
3
+ **DevThink** is a provider-neutral AI development workbench with a terminal CLI, an embedded local gateway and a static web workbench. The public source lives in the [DevThink repository](https://github.com/wenathlan/devthink); **DevThink** remains the product name.
4
+
5
+ > **Security boundary.** DevThink uses official provider APIs and credentials supplied explicitly by the user. It does not capture browser cookies, store browser sessions, solve CAPTCHAs, automate browser authentication or bypass anti-bot controls.
6
+
7
+ | Surface | Capability |
8
+ | --- | --- |
9
+ | CLI | Streaming chat, provider and model discovery, 20 modes, local sessions, memory, pairing and credential-free snapshot export. |
10
+ | Local gateway | Loopback HTTP and SSE service for health, provider models, workspaces, sessions, tabs and normalized chat streaming. |
11
+ | Provider layer | OpenAI-compatible, Anthropic Messages and Google Generate Content protocols with user-configured endpoints. |
12
+ | Local state | Atomic JSON compatibility records plus SQLite mirrors under `~/.config/devthink/`. |
13
+ | Workbench | Static React application under `web/`, with addressable workspace/session/tab/section routes and local one-time pairing. |
14
+
15
+ ## Installation
16
+
17
+ DevThink uses Bun for development and self-contained binary builds. The CLI keeps non-secret preferences in `devthink.json` and user-provided official credentials in a separate `auth.json` file.
18
+
19
+ ```bash
20
+ npm install --global @wenathlan/devthink
21
+ devthink init
22
+ devthink --help
23
+ ```
24
+
25
+ The portable static workbench is also published as an npm package. It is a browser artifact rather than a server, so it must be served with any static-file host after installation.
26
+
27
+ ```bash
28
+ npm install @wenathlan/devthink.web
29
+ ```
30
+
31
+ ```bash
32
+ bun install
33
+ bun run devthink.ts init
34
+ bun run devthink.ts --help
35
+ ```
36
+
37
+ ```bash
38
+ devthink auth login zai --token "$ZAI_API_KEY" --kind api-key
39
+ devthink config activeModel glm-5
40
+ devthink chat --prompt "Summarize this repository in three points."
41
+ ```
42
+
43
+ On Linux, the default local paths are:
44
+
45
+ | File | Purpose |
46
+ | --- | --- |
47
+ | `~/.config/devthink/devthink.json` | Non-secret preferences and provider endpoint configuration. |
48
+ | `~/.config/devthink/auth.json` | Explicit official API keys, bearer values or OAuth records; file mode is `0600`. |
49
+ | `~/.config/devthink/devthink.sqlite` | Local workspaces, sessions, tabs and messages. |
50
+
51
+ The examples below are deliberately inert and must be replaced locally by an official credential supplied by the user.
52
+
53
+ ```json
54
+ {
55
+ "version": 1,
56
+ "providers": {
57
+ "zai": {
58
+ "kind": "api-key",
59
+ "value": "example_api_key_not_a_secret",
60
+ "updatedAt": "2026-08-19T00:00:00.000Z"
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ ## Provider endpoints
67
+
68
+ DevThink does not use a separate proxy module. A provider-specific endpoint belongs in `providers.<id>.baseUrl` within the user-owned `devthink.json` file.
69
+
70
+ ```json
71
+ {
72
+ "activeProvider": "zai",
73
+ "activeModel": "glm-5",
74
+ "gateway": { "mode": "embedded", "stream": true },
75
+ "providers": {
76
+ "zai": {
77
+ "baseUrl": "https://api.z.ai/api/paas/v4",
78
+ "transport": "openai-compatible"
79
+ }
80
+ },
81
+ "web": { "enabled": true }
82
+ }
83
+ ```
84
+
85
+ | Provider family | Protocol |
86
+ | --- | --- |
87
+ | OpenAI, Z.AI, Qwen, OpenRouter, DeepSeek, Groq, Mistral, xAI, Ollama and Xiaomi MiMo | OpenAI-compatible streaming. |
88
+ | Anthropic | Messages streaming. |
89
+ | Google Gemini | Generate Content streaming. |
90
+
91
+ ## CLI commands
92
+
93
+ ```text
94
+ devthink init
95
+ devthink chat --provider <id> --model <id> --prompt <text>
96
+ devthink providers
97
+ devthink models --provider <id>
98
+ devthink modes
99
+ devthink config [key] [value]
100
+ devthink auth login <provider> --token <credential> [--kind api-key|bearer|oauth]
101
+ devthink auth status
102
+ devthink auth clear <provider>
103
+ devthink auth migrate
104
+ devthink identity
105
+ devthink pair create [--web-url <url>] [--gateway <url>]
106
+ devthink pair revoke
107
+ devthink sync status
108
+ devthink sync export
109
+ devthink gateway status
110
+ devthink sessions list
111
+ devthink sessions export <id> --format markdown
112
+ devthink serve
113
+ devthink interactive
114
+ ```
115
+
116
+ `devthink serve` listens only on the local loopback interface. The web workbench must pair through a short-lived, one-time code before a browser origin can access local records. Provider credentials remain local and are never sent to GitHub Pages.
117
+
118
+ ## Workbench and GitHub Pages
119
+
120
+ The workbench lives directly under [`web/`](./web), with no `web/src` or `web/client/src` nesting. Its route preserves the same stable identifiers used by the CLI and local gateway.
121
+
122
+ ```text
123
+ /w/:workspaceId/s/:sessionId/t/:tabId/:sectionId
124
+ ```
125
+
126
+ ```bash
127
+ cd web
128
+ pnpm install --frozen-lockfile
129
+ pnpm check
130
+ pnpm build
131
+ ```
132
+
133
+ The GitHub Pages build is static. It never embeds a provider credential or remote database. To interact with the CLI, users run `devthink serve` locally and pair the browser explicitly.
134
+
135
+ ## TypeScript documentation
136
+
137
+ The public `docs/` directory contains compact `category.ts.md` collections. Legacy non-TypeScript executable documentation was rewritten as typed, JSDoc-described TypeScript contracts. The root code integrates the safe parts of those contracts: bounded retry behavior, error redaction, exact allowed-origin matching, secure remote-endpoint validation and Pages base-path normalization.
138
+
139
+ | Collection | Focus |
140
+ | --- | --- |
141
+ | `auth.ts.md` | Official credential shapes and inert examples. |
142
+ | `providers.ts.md` | Protocol adapters, retry and error redaction. |
143
+ | `gateway.ts.md` | Local gateway request and origin contract. |
144
+ | `storage.ts.md` | Credential-free local persistence and sync boundary. |
145
+ | `web.ts.md` | Static base-path and addressable route contract. |
146
+
147
+ ## Validation and release
148
+
149
+ ```bash
150
+ bun run check
151
+ bun run build:binary
152
+
153
+ cd web
154
+ pnpm check
155
+ pnpm build
156
+ ```
157
+
158
+ The release workflow builds Bun binaries for Linux x64, macOS arm64 and Windows x64. The Pages workflow validates and publishes the static workbench with a repository-scoped base path and SPA fallback.
159
+
160
+ ## References
161
+
162
+ [1] [Bun documentation](https://bun.sh/docs)
163
+
164
+ [2] [Anthropic streaming documentation](https://platform.claude.com/docs/en/build-with-claude/streaming)
165
+
166
+ [3] [Google Gemini Generate Content](https://ai.google.dev/gemini-api/docs/generate-content)
167
+
168
+ [4] [Z.AI API documentation](https://docs.z.ai/api-reference/introduction)
169
+
170
+ [5] [Xiaomi MiMo quick start](https://mimo.mi.com/docs/quick-start/first-api-call)
package/build.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { arch, platform } from "node:process";
3
+
4
+ type BunRuntime = {
5
+ build: (options: Record<string, unknown>) => Promise<{ success: boolean; logs: unknown[] }>;
6
+ };
7
+
8
+ function bunRuntime(): BunRuntime {
9
+ const runtime = (globalThis as { Bun?: BunRuntime }).Bun;
10
+ if (!runtime) throw new Error("Bun is required to compile DevThink binaries.");
11
+ return runtime;
12
+ }
13
+
14
+ function flag(name: string): string | undefined {
15
+ const index = process.argv.indexOf(name);
16
+ return index >= 0 ? process.argv[index + 1] : undefined;
17
+ }
18
+
19
+ function packageVersion(): string {
20
+ const packageData = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")) as { version?: string };
21
+ return packageData.version || "0.0.0";
22
+ }
23
+
24
+ function localCompileTarget(): string {
25
+ const os = platform === "darwin" ? "darwin" : platform === "win32" ? "windows" : "linux";
26
+ const cpu = arch === "arm64" ? "arm64" : "x64";
27
+ return `bun-${os}-${cpu}`;
28
+ }
29
+
30
+ export async function buildBinary(): Promise<void> {
31
+ const runtime = bunRuntime();
32
+ const target = flag("--target") || process.env.BUN_TARGET || localCompileTarget();
33
+ const outfile = flag("--outfile") || process.env.DEVTHINK_OUTFILE || `release/devthink-${target}`;
34
+ const result = await runtime.build({ entrypoints: ["./devthink.ts"], target, define: { "process.env.DEVTHINK_VERSION": JSON.stringify(packageVersion()) }, compile: { target, outfile }, minify: true, sourcemap: "none" });
35
+ if (!result.success) throw new Error(`Bun compilation failed: ${JSON.stringify(result.logs)}`);
36
+ console.log(`Built ${outfile}`);
37
+ }
38
+
39
+ const directEntry = process.argv[1]?.endsWith("build.ts") || process.argv[1]?.endsWith("build");
40
+ const bunEntry = (import.meta as ImportMeta & { main?: boolean }).main === true;
41
+ if (directEntry || bunEntry) buildBinary().catch((error: unknown) => { console.error(error instanceof Error ? error.message : "Build failed."); process.exitCode = 1; });
@@ -0,0 +1,52 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ const patterns = [
5
+ /sk-[A-Za-z0-9_-]{20,}/g,
6
+ /AIza[A-Za-z0-9_-]{20,}/g,
7
+ /xox[baprs]-[A-Za-z0-9-]{16,}/g,
8
+ /gh[pousr]_[A-Za-z0-9_]{20,}/g,
9
+ /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g,
10
+ /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}/g,
11
+ ];
12
+
13
+ function productFiles(root: string): string[] {
14
+ const files: string[] = [];
15
+ const visit = (path: string) => {
16
+ if (!existsSync(path)) return;
17
+ const stat = statSync(path);
18
+ if (stat.isFile()) return files.push(path);
19
+ for (const entry of readdirSync(path)) {
20
+ if (entry === "node_modules" || entry === ".git" || entry === "docs" || entry === "dist" || entry === "release") continue;
21
+ visit(join(path, entry));
22
+ }
23
+ };
24
+ visit(join(root, "devthink.ts"));
25
+ for (const name of ["config.ts", "providers.ts", "stream.ts", "session.ts", "memory.ts", "modes.ts", "plugin.ts", "server.ts", "ui.ts", "build.ts", "check-secrets.ts", "package.json", "tsconfig.json", "README.md", "CHANGELOG.md", "SECURITY.md"]) visit(join(root, name));
26
+ visit(join(root, ".github"));
27
+ visit(join(root, "tests"));
28
+ return files.filter((path) => existsSync(path));
29
+ }
30
+
31
+ export function scanSecrets(root = process.cwd()): string[] {
32
+ const findings: string[] = [];
33
+ for (const file of productFiles(root)) {
34
+ const text = readFileSync(file, "utf8");
35
+ for (const pattern of patterns) {
36
+ if (pattern.test(text)) findings.push(`${file}: ${pattern}`);
37
+ pattern.lastIndex = 0;
38
+ }
39
+ }
40
+ return findings;
41
+ }
42
+
43
+ const directEntry = process.argv[1]?.endsWith("check-secrets.ts") || process.argv[1]?.endsWith("check-secrets");
44
+ const bunEntry = (import.meta as ImportMeta & { main?: boolean }).main === true;
45
+ if (directEntry || bunEntry) {
46
+ const findings = scanSecrets();
47
+ if (findings.length) {
48
+ console.error("Potential embedded credentials detected:");
49
+ for (const finding of findings) console.error(finding);
50
+ process.exitCode = 1;
51
+ } else console.log("Secret scan passed.");
52
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Shared TypeScript compatibility primitives derived from the converted docs logic.
3
+ * These helpers intentionally exclude browser automation, CAPTCHA handling and credential capture.
4
+ */
5
+
6
+ /** Identifies the public wire formats normalized by DevThink. */
7
+ export type ProviderProtocol = "openai" | "anthropic" | "google";
8
+
9
+ /** Returns bounded exponential retry timing for a transient request failure. */
10
+ export function retryDelay(attempt: number, baseMs = 250, maximumMs = 4_000): number {
11
+ return Math.min(maximumMs, baseMs * 2 ** Math.max(0, attempt));
12
+ }
13
+
14
+ /** Removes a bearer value from an error message before it reaches a terminal or browser surface. */
15
+ export function redactProviderError(message: string): string {
16
+ return message.replace(/bearer\s+[^\s]+/gi, "bearer [redacted]").slice(0, 500);
17
+ }
18
+
19
+ /** Accepts a browser origin only when it exactly matches an explicit local configuration entry. */
20
+ export function isAllowedOrigin(origin: string | undefined, allowedOrigins: readonly string[] | undefined): boolean {
21
+ return Boolean(origin && allowedOrigins?.includes(origin));
22
+ }
23
+
24
+ /** Verifies that a user-operated synchronization endpoint uses HTTPS. */
25
+ export function isSecureRemoteEndpoint(value: string | undefined): boolean {
26
+ try {
27
+ return Boolean(value && new URL(value).protocol === "https:");
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ /** Normalizes a static-site base path for repository-scoped GitHub Pages deployments. */
34
+ export function normalizeBasePath(value: string | undefined, repository: string): string {
35
+ const candidate = value?.trim() || `/${repository}/`;
36
+ return `/${candidate.replace(/^\/+|\/+$/g, "")}/`;
37
+ }
package/config.ts ADDED
@@ -0,0 +1,240 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { homedir, platform } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ export type DevThinkConfig = {
6
+ activeProvider?: string;
7
+ activeModel?: string;
8
+ provider?: string;
9
+ model?: string;
10
+ mode?: string;
11
+ baseUrl?: string;
12
+ apiKey?: string;
13
+ anthropicApiKey?: string;
14
+ googleApiKey?: string;
15
+ zaiApiKey?: string;
16
+ temperature?: number;
17
+ maxTokens?: number;
18
+ projectMemory?: boolean;
19
+ externalMemory?: boolean;
20
+ fallbackProviders?: string[];
21
+ providers?: Record<string, {
22
+ baseUrl?: string;
23
+ apiKey?: string;
24
+ model?: string;
25
+ transport?: "official" | "openai-compatible" | "local-gateway";
26
+ auth?: { kind: "api-key" | "bearer" | "oauth"; value?: string; expiresAt?: number };
27
+ }>;
28
+ gateway?: { mode?: "embedded"; stream?: boolean; host?: string };
29
+ web?: { enabled?: boolean; pagesUrl?: string; gatewayUrl?: string; allowedOrigins?: string[]; remoteSync?: { enabled?: boolean; endpoint?: string } };
30
+ };
31
+
32
+ export type AuthKind = "api-key" | "bearer" | "oauth";
33
+ export type AuthCredential = { kind: AuthKind; value?: string; accessToken?: string; refreshToken?: string; expiresAt?: number; resourceUrl?: string; updatedAt: string };
34
+ export type DevThinkAuth = { version: 1; providers: Record<string, AuthCredential> };
35
+
36
+ export type DevThinkPaths = {
37
+ home: string;
38
+ config: string;
39
+ auth: string;
40
+ identity: string;
41
+ pairings: string;
42
+ legacyConfig: string;
43
+ sessions: string;
44
+ workspaces: string;
45
+ database: string;
46
+ memory: string;
47
+ logs: string;
48
+ };
49
+
50
+ function resolveHome(): string {
51
+ const override = process.env.DEVTHINK_HOME?.trim();
52
+ if (override) return override;
53
+ if (platform() === "win32") return join(process.env.APPDATA || homedir(), "devthink");
54
+ if (platform() === "darwin") return join(homedir(), "Library", "Application Support", "devthink");
55
+ return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "devthink");
56
+ }
57
+
58
+ export function resolvePaths(root = resolveHome()): DevThinkPaths {
59
+ return { home: root, config: join(root, "devthink.json"), auth: join(root, "auth.json"), identity: join(root, "identity.json"), pairings: join(root, "pairings.json"), legacyConfig: join(root, "config.json"), sessions: join(root, "sessions"), workspaces: join(root, "workspaces"), database: join(root, "devthink.sqlite"), memory: join(root, "memory"), logs: join(root, "logs") };
60
+ }
61
+
62
+ export function ensurePaths(paths = resolvePaths()): DevThinkPaths {
63
+ for (const path of [paths.home, paths.sessions, paths.workspaces, paths.memory, paths.logs]) mkdirSync(path, { recursive: true });
64
+ return paths;
65
+ }
66
+
67
+ function isRecord(value: unknown): value is Record<string, unknown> {
68
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
69
+ }
70
+
71
+ function writeJson(path: string, value: unknown, mode?: number): void {
72
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
73
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, mode ? { encoding: "utf8", mode } : "utf8");
74
+ renameSync(temporary, path);
75
+ }
76
+
77
+ export function readConfig(paths = resolvePaths()): DevThinkConfig {
78
+ try {
79
+ const candidate = existsSync(paths.config) ? paths.config : paths.legacyConfig;
80
+ if (!existsSync(candidate)) return {};
81
+ const parsed: unknown = JSON.parse(readFileSync(candidate, "utf8"));
82
+ if (!isRecord(parsed)) return {};
83
+ const config = parsed as DevThinkConfig;
84
+ return { ...config, activeProvider: config.activeProvider || config.provider, activeModel: config.activeModel || config.model };
85
+ } catch {
86
+ return {};
87
+ }
88
+ }
89
+
90
+ export function saveConfig(config: DevThinkConfig, paths = resolvePaths()): void {
91
+ ensurePaths(paths);
92
+ writeJson(paths.config, { ...config, activeProvider: config.activeProvider || config.provider, activeModel: config.activeModel || config.model, gateway: { mode: "embedded" as const, stream: true, ...config.gateway } });
93
+ }
94
+
95
+ const forbiddenAuthFields = new Set(["cookie", "cookies", "fingerprint", "useragent", "sessiontoken", "captcha", "browserprofile"]);
96
+
97
+ function validCredential(value: unknown): AuthCredential | undefined {
98
+ if (!isRecord(value) || Object.keys(value).some((key) => forbiddenAuthFields.has(key.toLowerCase()))) return undefined;
99
+ const kind = String(value.kind) as AuthKind;
100
+ if (!["api-key", "bearer", "oauth"].includes(kind)) return undefined;
101
+ const token = typeof value.value === "string" ? value.value : undefined;
102
+ const accessToken = typeof value.accessToken === "string" ? value.accessToken : undefined;
103
+ const refreshToken = typeof value.refreshToken === "string" ? value.refreshToken : undefined;
104
+ if ((kind === "oauth" && !accessToken) || (kind !== "oauth" && !token)) return undefined;
105
+ return { kind, ...(token ? { value: token } : {}), ...(accessToken ? { accessToken } : {}), ...(refreshToken ? { refreshToken } : {}), ...(typeof value.expiresAt === "number" ? { expiresAt: value.expiresAt } : {}), ...(typeof value.resourceUrl === "string" ? { resourceUrl: value.resourceUrl } : {}), updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : new Date().toISOString() };
106
+ }
107
+
108
+ export function readAuth(paths = resolvePaths()): DevThinkAuth {
109
+ try {
110
+ if (!existsSync(paths.auth)) return { version: 1, providers: {} };
111
+ const parsed: unknown = JSON.parse(readFileSync(paths.auth, "utf8"));
112
+ if (!isRecord(parsed) || !isRecord(parsed.providers)) return { version: 1, providers: {} };
113
+ const providers: Record<string, AuthCredential> = {};
114
+ for (const [provider, raw] of Object.entries(parsed.providers)) {
115
+ const credential = validCredential(raw);
116
+ if (credential) providers[provider.toLowerCase()] = credential;
117
+ }
118
+ return { version: 1, providers };
119
+ } catch {
120
+ return { version: 1, providers: {} };
121
+ }
122
+ }
123
+
124
+ export function saveAuth(auth: DevThinkAuth, paths = resolvePaths()): void {
125
+ ensurePaths(paths);
126
+ const providers: Record<string, AuthCredential> = {};
127
+ for (const [provider, raw] of Object.entries(auth.providers)) {
128
+ const credential = validCredential(raw);
129
+ if (credential) providers[provider.toLowerCase()] = credential;
130
+ }
131
+ writeJson(paths.auth, { version: 1, providers }, 0o600);
132
+ }
133
+
134
+ export function setAuthCredential(provider: string, credential: Omit<AuthCredential, "updatedAt">, paths = resolvePaths()): DevThinkAuth {
135
+ const next = { ...credential, updatedAt: new Date().toISOString() };
136
+ if (!validCredential(next)) throw new Error("Credential must use an official api-key, bearer, or OAuth shape without browser-session fields.");
137
+ const current = readAuth(paths);
138
+ const auth = { version: 1 as const, providers: { ...current.providers, [provider.toLowerCase()]: next } };
139
+ saveAuth(auth, paths);
140
+ return auth;
141
+ }
142
+
143
+ export function clearAuthCredential(provider: string, paths = resolvePaths()): DevThinkAuth {
144
+ const current = readAuth(paths);
145
+ const providers = { ...current.providers };
146
+ delete providers[provider.toLowerCase()];
147
+ const auth = { version: 1 as const, providers };
148
+ saveAuth(auth, paths);
149
+ return auth;
150
+ }
151
+
152
+ export function migrateLegacyCredentials(config: DevThinkConfig, paths = resolvePaths()): { config: DevThinkConfig; migrated: string[] } {
153
+ const current = readAuth(paths);
154
+ const providers = { ...current.providers };
155
+ const nextConfig: DevThinkConfig = { ...config, providers: { ...config.providers } };
156
+ const migrated: string[] = [];
157
+ for (const [provider, settings] of Object.entries(config.providers || {})) {
158
+ const legacy = settings.auth?.value || settings.apiKey;
159
+ if (!legacy || providers[provider]) continue;
160
+ const kind = settings.auth?.kind || "api-key";
161
+ providers[provider] = kind === "oauth" ? { kind, accessToken: legacy, expiresAt: settings.auth?.expiresAt, updatedAt: new Date().toISOString() } : { kind, value: legacy, expiresAt: settings.auth?.expiresAt, updatedAt: new Date().toISOString() };
162
+ const { auth: _auth, apiKey: _apiKey, ...rest } = settings;
163
+ nextConfig.providers![provider] = rest;
164
+ migrated.push(provider);
165
+ }
166
+ for (const [provider, property] of Object.entries({ openai: "apiKey", anthropic: "anthropicApiKey", google: "googleApiKey", zai: "zaiApiKey" })) {
167
+ const value = nextConfig[property as keyof DevThinkConfig];
168
+ if (typeof value === "string" && value && !providers[provider]) {
169
+ providers[provider] = { kind: "api-key", value, updatedAt: new Date().toISOString() };
170
+ delete (nextConfig as Record<string, unknown>)[property];
171
+ migrated.push(provider);
172
+ }
173
+ }
174
+ saveAuth({ version: 1, providers }, paths);
175
+ saveConfig(nextConfig, paths);
176
+ return { config: nextConfig, migrated };
177
+ }
178
+
179
+ export function getConfigValue(config: DevThinkConfig, key: string): unknown {
180
+ return (config as Record<string, unknown>)[key];
181
+ }
182
+
183
+ export function setConfigValue(config: DevThinkConfig, key: string, value: unknown): DevThinkConfig {
184
+ return { ...config, [key]: value };
185
+ }
186
+
187
+ export function resolveCredential(provider: string, config: DevThinkConfig, paths = resolvePaths()): string | undefined {
188
+ const auth = readAuth(paths).providers[provider.toLowerCase()];
189
+ if (auth) {
190
+ if (auth.kind === "oauth" && auth.expiresAt && auth.expiresAt <= Date.now()) throw new Error(`Official OAuth credential for ${provider} has expired. Reauthenticate with the provider-owned flow.`);
191
+ return auth.kind === "oauth" ? auth.accessToken : auth.value;
192
+ }
193
+ const envNames: Record<string, string[]> = { openai: ["OPENAI_API_KEY"], anthropic: ["ANTHROPIC_API_KEY"], google: ["GOOGLE_API_KEY", "GEMINI_API_KEY"], zai: ["ZAI_API_KEY"], qwen: ["QWEN_API_KEY"], openrouter: ["OPENROUTER_API_KEY"], deepseek: ["DEEPSEEK_API_KEY"], groq: ["GROQ_API_KEY"], mistral: ["MISTRAL_API_KEY"], xai: ["XAI_API_KEY"], ollama: ["OLLAMA_API_KEY"], mimo: ["MIMO_API_KEY"] };
194
+ for (const name of envNames[provider.toLowerCase()] || []) {
195
+ const value = process.env[name]?.trim();
196
+ if (value) return value;
197
+ }
198
+ const property = `${provider}ApiKey` as keyof DevThinkConfig;
199
+ const nested = config.providers?.[provider];
200
+ return nested?.auth?.value || nested?.apiKey || config[property] || (provider === (config.activeProvider || config.provider) ? config.apiKey : undefined) || process.env.DEVTHINK_API_KEY?.trim();
201
+ }
202
+
203
+ export function redactValue(value: unknown): unknown {
204
+ if (typeof value !== "string") return value;
205
+ if (value.length < 8) return "[redacted]";
206
+ return `${value.slice(0, 3)}...${value.slice(-3)}`;
207
+ }
208
+
209
+ export function redactAuth(auth: DevThinkAuth): Record<string, unknown> {
210
+ return { version: auth.version, providers: Object.fromEntries(Object.entries(auth.providers).map(([provider, credential]) => [provider, { kind: credential.kind, ...(credential.value ? { value: redactValue(credential.value) } : {}), ...(credential.accessToken ? { accessToken: redactValue(credential.accessToken) } : {}), ...(credential.refreshToken ? { refreshToken: redactValue(credential.refreshToken) } : {}), ...(credential.expiresAt ? { expiresAt: credential.expiresAt } : {}), ...(credential.resourceUrl ? { resourceUrl: credential.resourceUrl } : {}), updatedAt: credential.updatedAt }])) };
211
+ }
212
+
213
+ export function redactConfig(config: DevThinkConfig): Record<string, unknown> {
214
+ const sensitive = new Set(["apiKey", "anthropicApiKey", "googleApiKey", "zaiApiKey"]);
215
+ return Object.fromEntries(Object.entries(config).map(([key, value]) => {
216
+ if (key === "providers" && value && typeof value === "object") {
217
+ const providers = Object.fromEntries(Object.entries(value as Record<string, Record<string, unknown>>).map(([id, settings]) => [id, Object.fromEntries(Object.entries(settings).map(([setting, item]) => {
218
+ if (setting === "apiKey") return [setting, redactValue(item)];
219
+ if (setting === "auth" && item && typeof item === "object") {
220
+ const auth = item as Record<string, unknown>;
221
+ return [setting, { ...auth, value: redactValue(auth.value) }];
222
+ }
223
+ return [setting, item];
224
+ }))]));
225
+ return [key, providers];
226
+ }
227
+ return [key, sensitive.has(key) || key.toLowerCase().endsWith("apikey") ? redactValue(value) : value];
228
+ }));
229
+ }
230
+
231
+ export function parseConfigValue(raw: string): unknown {
232
+ if (raw === "true") return true;
233
+ if (raw === "false") return false;
234
+ if (/^-?\d+$/.test(raw)) return Number.parseInt(raw, 10);
235
+ if (/^-?\d+\.\d+$/.test(raw)) return Number.parseFloat(raw);
236
+ if (raw.startsWith("[") || raw.startsWith("{")) {
237
+ try { return JSON.parse(raw); } catch { return raw; }
238
+ }
239
+ return raw;
240
+ }