UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

423 lines 14 kB
/** * User-Level Configuration Manager * * Manages the ~/.aiwg/ (or ~/.config/aiwg/) user configuration directory. * Provides get/set/list/validate/reset/path operations for all config files. * * Resolution order: * 1. AIWG_CONFIG env var (explicit override) * 2. --config-dir CLI flag (explicit override) * 3. ~/.aiwg (primary — simple, discoverable) * 4. ~/.config/aiwg (fallback — XDG-compliant) * * New config files are created in whichever path already exists, * defaulting to ~/.aiwg if neither exists. * * @implements #545 */ import { readFile, writeFile, mkdir, access } from 'fs/promises'; import { resolve } from 'path'; import { homedir } from 'os'; import { existsSync } from 'fs'; /** * Known config files in the user config directory */ export const KNOWN_CONFIG_FILES = [ { filename: 'config.yaml', description: 'Global AIWG user preferences' }, { filename: 'models.json', description: 'Model tier and provider overrides' }, { filename: 'ops.json', description: 'Ops workspace registry' }, { filename: 'mcp-servers.json', description: 'MCP server registry (single source of truth)' }, { filename: 'packages.yaml', description: 'Installed remote packages (aiwg install)' }, ]; /** * Default user config values */ export const DEFAULT_USER_CONFIG = { apiVersion: 'aiwg.io/v1', kind: 'UserConfig', defaults: { provider: 'claude', verbosity: 'normal', }, telemetry: { enabled: false, }, updates: { channel: 'stable', checkOnStartup: true, }, }; /** * Resolve the active user config directory * * Resolution order: * 1. Explicit override (AIWG_CONFIG env or --config-dir) * 2. ~/.aiwg (primary) * 3. ~/.config/aiwg (fallback) * 4. ~/.aiwg (default if neither exists) */ export function resolveConfigDir(overridePath) { // 1. Explicit override from env or CLI flag const envOverride = process.env.AIWG_CONFIG; if (overridePath) { return resolve(overridePath); } if (envOverride) { return resolve(envOverride); } // 2. Check primary path: ~/.aiwg const primaryPath = resolve(homedir(), '.aiwg'); if (existsSync(primaryPath)) { return primaryPath; } // 3. Check fallback path: ~/.config/aiwg const fallbackPath = resolve(homedir(), '.config/aiwg'); if (existsSync(fallbackPath)) { return fallbackPath; } // 4. Default to primary if neither exists return primaryPath; } /** * User-level configuration manager */ export class UserConfig { configDir; configCache = null; constructor(overridePath) { this.configDir = resolveConfigDir(overridePath); } /** * Get the resolved config directory path */ getPath() { return this.configDir; } /** * Ensure the config directory exists */ async ensureDir() { await mkdir(this.configDir, { recursive: true }); } /** * Get a config value by dot-notation key * * Supports keys like: * - "defaults.provider" * - "telemetry.enabled" * - "updates.channel" */ async get(key) { const config = await this.loadConfig(); return getNestedValue(config, key); } /** * Set a config value by dot-notation key */ async set(key, value) { const config = await this.loadConfig(); const parsed = parseValue(value); setNestedValue(config, key, parsed); await this.saveConfig(config); this.configCache = config; } /** * List all config values (merged view across all files) */ async list() { const result = {}; // Load config.yaml (or defaults) const config = await this.loadConfig(); result['config.yaml'] = config; // Check for other known files for (const spec of KNOWN_CONFIG_FILES) { if (spec.filename === 'config.yaml') continue; const filePath = resolve(this.configDir, spec.filename); try { await access(filePath); const content = await readFile(filePath, 'utf-8'); if (spec.filename.endsWith('.json')) { result[spec.filename] = JSON.parse(content); } else { result[spec.filename] = content; } } catch { // File doesn't exist, skip } } return result; } /** * Validate all config files * * Returns array of validation issues (empty = all valid) */ async validate() { const issues = []; // Check config directory exists if (!existsSync(this.configDir)) { issues.push({ file: this.configDir, severity: 'info', message: 'Config directory does not exist (will be created on first use)', }); return issues; } // Validate config.yaml const configPath = resolve(this.configDir, 'config.yaml'); if (existsSync(configPath)) { try { const content = await readFile(configPath, 'utf-8'); const parsed = parseYamlSimple(content); if (!parsed.apiVersion) { issues.push({ file: 'config.yaml', severity: 'warning', message: 'Missing apiVersion field', }); } if (!parsed.kind) { issues.push({ file: 'config.yaml', severity: 'warning', message: 'Missing kind field', }); } } catch (err) { issues.push({ file: 'config.yaml', severity: 'error', message: `Failed to parse: ${err instanceof Error ? err.message : String(err)}`, }); } } // Validate models.json const modelsPath = resolve(this.configDir, 'models.json'); if (existsSync(modelsPath)) { try { const content = await readFile(modelsPath, 'utf-8'); JSON.parse(content); } catch (err) { issues.push({ file: 'models.json', severity: 'error', message: `Invalid JSON: ${err instanceof Error ? err.message : String(err)}`, }); } } // Validate ops.json if present const opsPath = resolve(this.configDir, 'ops.json'); if (existsSync(opsPath)) { try { const content = await readFile(opsPath, 'utf-8'); const parsed = JSON.parse(content); if (parsed.apiVersion && parsed.apiVersion !== 'aiwg.io/v1') { issues.push({ file: 'ops.json', severity: 'warning', message: `Unknown apiVersion: ${parsed.apiVersion}`, }); } } catch (err) { issues.push({ file: 'ops.json', severity: 'error', message: `Failed to parse: ${err instanceof Error ? err.message : String(err)}`, }); } } return issues; } /** * Reset a key to its default value, or reset all config */ async reset(key) { if (!key) { // Reset entire config to defaults await this.saveConfig({ ...DEFAULT_USER_CONFIG }); this.configCache = null; return; } // Reset specific key const config = await this.loadConfig(); const defaultValue = getNestedValue(DEFAULT_USER_CONFIG, key); if (defaultValue !== undefined) { setNestedValue(config, key, defaultValue); await this.saveConfig(config); this.configCache = config; } } /** * Load config.yaml, creating defaults if it doesn't exist */ async loadConfig() { if (this.configCache) { return this.configCache; } const configPath = resolve(this.configDir, 'config.yaml'); try { const content = await readFile(configPath, 'utf-8'); const parsed = parseYamlSimple(content); this.configCache = { ...DEFAULT_USER_CONFIG, ...parsed, defaults: { ...DEFAULT_USER_CONFIG.defaults, ...parsed.defaults }, telemetry: { ...DEFAULT_USER_CONFIG.telemetry, ...parsed.telemetry }, updates: { ...DEFAULT_USER_CONFIG.updates, ...parsed.updates }, }; return this.configCache; } catch { // File doesn't exist — return defaults (don't create file yet) this.configCache = { ...DEFAULT_USER_CONFIG, defaults: { ...DEFAULT_USER_CONFIG.defaults }, telemetry: { ...DEFAULT_USER_CONFIG.telemetry }, updates: { ...DEFAULT_USER_CONFIG.updates }, }; return this.configCache; } } /** * Save config.yaml */ async saveConfig(config) { await this.ensureDir(); const configPath = resolve(this.configDir, 'config.yaml'); const yaml = serializeYamlSimple(config); await writeFile(configPath, yaml, 'utf-8'); } } // ============================================ // Simple YAML helpers (no dependency needed for flat configs) // ============================================ /** * Minimal YAML parser for flat/nested config files. * Handles the subset we use: scalars, nested objects (2 levels). * For complex YAML, users should use a full parser. */ export function parseYamlSimple(content) { const result = {}; let currentSection = null; for (const line of content.split('\n')) { // Skip comments and empty lines if (line.trim().startsWith('#') || line.trim() === '') continue; const indent = line.length - line.trimStart().length; const trimmed = line.trim(); // Key: value pair const match = trimmed.match(/^([^:]+):\s*(.*)$/); if (!match) continue; const key = match[1].trim(); const rawValue = match[2].trim(); if (indent === 0) { if (rawValue === '' || rawValue === undefined) { // Section header currentSection = key; if (!(key in result)) { result[key] = {}; } } else { // Top-level key-value currentSection = null; result[key] = parseScalar(rawValue); } } else if (currentSection && indent >= 2) { // Nested key under current section const section = result[currentSection]; section[key] = parseScalar(rawValue); } } return result; } function parseScalar(value) { if (value === 'true') return true; if (value === 'false') return false; if (value === 'null' || value === '~') return null; if (/^-?\d+$/.test(value)) return parseInt(value, 10); if (/^-?\d+\.\d+$/.test(value)) return parseFloat(value); // Strip quotes if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { return value.slice(1, -1); } return value; } /** * Minimal YAML serializer for our config format */ export function serializeYamlSimple(obj, indent = 0) { const lines = []; const pad = ' '.repeat(indent); for (const [key, value] of Object.entries(obj)) { if (value === null || value === undefined) { lines.push(`${pad}${key}:`); } else if (typeof value === 'object' && !Array.isArray(value)) { lines.push(`${pad}${key}:`); lines.push(serializeYamlSimple(value, indent + 2)); } else if (typeof value === 'string') { // Quote strings that could be misinterpreted const needsQuote = /[:#{}[\],&*?|>!%@`]/.test(value) || value === ''; lines.push(`${pad}${key}: ${needsQuote ? `"${value}"` : value}`); } else { lines.push(`${pad}${key}: ${String(value)}`); } } return lines.join('\n'); } // ============================================ // Dot-notation helpers // ============================================ function getNestedValue(obj, path) { const parts = path.split('.'); let current = obj; for (const part of parts) { if (current === null || current === undefined || typeof current !== 'object') { return undefined; } current = current[part]; } return current; } function setNestedValue(obj, path, value) { const parts = path.split('.'); let current = obj; for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]; if (!(part in current) || typeof current[part] !== 'object' || current[part] === null) { current[part] = {}; } current = current[part]; } current[parts[parts.length - 1]] = value; } function parseValue(value) { if (value === 'true') return true; if (value === 'false') return false; if (value === 'null') return null; if (/^-?\d+$/.test(value)) return parseInt(value, 10); if (/^-?\d+\.\d+$/.test(value)) return parseFloat(value); return value; } //# sourceMappingURL=user-config.js.map