devlensio 1.0.3 → 1.0.4

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,188 @@
1
+ // Back-compat + tolerance tests for the config system (plan issue 15, M1).
2
+ //
3
+ // Each scenario runs in a SUBPROCESS with HOME pointed at a throwaway dir:
4
+ // CONFIG_FILE is resolved from os.homedir() at module load, so isolation only
5
+ // works in a fresh process. This also guarantees these tests can never touch
6
+ // the developer's real ~/.devlens/config.json.
7
+ //
8
+ // Run: bun test src/config/backcompat.test.ts
9
+ import { describe, test, expect } from "bun:test";
10
+ import { spawnSync } from "node:child_process";
11
+ import fs from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ const ROOT = path.resolve(import.meta.dir, "..", ".."); // engine repo root
15
+ function runScenario(name, files, script) {
16
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), `devlens-cfg-${name}-`));
17
+ for (const [rel, content] of Object.entries(files)) {
18
+ const p = path.join(home, rel);
19
+ fs.mkdirSync(path.dirname(p), { recursive: true });
20
+ fs.writeFileSync(p, content);
21
+ }
22
+ const env = { ...process.env, HOME: home };
23
+ // strip ambient LLM env so fixtures are hermetic
24
+ for (const k of Object.keys(env)) {
25
+ if (k.startsWith("DEVLENS_LLM") || k.startsWith("DEVLENS_EMBED") || k.startsWith("DEVLENS_BATCH")) {
26
+ delete env[k];
27
+ }
28
+ }
29
+ const r = spawnSync("bun", ["-e", script], {
30
+ cwd: ROOT,
31
+ env,
32
+ encoding: "utf8",
33
+ timeout: 30000,
34
+ });
35
+ if (r.error)
36
+ throw r.error;
37
+ const out = (r.stdout ?? "").trim().split("\n").pop() ?? "";
38
+ let result;
39
+ try {
40
+ result = JSON.parse(out);
41
+ }
42
+ catch {
43
+ result = { parseError: r.stdout, status: r.status, stderr: r.stderr };
44
+ }
45
+ return { home, result, stderr: r.stderr ?? "" };
46
+ }
47
+ const CONFIG_REL = ".devlens/config.json";
48
+ const PROBE = `
49
+ const m = await import("./src/config/index.js");
50
+ const out = {};
51
+ try {
52
+ const c = m.resolveConfig(undefined, { validate: false });
53
+ out.tolerant = { provider: c.summarization.provider, providerName: c.summarization.providerName ?? null, model: c.summarization.model, hasKey: !!c.summarization.apiKey };
54
+ } catch (e) { out.tolerantErr = e instanceof Error ? e.message : String(e); }
55
+ try {
56
+ m.resolveConfig();
57
+ out.validating = "ok";
58
+ } catch (e) { out.validatingErr = e instanceof Error ? e.message : String(e); }
59
+ out.configured = m.hasSummarizationConfigured();
60
+ console.log(JSON.stringify(out));
61
+ `;
62
+ describe("fresh install (no config file)", () => {
63
+ const s = runScenario("fresh", {}, PROBE);
64
+ test("tolerant read resolves defaults without error", () => {
65
+ expect(s.result.tolerantErr).toBeUndefined();
66
+ expect(s.result.tolerant).toMatchObject({ provider: "anthropic", hasKey: false });
67
+ });
68
+ test("validating read fails with the actionable missing-key message", () => {
69
+ expect(String(s.result.validatingErr)).toContain("summarization.apiKey is required");
70
+ expect(String(s.result.validatingErr)).toContain("devlens init");
71
+ });
72
+ test("hasSummarizationConfigured() = false", () => {
73
+ expect(s.result.configured).toBe(false);
74
+ });
75
+ });
76
+ describe("incomplete config (provider, no key)", () => {
77
+ const s = runScenario("incomplete", { [CONFIG_REL]: JSON.stringify({ summarization: { provider: "openai", providerName: "deepseek", model: "deepseek-chat" } }) }, PROBE);
78
+ test("tolerant read works (init/config/analyze can run)", () => {
79
+ expect(s.result.tolerantErr).toBeUndefined();
80
+ expect(s.result.tolerant).toMatchObject({ provider: "openai", providerName: "deepseek", hasKey: false });
81
+ });
82
+ test("only the validating path throws", () => {
83
+ expect(String(s.result.validatingErr)).toContain("apiKey is required");
84
+ expect(s.result.configured).toBe(false);
85
+ });
86
+ });
87
+ describe("valid config (current multi-provider v2)", () => {
88
+ const s = runScenario("valid", {
89
+ [CONFIG_REL]: JSON.stringify({
90
+ summarization: {
91
+ active: "openai:deepseek",
92
+ providers: {
93
+ "openai:deepseek": { provider: "openai", providerName: "deepseek", model: "deepseek-chat", apiKey: "sk-x", batchSize: 25 },
94
+ },
95
+ },
96
+ }),
97
+ }, PROBE);
98
+ test("both paths resolve; configured = true", () => {
99
+ expect(s.result.tolerantErr).toBeUndefined();
100
+ expect(s.result.validating).toBe("ok");
101
+ expect(s.result.configured).toBe(true);
102
+ expect(s.result.tolerant).toMatchObject({ provider: "openai", providerName: "deepseek", hasKey: true });
103
+ });
104
+ });
105
+ describe("broken config JSON", () => {
106
+ const s = runScenario("broken", { [CONFIG_REL]: "{ not json" }, PROBE);
107
+ test("tolerant read STILL reports the invalid-JSON error (never silent)", () => {
108
+ expect(String(s.result.tolerantErr)).toContain("invalid JSON");
109
+ expect(String(s.result.validatingErr)).toContain("invalid JSON");
110
+ expect(s.result.configured).toBe(false);
111
+ });
112
+ });
113
+ describe("old v1 flat config (pre multi-provider)", () => {
114
+ const flat = JSON.stringify({ summarization: { provider: "openai", providerName: "mybrand", model: "m1", apiKey: "sk-old", batchSize: 30 } });
115
+ const s = runScenario("flat", { [CONFIG_REL]: flat }, PROBE);
116
+ test("loads fine on both paths", () => {
117
+ expect(s.result.tolerantErr).toBeUndefined();
118
+ expect(s.result.validating).toBe("ok");
119
+ expect(s.result.configured).toBe(true);
120
+ });
121
+ test("migrates the file to multi-provider format (active + providers)", () => {
122
+ const raw = JSON.parse(fs.readFileSync(path.join(s.home, CONFIG_REL), "utf8"));
123
+ expect(raw.summarization.active).toBe("openai:mybrand");
124
+ expect(raw.summarization.providers["openai:mybrand"].apiKey).toBe("sk-old");
125
+ });
126
+ });
127
+ describe("v1 config with a BRAND in provider (deepseek)", () => {
128
+ const s = runScenario("brand", { [CONFIG_REL]: JSON.stringify({ summarization: { provider: "deepseek", model: "m1", apiKey: "sk-b" } }) }, PROBE);
129
+ test("migrates brand → protocol + providerName without error", () => {
130
+ expect(s.result.tolerantErr).toBeUndefined();
131
+ expect(s.result.tolerant).toMatchObject({ provider: "openai", providerName: "deepseek", hasKey: true });
132
+ expect(s.result.validating).toBe("ok");
133
+ });
134
+ });
135
+ describe("legacy OLLAMA config (provider removed)", () => {
136
+ const s = runScenario("ollama", { [CONFIG_REL]: JSON.stringify({ summarization: { provider: "ollama", model: "qwen2.5-coder:3b", baseUrl: "http://localhost:11434/v1" } }) }, PROBE);
137
+ test("tolerant read loads (old configs must keep working)", () => {
138
+ expect(s.result.tolerantErr).toBeUndefined();
139
+ expect(s.result.tolerant).toMatchObject({ provider: "openai", providerName: "ollama", hasKey: false });
140
+ });
141
+ test("validating read gives the 'no longer supported' migration message", () => {
142
+ expect(String(s.result.validatingErr)).toContain("Ollama is no longer supported");
143
+ expect(s.result.configured).toBe(false);
144
+ });
145
+ test("stderr carries the not-in-catalog warning (once)", () => {
146
+ expect(s.stderr).toContain("not in the provider catalog");
147
+ });
148
+ });
149
+ describe("config with an embedding block but no embedding key", () => {
150
+ const s = runScenario("embedding", {
151
+ [CONFIG_REL]: JSON.stringify({
152
+ summarization: { provider: "openai", providerName: "deepseek", model: "m", apiKey: "sk-ok" },
153
+ embedding: { provider: "openai", model: "text-embedding-3-small" },
154
+ }),
155
+ }, PROBE);
156
+ test("validating read no longer demands an embedding key (issue 14)", () => {
157
+ expect(s.result.validating).toBe("ok");
158
+ expect(s.result.tolerantErr).toBeUndefined();
159
+ expect(s.result.configured).toBe(true);
160
+ });
161
+ });
162
+ describe("provider catalog", () => {
163
+ test("ollama is gone from the shipped catalog", () => {
164
+ const s = runScenario("catalog", {}, `
165
+ const { loadCatalog } = await import("./src/config/providers/catalog.js");
166
+ const names = loadCatalog().map(p => p.name);
167
+ console.log(JSON.stringify({ names }));
168
+ `);
169
+ const names = (s.result.names ?? []);
170
+ expect(names).not.toContain("ollama");
171
+ expect(names).toContain("deepseek");
172
+ expect(names).toContain("anthropic");
173
+ });
174
+ });
175
+ describe("env-var key counts as configured", () => {
176
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "devlens-cfg-env-"));
177
+ fs.mkdirSync(path.join(home, ".devlens"), { recursive: true });
178
+ const r = spawnSync("bun", ["-e", `const m = await import("./src/config/index.js"); console.log(JSON.stringify({ configured: m.hasSummarizationConfigured(), hasKey: !!m.resolveConfig(undefined,{validate:false}).summarization.apiKey }));`], {
179
+ cwd: ROOT,
180
+ encoding: "utf8",
181
+ timeout: 30000,
182
+ env: { ...process.env, HOME: home, DEVLENS_LLM_KEY: "sk-from-env" },
183
+ });
184
+ test("DEVLENS_LLM_KEY enables hasSummarizationConfigured()", () => {
185
+ const out = JSON.parse((r.stdout ?? "").trim().split("\n").pop() ?? "{}");
186
+ expect(out).toEqual({ configured: true, hasKey: true });
187
+ });
188
+ });
@@ -1,13 +1,15 @@
1
1
  import { type DevLensConfig, type ProviderConfigEntry } from "./types.js";
