v2.9.1 — Config centralization, settings route extraction, web-ui split

- tools/*.ts: Replace fs.readFileSync config reads with getConfig() + resolveSecret()
  (pubmed, legal, scholar, weather, email, web, files — 7 files)
- tools: Remove direct getVault() calls in email.ts and weather.ts, use resolveSecret()
- gateway: Extract 21 /api/settings/* routes from server-v2.ts into routes/settings.ts
  (server-v2.ts reduced by ~375 lines)
- types: Add missing config fields (legal, semantic_scholar, openweather, weather, email, search.pubmed_api_key)
- web-ui: Split index.html (13,879 lines) into index.html (1,934) + styles.css (2,530) + app.js (9,416)
- web-ui: Allow static assets to bypass auth middleware
- Remove empty index2.html template stub

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
kim
2026-05-23 19:26:23 +09:00
co-authored by Claude Opus 4.7
parent 8aee152f2f
commit 347bab27b3
14 changed files with 12423 additions and 12458 deletions
+14 -38
View File
@@ -1,8 +1,7 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import { ToolResult } from '../types.js';
import { getVault } from '../security/vault.js';
import { getConfig } from '../config/config.js';
// ── Config ────────────────────────────────────────────────────────────────────
@@ -24,30 +23,10 @@ interface EmailAccount {
config: EmailConfig;
}
function resolveConfigPath(workspacePath?: string): string | null {
const perUserPath = workspacePath
? path.join(workspacePath, '.smallclaw', 'email-config.json')
: null;
const globalPath = path.join(os.homedir(), '.smallclaw', 'config.json');
const projectCfg = path.join(process.cwd(), '.smallclaw', 'config.json');
if (perUserPath && fs.existsSync(perUserPath)) return perUserPath;
if (fs.existsSync(projectCfg)) return projectCfg;
if (fs.existsSync(globalPath)) return globalPath;
return null;
}
function parseAccount(raw: any, cfgPath: string): EmailConfig | null {
function parseAccount(raw: any): EmailConfig | null {
if (!raw?.imap_host || !raw?.username) return null;
let password = raw.password || '';
if (password.startsWith('vault:')) {
try {
const vault = getVault(path.dirname(cfgPath));
const secret = vault.get(password.slice(6), 'email:resolve');
password = secret ? secret.expose() : '';
} catch {
password = '';
}
}
const cm = getConfig();
let password = cm.resolveSecret(raw.password || '') || '';
return {
imap_host: raw.imap_host,
imap_port: Number(raw.imap_port ?? 993),
@@ -64,10 +43,9 @@ function parseAccount(raw: any, cfgPath: string): EmailConfig | null {
function getEmailConfig(accountId?: string, workspacePath?: string): EmailConfig | null {
try {
const cfgPath = resolveConfigPath(workspacePath);
if (!cfgPath) return null;
const data = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
const emailCfg = data.email;
const cm = getConfig();
const cfg = cm.getConfig();
const emailCfg = cfg.email;
if (!emailCfg) return null;
let e: any;
@@ -79,7 +57,7 @@ function getEmailConfig(accountId?: string, workspacePath?: string): EmailConfig
} else {
e = emailCfg;
}
return parseAccount(e, cfgPath);
return parseAccount(e);
} catch {
return null;
}
@@ -87,21 +65,20 @@ function getEmailConfig(accountId?: string, workspacePath?: string): EmailConfig
function getAllEmailConfigs(workspacePath?: string): EmailAccount[] {
try {
const cfgPath = resolveConfigPath(workspacePath);
if (!cfgPath) return [];
const data = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
const emailCfg = data.email;
const cm = getConfig();
const cfg = cm.getConfig();
const emailCfg = cfg.email;
if (!emailCfg) return [];
if (Array.isArray(emailCfg.accounts)) {
const accounts: EmailAccount[] = [];
for (const a of emailCfg.accounts) {
const config = parseAccount(a, cfgPath);
const config = parseAccount(a);
if (config) accounts.push({ id: a.id, config });
}
return accounts;
}
const config = parseAccount(emailCfg, cfgPath);
const config = parseAccount(emailCfg);
const id = emailCfg.username ? emailCfg.username.split('@')[0] : 'default';
return config ? [{ id, config }] : [];
} catch {
@@ -355,8 +332,7 @@ export const emailReadTool = {
if (injectedWorkspace) {
resolvedWorkspacePath = path.resolve(String(injectedWorkspace));
} else {
const cfgPath = path.join(process.cwd(), '.smallclaw', 'config.json');
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
const cfg = getConfig().getConfig();
resolvedWorkspacePath = path.resolve(cfg?.workspace?.path || path.join(process.cwd(), 'workspace'));
}
const relDir = path.join('attachments', `uid-${uid}`);
+3 -11
View File
@@ -177,11 +177,6 @@ export interface ReadToolArgs {
type RetrievalMode = 'fast' | 'standard' | 'deep';
function getLocalConfigFilePath(): string {
const projectCfg = path.join(process.cwd(), '.smallclaw', 'config.json');
return fsSync.existsSync(projectCfg) ? projectCfg : path.join(os.homedir(), '.smallclaw', 'config.json');
}
let _retrievalModeCache: { mode: RetrievalMode; expiresAt: number } | null = null;
function getRetrievalMode(): RetrievalMode {
@@ -189,12 +184,9 @@ function getRetrievalMode(): RetrievalMode {
if (_retrievalModeCache && now < _retrievalModeCache.expiresAt) return _retrievalModeCache.mode;
let mode: RetrievalMode = 'standard';
try {
const p = getLocalConfigFilePath();
if (fsSync.existsSync(p)) {
const raw = JSON.parse(fsSync.readFileSync(p, 'utf-8'));
const m = String(raw?.agent_policy?.retrieval_mode || 'standard').toLowerCase();
if (m === 'fast' || m === 'deep') mode = m;
}
const cfg = getConfig().getConfig();
const m = String(cfg?.agent_policy?.retrieval_mode || 'standard').toLowerCase();
if (m === 'fast' || m === 'deep') mode = m;
} catch {}
_retrievalModeCache = { mode, expiresAt: now + 60_000 };
return mode;
+4 -11
View File
@@ -1,21 +1,14 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import { ToolResult } from '../types.js';
import { getConfig } from '../config/config.js';
const LAW_GO_KR_BASE = 'https://www.law.go.kr/DRF';
const COURT_LISTENER_BASE = 'https://www.courtlistener.com/api/rest/v3';
function getLawGoKrApiKey(): string | undefined {
try {
const projectCfg = path.join(process.cwd(), '.smallclaw', 'config.json');
const cfgPath = fs.existsSync(projectCfg)
? projectCfg
: path.join(os.homedir(), '.smallclaw', 'config.json');
if (fs.existsSync(cfgPath)) {
const data = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
return data.legal?.law_go_kr_api_key;
}
const cm = getConfig();
const cfg = cm.getConfig();
return cm.resolveSecret(cfg.legal?.law_go_kr_api_key) || undefined;
} catch {}
return undefined;
}
+4 -10
View File
@@ -1,6 +1,5 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import fs from 'fs';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { ToolResult } from '../types.js';
@@ -13,14 +12,9 @@ const PMC_OA_BASE = 'https://www.ncbi.nlm.nih.gov/pmc/utils/oa/oa.fcgi';
function getPubMedApiKey(): string | undefined {
try {
const projectCfg = path.join(process.cwd(), '.smallclaw', 'config.json');
const cfgPath = fs.existsSync(projectCfg)
? projectCfg
: path.join(os.homedir(), '.smallclaw', 'config.json');
if (fs.existsSync(cfgPath)) {
const data = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
return data.pubmed?.api_key || data.search?.pubmed_api_key;
}
const cm = getConfig();
const cfg = cm.getConfig();
return cm.resolveSecret(cfg.pubmed?.api_key || cfg.search?.pubmed_api_key) || undefined;
} catch {}
return undefined;
}
+5 -11
View File
@@ -1,7 +1,5 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import { ToolResult } from '../types.js';
import { getConfig } from '../config/config.js';
const OPENALEX_BASE = 'https://api.openalex.org';
const S2_BASE = 'https://api.semanticscholar.org/graph/v1';
@@ -9,14 +7,10 @@ const POLITE_EMAIL = 'research@smallclaw.local';
function getScholarConfig(): { s2Key?: string } {
try {
const projectCfg = path.join(process.cwd(), '.smallclaw', 'config.json');
const cfgPath = fs.existsSync(projectCfg)
? projectCfg
: path.join(os.homedir(), '.smallclaw', 'config.json');
if (fs.existsSync(cfgPath)) {
const data = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
return { s2Key: data.semantic_scholar?.api_key || '' };
}
const cm = getConfig();
const cfg = cm.getConfig();
const key = cm.resolveSecret(cfg.semantic_scholar?.api_key);
return { s2Key: key || '' };
} catch {}
return {};
}
+10 -22
View File
@@ -1,32 +1,20 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import { ToolResult } from '../types.js';
import { getConfig } from '../config/config.js';
import { getVault } from '../security/vault.js';
const OW_BASE = 'https://api.openweathermap.org/data/2.5';
function getApiKey(): string | undefined {
try {
const projectCfg = path.join(process.cwd(), '.smallclaw', 'config.json');
const cfgPath = fs.existsSync(projectCfg)
? projectCfg
: path.join(os.homedir(), '.smallclaw', 'config.json');
const configDir = path.dirname(cfgPath);
if (fs.existsSync(cfgPath)) {
const data = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
let raw = data.openweather?.api_key || data.weather?.api_key;
// Resolve vault reference (e.g. "vault:openweather.api_key")
if (typeof raw === 'string' && raw.startsWith('vault:')) {
const vaultKey = raw.slice('vault:'.length);
const entry = getVault(configDir).get(vaultKey, 'weather:getkey');
return entry?.expose();
}
if (raw) return raw;
// Key may be in vault without a config reference
const entry = getVault(configDir).get('openweather.api_key', 'weather:getkey');
return entry?.expose();
}
const cm = getConfig();
const cfg = cm.getConfig();
const resolved = cm.resolveSecret(cfg.openweather?.api_key || cfg.weather?.api_key);
if (resolved) return resolved;
// Fallback: key may be in vault without a config reference
const cm2 = getConfig();
const vault = getVault(cm2.getConfigDir());
const entry = vault.get('openweather.api_key', 'weather:getkey');
return entry?.expose();
} catch {}
return undefined;
}
+20 -28
View File
@@ -1,7 +1,5 @@
import { ToolResult } from '../types.js';
import os from 'os';
import fs from 'fs';
import path from 'path';
import { getConfig } from '../config/config.js';
type SearchResultItem = { title: string; url: string; snippet: string };
@@ -463,7 +461,7 @@ function rankResults(query: string, results: SearchResultItem[]) {
.map(x => x.r);
}
// ── Load optional API keys from ~/.smallclaw/config.json ─────────────────────
// ── Load optional API keys from config ─────────────────────────────────────
// Cached with 5-minute TTL so config changes are picked up without restart.
type SearchConfig = { preferred: 'tavily' | 'google' | 'brave' | 'ddg' | 'searxng'; tavilyKey?: string; googleKey?: string; googleCx?: string; braveKey?: string; searxngUrl?: string };
let _searchConfigCache: { value: SearchConfig; expiresAt: number } | null = null;
@@ -473,22 +471,19 @@ function getSearchConfig(): SearchConfig {
if (_searchConfigCache && now < _searchConfigCache.expiresAt) return _searchConfigCache.value;
let value: SearchConfig = { preferred: 'ddg' };
try {
const projectCfg = path.join(process.cwd(), '.smallclaw', 'config.json');
const cfg = fs.existsSync(projectCfg) ? projectCfg : path.join(os.homedir(), '.smallclaw', 'config.json');
if (fs.existsSync(cfg)) {
const data = JSON.parse(fs.readFileSync(cfg, 'utf-8'));
const preferredRaw = String(data.search?.preferred_provider || 'ddg').toLowerCase();
const preferred = (['tavily', 'google', 'brave', 'ddg', 'searxng'].includes(preferredRaw) ? preferredRaw : 'ddg') as SearchConfig['preferred'];
const searxngRaw = typeof data.search?.searxng_url === 'string' ? data.search.searxng_url.trim().replace(/\/+$/, '') : '';
value = {
preferred,
tavilyKey: data.search?.tavily_api_key,
googleKey: data.search?.google_api_key,
googleCx: data.search?.google_cx,
braveKey: data.search?.brave_api_key,
searxngUrl: searxngRaw || undefined,
};
}
const cm = getConfig();
const data = cm.getConfig();
const preferredRaw = String(data.search?.preferred_provider || 'ddg').toLowerCase();
const preferred = (['tavily', 'google', 'brave', 'ddg', 'searxng'].includes(preferredRaw) ? preferredRaw : 'ddg') as SearchConfig['preferred'];
const searxngRaw = typeof data.search?.searxng_url === 'string' ? data.search.searxng_url.trim().replace(/\/+$/, '') : '';
value = {
preferred,
tavilyKey: cm.resolveSecret(data.search?.tavily_api_key),
googleKey: cm.resolveSecret(data.search?.google_api_key),
googleCx: data.search?.google_cx,
braveKey: cm.resolveSecret(data.search?.brave_api_key),
searxngUrl: searxngRaw || undefined,
};
} catch {}
_searchConfigCache = { value, expiresAt: now + 5 * 60_000 };
return value;
@@ -998,14 +993,11 @@ export const webFetchTool = {
function getOllamaConfig(): { endpoint: string; model: string } {
try {
const projectCfg = path.join(process.cwd(), '.smallclaw', 'config.json');
const cfgPath = fs.existsSync(projectCfg) ? projectCfg : path.join(os.homedir(), '.smallclaw', 'config.json');
if (fs.existsSync(cfgPath)) {
const data = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
const endpoint = String(data.ollama?.endpoint || 'http://localhost:11434');
const model = String(data.llm?.providers?.ollama?.model || data.models?.primary || 'llama3');
return { endpoint, model };
}
const cm = getConfig();
const data = cm.getConfig();
const endpoint = String(data.ollama?.endpoint || 'http://localhost:11434');
const model = String(data.llm?.providers?.ollama?.model || data.models?.primary || 'llama3');
return { endpoint, model };
} catch {}
return { endpoint: 'http://localhost:11434', model: 'llama3' };
}