2
- export declare function detectOllama(): Promise<boolean>;
3
2
  export declare function initConfig(): Promise<void>;
4
- export declare function resolveConfig(req?: Request): DevLensConfig;
3
+ export declare function resolveConfig(req?: Request, opts?: {
4
+ validate?: boolean;
5
+ }): DevLensConfig;
6
+ export declare function hasSummarizationConfigured(req?: Request): boolean;
5
7
  export type { DevLensConfig } from "./types.js";
6
8
  export type { SafeConfig } from "./writer.js";
7
9
  export { maskConfig, writeConfig, atomicWrite } from "./writer.js";
8
10
  export { CONFIG_FILE, CONFIG_DIR, ENV } from "./providers/file.js";
9
11
  export { sanitizeHeaders, CONFIG_HEADERS } from "./types.js";
10
- export { OLLAMA_DEFAULTS, ANTHROPIC_DEFAULTS } from "./types.js";
12
+ export { ANTHROPIC_DEFAULTS } from "./types.js";
11
13
  export type { ProviderConfigEntry, MultiProviderStorage } from "./types.js";
12
14
  export { makeProviderKey, parseProviderKey } from "./types.js";
13
15
  export interface AllProvidersResult {
@@ -1,72 +1,35 @@
1
- import { OLLAMA_DEFAULTS, ANTHROPIC_DEFAULTS, makeProviderKey } from "./types.js";
1
+ import { ANTHROPIC_DEFAULTS, makeProviderKey } from "./types.js";
2
2
  import { loadFileConfig } from "./providers/file.js";
3
3
  import { applyRequestHeaders } from "./providers/request.js";
4
4
  import { atomicWrite } from "./writer.js";
5
5
  import { CONFIG_FILE } from "./providers/paths.js";
6
+ import { findProvider } from "./providers/catalog.js";
6
7
  import fs from "fs";
7
- // Ollama Detection
8
+ // Startup Initialization
8
9
  //
9
- // Pings Ollama's default endpoint at server startup.
10
- // Used by resolveConfig() to choose which defaults to fall back to:
11
- // - Ollama running → OLLAMA_DEFAULTS (free, private, zero API cost)
12
- // - Ollama absent → ANTHROPIC_DEFAULTS (user must set apiKey)
13
- //
14
- // Uses a short timeout — we don't want server startup to hang for 30 seconds
15
- // if Ollama is not installed. 2 seconds is enough for a local HTTP ping.
16
- //
17
- // Called ONCE at startup and the result is cached — see `cachedDefaults` below.
18
- const OLLAMA_PING_URL = "http://localhost:11434";
19
- const OLLAMA_PING_TIMEOUT_MS = 2000;
20
- export async function detectOllama() {
21
- try {
22
- const controller = new AbortController();
23
- const timeout = setTimeout(() => controller.abort(), OLLAMA_PING_TIMEOUT_MS);
24
- const res = await fetch(OLLAMA_PING_URL, {
25
- signal: controller.signal,
26
- method: "GET",
27
- });
28
- clearTimeout(timeout);
29
- return res.ok;
30
- }
31
- catch {
32
- // Ollama not running, not installed, or timed out — all treated the same
33
- return false;
34
- }
35
- }
36
- // Startup Initialization
37
- //
38
- // detectOllama() is called once when the server starts (in server/index.ts).
39
- // The result is stored here so resolveConfig() doesn't ping Ollama on
40
- // every single request — that would be slow and noisy.
41
- //
42
- // initConfig() must be called before any request is handled.
43
- // Until it is called, resolveConfig() falls back to ANTHROPIC_DEFAULTS safely.
10
+ // Historically this pinged a local Ollama endpoint to pick defaults and logged
11
+ // the result with console.log — noise, and a real hazard for the MCP stdio
12
+ // transport (stdout is the JSON-RPC channel). Ollama support has been removed;
13
+ // initConfig() is now a quiet, idempotent shim kept for call-site
14
+ // compatibility (OSS `devlens serve` + MCP servers await it at startup).
44
15
  let cachedDefaults = ANTHROPIC_DEFAULTS;
45
16
  let initialized = false;
46
17
  export async function initConfig() {
47
18
  if (initialized)
48
19
  return;
49
- const ollamaRunning = await detectOllama();
50
- if (ollamaRunning) {
51
- cachedDefaults = OLLAMA_DEFAULTS;
52
- console.log("⚡ Ollama detected — using local LLM defaults");
53
- console.log(` Summarization: ${OLLAMA_DEFAULTS.summarization.model}`);
54
- console.log(` Embedding: ${OLLAMA_DEFAULTS.embedding.model}`);
55
- }
56
- else {
57
- cachedDefaults = ANTHROPIC_DEFAULTS;
58
- console.log("☁️ Ollama not detected — using Anthropic defaults");
59
- console.log(" Add an apiKey to ~/.devlens/config.json to enable summarization");
60
- console.log(` Or set ${(await import("./providers/file.js")).ENV.LLM_KEY}=your-key`);
61
- }
62
20
  initialized = true;
63
21
  }
64
22
  // This function reads config.json fresh on every call —
65
23
  // so if the user edits settings in the UI, the next job picks up the change
66
24
  // without requiring a server restart.
67
- export function resolveConfig(req) {
25
+ //
26
+ // opts.validate === false → tolerant read: an incomplete summarization config
27
+ // (missing API key etc.) resolves fine and is only reported when summarization
28
+ // actually runs. Used by display paths (devlens config/doctor/init) and by
29
+ // structure-only analysis (GitHub issue #10).
30
+ export function resolveConfig(req, opts) {
68
31
  // Step 1 — load file config merged with detected defaults + env vars
69
- const fileConfig = loadFileConfig(cachedDefaults);
32
+ const fileConfig = loadFileConfig(cachedDefaults, opts);
70
33
  if (!req)
71
34
  return fileConfig;
72
35
  // In local mode, ignore headers even if present
@@ -75,10 +38,29 @@ export function resolveConfig(req) {
75
38
  // Step 4 — apply header overrides for cloud users
76
39
  return applyRequestHeaders(fileConfig, req);
77
40
  }
41
+ // True when summarization can actually run with the current config: the active
42
+ // provider either needs no key, or has one (file or env). Never throws on an
43
+ // incomplete config — callers use it to auto-skip summarization instead of
44
+ // failing analysis (GitHub issue #10). Returns false when config.json is
45
+ // unreadable: summarization must not run, but analysis may still proceed.
46
+ export function hasSummarizationConfigured(req) {
47
+ try {
48
+ const config = resolveConfig(req, { validate: false });
49
+ const providerName = config.summarization.providerName ?? config.summarization.provider;
50
+ if (providerName === "ollama")
51
+ return false; // removed provider
52
+ const entry = findProvider(providerName);
53
+ const needsKey = entry?.requiresKey ?? true;
54
+ return !needsKey || !!config.summarization.apiKey;
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
78
60
  export { maskConfig, writeConfig, atomicWrite } from "./writer.js";
79
61
  export { CONFIG_FILE, CONFIG_DIR, ENV } from "./providers/file.js";
80
62
  export { sanitizeHeaders, CONFIG_HEADERS } from "./types.js";
81
- export { OLLAMA_DEFAULTS, ANTHROPIC_DEFAULTS } from "./types.js";
63
+ export { ANTHROPIC_DEFAULTS } from "./types.js";
82
64
  export { makeProviderKey, parseProviderKey } from "./types.js";
83
65
  /** Read the raw multi-provider storage from config.json (no defaults applied). */
84
66
  function readProviderStorage() {
@@ -107,7 +89,8 @@ export function resolveAllProviders() {
107
89
  };
108
90
  }
109
91
  // Fallback: use the resolved active config to synthesise one entry
110
- const config = loadFileConfig(ANTHROPIC_DEFAULTS);
92
+ // (tolerant read — a missing key must not break the settings UI)
93
+ const config = loadFileConfig(ANTHROPIC_DEFAULTS, { validate: false });
111
94
  const key = makeProviderKey(config.summarization.provider, config.summarization.providerName ?? config.summarization.provider);
112
95
  return {
113
96
  active: key,
@@ -18,4 +18,6 @@ export declare const ENV: {
18
18
  };
19
19
  /** Public — reads the raw config.json as a plain object. Used by multi-provider helpers. */
20
20
  export declare function readRawConfigFile(): Record<string, unknown>;
21
- export declare function loadFileConfig(defaults?: DevLensConfig): DevLensConfig;
21
+ export declare function loadFileConfig(defaults?: DevLensConfig, opts?: {
22
+ validate?: boolean;
23
+ }): DevLensConfig;
@@ -6,9 +6,9 @@ export { CONFIG_DIR, CONFIG_FILE } from "./paths.js";
6
6
  // ─── Constants ────────────────────────────────────────────────────────────────
7
7
  //
8
8
  // For Docker users who prefer env vars over config files.
9
- // A Docker user running Ollama in the same network would set:
10
- // DEVLENS_LLM_PROVIDER=ollama
11
- // DEVLENS_LLM_BASE_URL=http://ollama:11434
9
+ // A Docker user with their own endpoint would set:
10
+ // DEVLENS_LLM_PROVIDER=openai
11
+ // DEVLENS_LLM_BASE_URL=https://my-endpoint.example/v1
12
12
  //
13
13
  // Priority: config file wins over env vars.
14
14
  // Env vars only fill fields that the config file left empty.
@@ -67,16 +67,24 @@ function migrateProviderConfig(config) {
67
67
  const p = config.summarization.provider;
68
68
  if (p === "openai" || p === "anthropic")
69
69
  return config;
70
+ // Known brand → its catalog protocol. Unknown brand (including providers
71
+ // removed from the catalog, e.g. "ollama") → treat as a custom
72
+ // OpenAI-compatible entry instead of throwing: configs written by older CLI
73
+ // versions must keep loading. The actionable error ("no longer supported" /
74
+ // missing key) fires at SUMMARIZE time, never on a plain config read.
70
75
  const entry = findProvider(p);
71
- if (!entry)
72
- throw new Error(`DevLens: "${p}" is not a valid provider protocol. Wire protocol must be "openai" or "anthropic". ` +
73
- `Fix: set "provider" to "openai" and "providerName" to "${p}" in ~/.devlens/config.json`);
74
- const migrated = { ...config, summarization: { ...config.summarization, provider: entry.protocol, providerName: entry.name } };
76
+ const protocol = entry?.protocol ?? "openai";
77
+ const name = entry?.name ?? p;
78
+ if (!entry) {
79
+ console.warn(`DevLens: "${p}" is not in the provider catalog — ` +
80
+ `treating it as a custom OpenAI-compatible provider.`);
81
+ }
82
+ const migrated = { ...config, summarization: { ...config.summarization, provider: protocol, providerName: name } };
75
83
  try {
76
84
  const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
77
- raw.summarization = { ...raw.summarization, provider: entry.protocol };
85
+ raw.summarization = { ...raw.summarization, provider: protocol };
78
86
  if (!raw.summarization.providerName)
79
- raw.summarization.providerName = entry.name;
87
+ raw.summarization.providerName = name;
80
88
  const tmp = CONFIG_FILE + ".tmp";
81
89
  fs.writeFileSync(tmp, JSON.stringify(raw, null, 2));
82
90
  fs.renameSync(tmp, CONFIG_FILE);
@@ -85,7 +93,7 @@ function migrateProviderConfig(config) {
85
93
  return migrated;
86
94
  }
87
95
  const VALID_LLM_PROTOCOLS = new Set(["openai", "anthropic"]);
88
- const VALID_EMBED_PROTOCOLS = new Set(["openai", "anthropic", "openrouter", "gemini", "ollama"]);
96
+ const VALID_EMBED_PROTOCOLS = new Set(["openai", "anthropic", "openrouter", "gemini"]);
89
97
  function sanitizeProviderEnv(raw) {
90
98
  if (!raw)
91
99
  return undefined;
@@ -176,45 +184,35 @@ function buildNeo4jFromEnv() {
176
184
  return undefined;
177
185
  return { url, username, password, storeRawCode };
178
186
  }
179
- // Validation
187
+ // Validation //
180
188
  //
181
- // Only validates what cannot have a sensible default.
189
+ // Only validates what cannot have a sensible default. Called from
190
+ // loadFileConfig() ONLY when validation is requested (summarize paths);
191
+ // display/init/analyze paths resolve tolerantly — see loadFileConfig opts.
182
192
  // Key requirement is resolved from the catalog's `requiresKey` per provider.
183
193
  // If a providerName isn't in the catalog (custom), we default to requiring a key.
184
194
  //
185
195
  // Error messages are actionable — they tell the user exactly how to fix the problem.
186
- const EMBEDDING_PROVIDERS_NEEDING_KEY = new Set([
187
- "anthropic",
188
- "openai",
189
- "openrouter",
190
- "gemini",
191
- ]);
192
196
  function validate(config) {
193
- const { summarization, embedding } = config;
197
+ const { summarization } = config;
198
+ // Ollama support was removed — fail with a migration hint, never a generic
199
+ // missing-key error. (Tolerant reads never reach here; this fires only when
200
+ // actually summarizing.)
201
+ if (summarization.providerName === "ollama") {
202
+ throw new Error(`DevLens config error: Ollama is no longer supported.\n` +
203
+ ` Fix: run "devlens init" to configure a cloud provider,\n` +
204
+ ` or switch the active provider with "devlens config --active <provider:key>".`);
205
+ }
194
206
  // Summarization apiKey — resolved from catalog
195
207
  const entry = findProvider(summarization.providerName ?? "");
196
208
  const needsKey = entry?.requiresKey ?? true; // unknown/custom → require key
197
209
  if (needsKey && !summarization.apiKey) {
198
210
  throw new Error(`DevLens config error: summarization.apiKey is required for "${summarization.providerName ?? summarization.provider}".\n` +
199
- ` Fix option 1 — add to ${CONFIG_FILE}:\n` +
211
+ ` Fix option 1 — run "devlens init" (or "devlens config --set") to configure a provider.\n` +
212
+ ` Fix option 2 — add to ${CONFIG_FILE}:\n` +
200
213
  ` { "summarization": { "apiKey": "your-key-here" } }\n` +
201
- ` Fix option 2 — set environment variable:\n` +
202
- ` ${ENV.LLM_KEY}=your-key-here \n` +
203
- `Fix option 3 - Skip Summarization`);
204
- }
205
- // Embedding apiKey — only validate if the user explicitly configured embedding.
206
- // If the user only set summarization, embedding may still be at default (openai
207
- // with no key) which is fine — embedding is only needed for vector search (cloud).
208
- const rawFile = readFileConfig();
209
- const userSetEmbedding = !!rawFile.embedding?.provider;
210
- if (userSetEmbedding &&
211
- EMBEDDING_PROVIDERS_NEEDING_KEY.has(embedding.provider) &&
212
- !embedding.apiKey) {
213
- throw new Error(`DevLens config error: embedding.apiKey is required when provider is "${embedding.provider}".\n` +
214
- ` Fix option 1 — add to ${CONFIG_FILE}:\n` +
215
- ` { "embedding": { "apiKey": "your-key-here" } }\n` +
216
- ` Fix option 2 — set environment variable:\n` +
217
- ` ${ENV.EMBED_KEY}=your-key-here`);
214
+ ` Fix option 3 — set environment variable ${ENV.LLM_KEY}=your-key-here\n` +
215
+ ` Fix option 4 — run analyze WITHOUT --summarize (structure-only needs no key).`);
218
216
  }
219
217
  // Neo4j — if any field is provided, all three must be present
220
218
  if (config.neo4j) {
@@ -230,14 +228,6 @@ function validate(config) {
230
228
  ` ${ENV.NEO4J_PASSWORD}=your-password`);
231
229
  }
232
230
  }
233
- // Ollama baseUrl format
234
- if (summarization.providerName === "ollama") {
235
- const base = summarization.baseUrl ?? "http://localhost:11434/v1";
236
- if (!base.startsWith("http://") && !base.startsWith("https://")) {
237
- throw new Error(`DevLens config error: summarization.baseUrl must start with http:// or https://.\n` +
238
- ` Got: "${base}"`);
239
- }
240
- }
241
231
  }
242
232
  // readFileConfig
243
233
  //
@@ -308,21 +298,24 @@ function extractActiveProvider(storage) {
308
298
  batchSize: entry.batchSize,
309
299
  };
310
300
  }
311
- // loadFileConfig
301
+ // loadFileConfig
312
302
  //
313
303
  // Public entry point — called by resolveConfig() in config/index.ts.
314
304
  //
315
- // Takes the active defaults (chosen by detectOllama() in index.ts):
316
- // - OLLAMA_DEFAULTS if Ollama is running at startup
317
- // - ANTHROPIC_DEFAULTS if Ollama is not detected
318
- //
319
305
  // Steps:
320
306
  // 1. Read ~/.devlens/config.json (partial — only what user set)
321
307
  // 2. Deep merge onto provided defaults
322
308
  // 3. Apply env vars for any still-missing fields
323
- // 4. Validate — throw clear errors for anything missing or invalid
309
+ // 4. Validate (only when opts.validate !== false) — see opts below
324
310
  // 5. Return fully resolved DevLensConfig — never partial, never undefined fields
325
- export function loadFileConfig(defaults = ANTHROPIC_DEFAULTS) {
311
+ //
312
+ // opts.validate:
313
+ // - default (true): summarize paths — throws actionable errors for missing
314
+ // keys / incomplete neo4j / removed providers.
315
+ // - false: tolerant reads for display, init, config editing and
316
+ // structure-only analysis — an incomplete summarization config is NOT an
317
+ // error here (GitHub issue #10). Invalid JSON in the file still throws.
318
+ export function loadFileConfig(defaults = ANTHROPIC_DEFAULTS, opts = {}) {
326
319
  let partial = readFileConfig();
327
320
  // ── Multi-provider detection & migration ─────────────────────────────────
328
321
  if (partial.summarization) {
@@ -376,6 +369,7 @@ export function loadFileConfig(defaults = ANTHROPIC_DEFAULTS) {
376
369
  const merged = deepMerge(defaults, partial);
377
370
  const migrated = migrateProviderConfig(merged);
378
371
  const withEnv = applyEnvVars(migrated);
379
- validate(withEnv);
372
+ if (opts.validate !== false)
373
+ validate(withEnv);
380
374
  return withEnv;
381
375
  }
@@ -7,6 +7,5 @@ export const DEFAULT_PROVIDERS = [
7
7
  { name: "mistral", label: "Mistral", protocol: "openai", baseUrl: "https://api.mistral.ai/v1", requiresKey: true },
8
8
  { name: "xai", label: "xAI Grok", protocol: "openai", baseUrl: "https://api.x.ai/v1", requiresKey: true },
9
9
  { name: "openrouter", label: "OpenRouter", protocol: "openai", baseUrl: "https://openrouter.ai/api/v1", requiresKey: true },
10
- { name: "ollama", label: "Ollama (local)", protocol: "openai", baseUrl: "http://localhost:11434/v1", requiresKey: false },
11
10
  ];
12
11
  export const CATALOG_VERSION = 2;
@@ -1,6 +1,6 @@
1
1
  import { CONFIG_HEADERS } from "../types.js";
2
2
  const VALID_LLM_PROTOCOLS = new Set(["openai", "anthropic"]);
3
- const VALID_EMBED_PROTOCOLS = new Set(["openai", "anthropic", "openrouter", "gemini", "ollama"]);
3
+ const VALID_EMBED_PROTOCOLS = new Set(["openai", "anthropic", "openrouter", "gemini"]);
4
4
  function rejectPlaceholder(v) {
5
5
  if (!v)
6
6
  return undefined;
@@ -48,7 +48,6 @@ export interface DevLensConfig {
48
48
  embedding: EmbeddingConfig;
49
49
  neo4j?: Neo4jConfig;
50
50
  }
51
- export declare const OLLAMA_DEFAULTS: DevLensConfig;
52
51
  export declare const ANTHROPIC_DEFAULTS: DevLensConfig;
53
52
  export declare const CONFIG_HEADERS: {
54
53
  readonly PROVIDER: "x-llm-provider";
@@ -12,22 +12,6 @@ export function parseProviderKey(key) {
12
12
  // Note: apiKey is intentionally absent from all defaults.
13
13
  // If a user reaches the defaults with no key configured anywhere,
14
14
  // the system fails clearly at the LLM call — never silently sends an empty key.
15
- export const OLLAMA_DEFAULTS = {
16
- deploymentMode: "local",
17
- summarization: {
18
- provider: "openai",
19
- providerName: "ollama",
20
- model: "qwen2.5-coder:3b", // code-aware, 3B params, runs on ~2GB RAM
21
- baseUrl: "http://localhost:11434/v1",
22
- batchSize: 50,
23
- },
24
- embedding: {
25
- provider: "ollama",
26
- model: "nomic-embed-text", // best local embedding model, 768 dims
27
- baseUrl: "http://localhost:11434/v1",
28
- },
29
- // neo4j absent — file-only mode is the safe default
30
- };
31
15
  export const ANTHROPIC_DEFAULTS = {
32
16
  deploymentMode: "local",
33
17
  summarization: {
@@ -58,12 +42,12 @@ export const CONFIG_HEADERS = {
58
42
  PROVIDER_NAME: "x-llm-provider-name", // e.g. "deepseek" — brand identity
59
43
  MODEL: "x-llm-model", // e.g. "claude-haiku-4-5"
60
44
  API_KEY: "x-llm-key",
61
- BASE_URL: "x-llm-base-url", // for Ollama: "http://localhost:11434"
45
+ BASE_URL: "x-llm-base-url", // e.g. "https://api.deepseek.com"
62
46
  BATCH_SIZE: "x-batch-size", // e.g. "30"
63
47
  EMBED_PROVIDER: "x-embed-provider",
64
48
  EMBED_MODEL: "x-embed-model",
65
49
  EMBED_KEY: "x-embed-key",
66
- EMBED_BASE_URL: "x-embed-base-url", // for Ollama embedding
50
+ EMBED_BASE_URL: "x-embed-base-url", // cloud embedding endpoint
67
51
  NEO4J_URL: "x-neo4j-url",
68
52
  NEO4J_USER: "x-neo4j-user",
69
53
  NEO4J_PASSWORD: "x-neo4j-password",
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@ export * from "./pipeline/index.js";
9
9
  export * from "./config/types.js";
10
10
  export { queue } from "./jobs/index.js";
11
11
  export { storage } from "./storage/index.js";
12
- export { resolveConfig, initConfig, maskConfig, writeConfig, resolveAllProviders, setActiveProvider, removeProvider as removeProviderConfig, } from "./config/index.js";
12
+ export { resolveConfig, hasSummarizationConfigured, initConfig, maskConfig, writeConfig, resolveAllProviders, setActiveProvider, removeProvider as removeProviderConfig, } from "./config/index.js";
13
13
  export type { AllProvidersResult } from "./config/index.js";
14
14
  export { readPackageDependencies, categorizeLibrary } from "./graph/thirdPartyLibs.js";
15
15
  export { analyzePipeline } from "./pipeline/index.js";
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ export * from "./config/types.js";
11
11
  export { queue } from "./jobs/index.js";
12
12
  export { storage } from "./storage/index.js";
13
13
  // Config helpers
14
- export { resolveConfig, initConfig, maskConfig, writeConfig, resolveAllProviders, setActiveProvider, removeProvider as removeProviderConfig, } from "./config/index.js";
14
+ export { resolveConfig, hasSummarizationConfigured, initConfig, maskConfig, writeConfig, resolveAllProviders, setActiveProvider, removeProvider as removeProviderConfig, } from "./config/index.js";
15
15
  // Pre-scan helpers
16
16
  export { readPackageDependencies, categorizeLibrary } from "./graph/thirdPartyLibs.js";
17
17
  // Pipeline & analysis
@@ -116,7 +116,18 @@ export class InMemoryQueue {
116
116
  this._markCancelled(jobId, true);
117
117
  return true;
118
118
  }
119
- // Running or paused — signal the runner
119
+ // Paused: NO runner loop is active (the summarizer returned at the
120
+ // pause checkpoint), so a cancelRequested flag would sit forever —
121
+ // resumeJob() even clears it. Mark cancelled right here. The
122
+ // checkpoint file stays on disk (cleanedUp=false), so a later
123
+ // summarize resumes from where it stopped.
124
+ if (job.status === "paused") {
125
+ job.cancelRequested = true;
126
+ job.pauseRequested = false;
127
+ this._markCancelled(jobId, false);
128
+ return true;
129
+ }
130
+ // Running — signal the runner (checked between batches)
120
131
  job.cancelRequested = true;
121
132
  job.pauseRequested = false; // clear pause signal if both were set
122
133
  console.log(`🚫 Cancel requested for job ${jobId}`);
@@ -12,13 +12,13 @@ export function createLLMClient(config) {
12
12
  const effectiveBase = baseUrl ?? entry?.baseUrl;
13
13
  switch (provider) {
14
14
  case "openai": {
15
- if (needsKey && !apiKey)
16
- throw new Error(`Provider "${providerName}" requires an API key`);
17
- return new OpenAIClient(apiKey ?? "ollama", model, effectiveBase, providerName);
15
+ if (!apiKey)
16
+ throw new Error(`No API key configured for "${providerName}" — run "devlens init" to set one up.`);
17
+ return new OpenAIClient(apiKey, model, effectiveBase, providerName);
18
18
  }
19
19
  case "anthropic": {
20
20
  if (!apiKey)
21
- throw new Error(`Provider "${providerName}" requires an API key`);
21
+ throw new Error(`No API key configured for "${providerName}" — run "devlens init" to set one up.`);
22
22
  return new AnthropicClient(apiKey, model, effectiveBase, providerName);
23
23
  }
24
24
  default:
@@ -26,7 +26,7 @@ function parseResponse(raw) {
26
26
  tokensUsed: 0,
27
27
  };
28
28
  }
29
- // Also used as the base for OpenRouter and Ollama —
29
+ // Also used as the base for other OpenAI-compatible providers —
30
30
  // both expose OpenAI-compatible APIs, just with a different baseURL.
31
31
  export class OpenAIClient {
32
32
  constructor(apiKey, model, baseURL, providerName) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlensio",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "Codebase intelligence engine for TypeScript/JavaScript/React/Next.js, Python, Java, Go, and Rust repositories.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -21,7 +21,14 @@
21
21
  "prepack": "node extractors/java/build.mjs && node extractors/go/build.mjs && node extractors/rust/build.mjs",
22
22
  "postinstall": "node extractors/python/setup.mjs"
23
23
  },
24
- "keywords": ["codebase", "graph", "ast", "typescript", "javascript", "devtools"],
24
+ "keywords": [
25
+ "codebase",
26
+ "graph",
27
+ "ast",
28
+ "typescript",
29
+ "javascript",
30
+ "devtools"
31
+ ],
25
32
  "author": "",
26
33
  "license": "AGPL-3.0",
27
34
  "files": [
@@ -63,4 +70,4 @@
63
70
  "ts-node-dev": "^2.0.0",
64
71
  "typescript": "^5.9.3"
65
72
  }
66
- }
73
+ }