diff --git a/.agents/configs/aidevops-config.schema.json b/.agents/configs/aidevops-config.schema.json new file mode 100644 index 0000000000..3b6d147eaf --- /dev/null +++ b/.agents/configs/aidevops-config.schema.json @@ -0,0 +1,343 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://aidevops.sh/config.json", + "title": "aidevops Configuration", + "description": "Configuration schema for the aidevops framework. Supports JSONC (JSON with Comments).", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema reference for editor autocomplete and validation." + }, + + "updates": { + "type": "object", + "description": "Auto-update behaviour for aidevops, skills, tools, and OpenClaw.", + "properties": { + "auto_update": { + "type": "boolean", + "default": true, + "description": "Automatically check for and install aidevops updates. Env: AIDEVOPS_AUTO_UPDATE" + }, + "update_interval_minutes": { + "type": "integer", + "default": 10, + "minimum": 1, + "description": "Minutes between update checks. Env: AIDEVOPS_UPDATE_INTERVAL" + }, + "skill_auto_update": { + "type": "boolean", + "default": true, + "description": "Automatically check imported skills for upstream changes. Env: AIDEVOPS_SKILL_AUTO_UPDATE" + }, + "skill_freshness_hours": { + "type": "integer", + "default": 24, + "minimum": 1, + "description": "Hours between skill freshness checks. Env: AIDEVOPS_SKILL_FRESHNESS_HOURS" + }, + "tool_auto_update": { + "type": "boolean", + "default": true, + "description": "Automatically update installed tools when user is idle. Env: AIDEVOPS_TOOL_AUTO_UPDATE" + }, + "tool_freshness_hours": { + "type": "integer", + "default": 6, + "minimum": 1, + "description": "Hours between tool freshness checks. Env: AIDEVOPS_TOOL_FRESHNESS_HOURS" + }, + "tool_idle_hours": { + "type": "integer", + "default": 6, + "minimum": 1, + "description": "Required user idle hours before tool updates run. Env: AIDEVOPS_TOOL_IDLE_HOURS" + }, + "openclaw_auto_update": { + "type": "boolean", + "default": true, + "description": "Automatically check for OpenClaw updates. Env: AIDEVOPS_OPENCLAW_AUTO_UPDATE" + }, + "openclaw_freshness_hours": { + "type": "integer", + "default": 24, + "minimum": 1, + "description": "Hours between OpenClaw update checks. Env: AIDEVOPS_OPENCLAW_FRESHNESS_HOURS" + } + }, + "additionalProperties": false + }, + + "integrations": { + "type": "object", + "description": "AI assistant and external tool integration management.", + "properties": { + "manage_opencode_config": { + "type": "boolean", + "default": true, + "description": "Allow setup.sh to modify OpenCode config." + }, + "manage_claude_config": { + "type": "boolean", + "default": true, + "description": "Allow setup.sh to modify Claude Code config." + } + }, + "additionalProperties": false + }, + + "orchestration": { + "type": "object", + "description": "Supervisor, dispatch, and autonomous operation settings.", + "properties": { + "supervisor_pulse": { + "type": "boolean", + "default": true, + "description": "Enable the autonomous supervisor pulse scheduler. Env: AIDEVOPS_SUPERVISOR_PULSE" + }, + "repo_sync": { + "type": "boolean", + "default": true, + "description": "Enable daily git pull --ff-only on clean repos. Env: AIDEVOPS_REPO_SYNC" + } + }, + "additionalProperties": false + }, + + "safety": { + "type": "object", + "description": "Security hooks, verification, and protective measures.", + "properties": { + "hooks_enabled": { + "type": "boolean", + "default": true, + "description": "Install git pre-commit and pre-push safety hooks." + }, + "verification_enabled": { + "type": "boolean", + "default": true, + "description": "Enable parallel model verification for high-stakes operations." + }, + "verification_tier": { + "type": "string", + "default": "haiku", + "enum": ["haiku", "flash", "sonnet", "pro", "opus"], + "description": "Model tier used for verification checks." + } + }, + "additionalProperties": false + }, + + "ui": { + "type": "object", + "description": "User interface and session experience settings.", + "properties": { + "session_greeting": { + "type": "boolean", + "default": true, + "description": "Show version check and update prompt when starting an AI session." + }, + "shell_aliases": { + "type": "boolean", + "default": true, + "description": "Add aidevops shell aliases to .zshrc/.bashrc during setup." + }, + "onboarding_prompt": { + "type": "boolean", + "default": true, + "description": "Offer to launch /onboarding after setup.sh completes." + } + }, + "additionalProperties": false + }, + + "models": { + "type": "object", + "description": "Model routing, tiers, and provider configuration.", + "properties": { + "tiers": { + "type": "object", + "description": "Model tier assignments. Each tier maps to an ordered list of models.", + "additionalProperties": { + "type": "object", + "properties": { + "models": { + "type": "array", + "items": { "type": "string" }, + "description": "Ordered list of models for this tier." + }, + "fallback": { + "type": "string", + "description": "Tier to fall back to if all models in this tier are unavailable." + } + }, + "required": ["models"] + } + }, + "providers": { + "type": "object", + "description": "Provider endpoint and authentication configuration.", + "additionalProperties": { + "type": "object", + "properties": { + "endpoint": { "type": "string", "format": "uri" }, + "key_env": { "type": ["string", "null"] }, + "probe_timeout_seconds": { "type": "integer", "minimum": 1 } + } + } + }, + "fallback_chains": { + "type": "object", + "description": "Per-tier fallback chains for model-level failover.", + "additionalProperties": { + "type": "array", + "items": { "type": "string" } + } + }, + "fallback_triggers": { + "type": "object", + "description": "Fallback trigger configuration.", + "additionalProperties": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "cooldown_seconds": { "type": "integer", "minimum": 0 } + } + } + }, + "settings": { + "type": "object", + "description": "General model routing settings.", + "properties": { + "probe_timeout_seconds": { "type": "integer", "default": 10 }, + "cache_ttl_seconds": { "type": "integer", "default": 300 }, + "max_chain_depth": { "type": "integer", "default": 5 }, + "default_cooldown_seconds": { "type": "integer", "default": 300 }, + "log_retention_days": { "type": "integer", "default": 30 } + } + }, + "rate_limits": { + "type": "object", + "description": "Rate limits per provider.", + "properties": { + "warn_pct": { "type": "integer", "default": 80, "minimum": 0, "maximum": 100 }, + "window_minutes": { "type": "integer", "default": 1, "minimum": 1 }, + "providers": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "requests_per_min": { "type": "integer", "minimum": 0 }, + "tokens_per_min": { "type": "integer", "minimum": 0 } + } + } + } + } + }, + "gateways": { + "type": "object", + "description": "Gateway provider configuration.", + "additionalProperties": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "endpoint": { "type": "string" }, + "key_env_var": { "type": "string" }, + "account_id": { "type": "string" }, + "gateway_id": { "type": "string" } + } + } + } + }, + "additionalProperties": false + }, + + "quality": { + "type": "object", + "description": "Code quality, linting, and CI/CD timing configuration.", + "properties": { + "sonarcloud_grade": { + "type": "string", + "default": "A", + "enum": ["A", "B", "C", "D", "E"], + "description": "Target quality grade for SonarCloud analysis." + }, + "shellcheck_max_violations": { + "type": "integer", + "default": 0, + "minimum": 0, + "description": "ShellCheck violation tolerance (0 = zero tolerance)." + }, + "ci_timing": { + "type": "object", + "description": "CI/CD service timing constants (seconds).", + "properties": { + "fast_wait": { "type": "integer", "default": 10 }, + "fast_poll": { "type": "integer", "default": 5 }, + "medium_wait": { "type": "integer", "default": 60 }, + "medium_poll": { "type": "integer", "default": 15 }, + "slow_wait": { "type": "integer", "default": 120 }, + "slow_poll": { "type": "integer", "default": 30 }, + "fast_timeout": { "type": "integer", "default": 60 }, + "medium_timeout": { "type": "integer", "default": 180 }, + "slow_timeout": { "type": "integer", "default": 600 }, + "backoff_base": { "type": "integer", "default": 15 }, + "backoff_max": { "type": "integer", "default": 120 }, + "backoff_multiplier": { "type": "integer", "default": 2 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + + "verification": { + "type": "object", + "description": "High-stakes operation verification triggers.", + "properties": { + "enabled": { "type": "boolean", "default": true }, + "default_gate": { + "type": "string", + "default": "warn", + "enum": ["block", "warn", "allow"] + }, + "cross_provider": { "type": "boolean", "default": true }, + "verifier_tier": { "type": "string", "default": "sonnet" }, + "escalation_tier": { "type": "string", "default": "opus" }, + "categories": { + "type": "object", + "description": "Per-category risk levels and gates.", + "additionalProperties": { + "type": "object", + "properties": { + "risk_level": { + "type": "string", + "enum": ["critical", "high", "medium", "low"] + }, + "gate": { + "type": "string", + "enum": ["block", "warn", "allow"] + } + } + } + } + }, + "additionalProperties": false + }, + + "paths": { + "type": "object", + "description": "Directory and file path configuration. Supports ~ for home directory.", + "properties": { + "agents_dir": { "type": "string", "default": "~/.aidevops/agents" }, + "config_dir": { "type": "string", "default": "~/.config/aidevops" }, + "workspace_dir": { "type": "string", "default": "~/.aidevops/.agent-workspace" }, + "log_dir": { "type": "string", "default": "~/.aidevops/logs" }, + "memory_db": { "type": "string", "default": "~/.aidevops/.agent-workspace/memory/memory.db" }, + "worktree_registry_db": { "type": "string", "default": "~/.aidevops/.agent-workspace/worktree-registry.db" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/.agents/configs/aidevops.defaults.jsonc b/.agents/configs/aidevops.defaults.jsonc new file mode 100644 index 0000000000..35774ba204 --- /dev/null +++ b/.agents/configs/aidevops.defaults.jsonc @@ -0,0 +1,319 @@ +{ + // ============================================================================= + // aidevops Configuration — Defaults + // ============================================================================= + // + // This file documents all available configuration options and their defaults. + // DO NOT edit this file — it is overwritten on every update. + // + // To customise, create or edit: + // ~/.config/aidevops/config.jsonc + // + // Or use the CLI: + // aidevops config set updates.auto_update false + // aidevops config list + // aidevops config reset updates.auto_update + // + // Priority order: + // 1. Environment variables (highest) + // 2. ~/.config/aidevops/config.jsonc (user overrides) + // 3. This defaults file (lowest) + // + // JSONC format: supports // comments, /* block comments */, trailing commas. + // ============================================================================= + + "$schema": "./aidevops-config.schema.json", + + // --------------------------------------------------------------------------- + // updates — Auto-update behaviour for aidevops, skills, tools, and OpenClaw + // --------------------------------------------------------------------------- + "updates": { + // Automatically check for and install aidevops updates (every N minutes). + // Set to false to disable all automatic updates. + // Manual update: aidevops update + // Env override: AIDEVOPS_AUTO_UPDATE + "auto_update": true, + + // Minutes between update checks. + // Env override: AIDEVOPS_UPDATE_INTERVAL + "update_interval_minutes": 10, + + // Automatically check imported skills for upstream changes. + // Env override: AIDEVOPS_SKILL_AUTO_UPDATE + "skill_auto_update": true, + + // Hours between skill freshness checks. + // Env override: AIDEVOPS_SKILL_FRESHNESS_HOURS + "skill_freshness_hours": 24, + + // Automatically update installed tools (npm, brew, pip) when user is idle. + // Env override: AIDEVOPS_TOOL_AUTO_UPDATE + "tool_auto_update": true, + + // Hours between tool freshness checks. + // Env override: AIDEVOPS_TOOL_FRESHNESS_HOURS + "tool_freshness_hours": 6, + + // Required user idle hours before tool updates run. + // Env override: AIDEVOPS_TOOL_IDLE_HOURS + "tool_idle_hours": 6, + + // Automatically check for OpenClaw updates (daily, if installed). + // Env override: AIDEVOPS_OPENCLAW_AUTO_UPDATE + "openclaw_auto_update": true, + + // Hours between OpenClaw update checks. + // Env override: AIDEVOPS_OPENCLAW_FRESHNESS_HOURS + "openclaw_freshness_hours": 24 + }, + + // --------------------------------------------------------------------------- + // integrations — AI assistant and external tool integration management + // --------------------------------------------------------------------------- + "integrations": { + // Allow setup.sh to modify OpenCode config (agents, MCPs, settings). + // Set to false if you manage opencode.json manually. + "manage_opencode_config": true, + + // Allow setup.sh to modify Claude Code config (commands, MCPs, settings). + // Set to false if you manage Claude Code config manually. + "manage_claude_config": true + }, + + // --------------------------------------------------------------------------- + // orchestration — Supervisor, dispatch, and autonomous operation settings + // --------------------------------------------------------------------------- + "orchestration": { + // Enable the autonomous supervisor pulse scheduler. + // Dispatches workers, merges PRs, evaluates results. + // Env override: AIDEVOPS_SUPERVISOR_PULSE + "supervisor_pulse": true, + + // Enable daily git pull --ff-only on clean repos. + // Env override: AIDEVOPS_REPO_SYNC + "repo_sync": true + }, + + // --------------------------------------------------------------------------- + // safety — Security hooks, verification, and protective measures + // --------------------------------------------------------------------------- + "safety": { + // Install git pre-commit and pre-push safety hooks that block + // destructive commands. Set to false if hooks conflict with your workflow. + "hooks_enabled": true, + + // Enable parallel model verification for high-stakes operations. + // When true, destructive operations are verified by a second model. + "verification_enabled": true, + + // Model tier used for verification checks (cheapest sufficient tier). + "verification_tier": "haiku" + }, + + // --------------------------------------------------------------------------- + // ui — User interface and session experience settings + // --------------------------------------------------------------------------- + "ui": { + // Show version check and update prompt when starting an AI session. + // Set to false for a quieter startup experience. + "session_greeting": true, + + // Add aidevops shell aliases to .zshrc/.bashrc during setup. + // Set to false if you prefer to manage your shell config manually. + "shell_aliases": true, + + // Offer to launch /onboarding after setup.sh completes. + // Set to false to skip the prompt. + "onboarding_prompt": true + }, + + // --------------------------------------------------------------------------- + // models — Model routing, tiers, and provider configuration + // --------------------------------------------------------------------------- + "models": { + // Model tier assignments. Each tier maps to an ordered list of models. + // The first available model in the list is used. + "tiers": { + "local": { "models": ["local/llama.cpp"], "fallback": "haiku" }, + "haiku": { "models": ["anthropic/claude-haiku-4-5"] }, + "flash": { "models": ["anthropic/claude-haiku-4-5"] }, + "sonnet": { "models": ["anthropic/claude-sonnet-4-6"] }, + "pro": { "models": ["anthropic/claude-sonnet-4-6"] }, + "opus": { "models": ["anthropic/claude-opus-4-6"] }, + "coding": { "models": ["anthropic/claude-opus-4-6", "anthropic/claude-sonnet-4-6"] }, + "eval": { "models": ["anthropic/claude-sonnet-4-6"] }, + "health": { "models": ["anthropic/claude-sonnet-4-6"] } + }, + + // Provider endpoint and authentication configuration. + "providers": { + "local": { + "endpoint": "http://localhost:8080/v1/chat/completions", + "key_env": null, + "probe_timeout_seconds": 3 + }, + "anthropic": { + "endpoint": "https://api.anthropic.com/v1/messages", + "key_env": "ANTHROPIC_API_KEY", + "probe_timeout_seconds": 10 + } + }, + + // Fallback chain configuration for model-level failover. + "fallback_chains": { + "haiku": ["anthropic/claude-haiku-4-5"], + "flash": ["anthropic/claude-haiku-4-5"], + "sonnet": ["anthropic/claude-sonnet-4-6"], + "pro": ["anthropic/claude-sonnet-4-6"], + "opus": ["anthropic/claude-opus-4-6"], + "coding": ["anthropic/claude-opus-4-6", "anthropic/claude-sonnet-4-6"], + "eval": ["anthropic/claude-sonnet-4-6"], + "health": ["anthropic/claude-sonnet-4-6"], + "default": ["anthropic/claude-sonnet-4-6"] + }, + + // Fallback trigger configuration. + "fallback_triggers": { + "api_error": { "enabled": true, "cooldown_seconds": 300 }, + "timeout": { "enabled": true, "cooldown_seconds": 180 }, + "rate_limit": { "enabled": true, "cooldown_seconds": 60 }, + "auth_error": { "enabled": true, "cooldown_seconds": 3600 }, + "overloaded": { "enabled": true, "cooldown_seconds": 120 } + }, + + // General model routing settings. + "settings": { + "probe_timeout_seconds": 10, + "cache_ttl_seconds": 300, + "max_chain_depth": 5, + "default_cooldown_seconds": 300, + "log_retention_days": 30 + }, + + // Rate limits per provider (requests/tokens per minute). + // Copy and adjust for your plan tier. + "rate_limits": { + "warn_pct": 80, + "window_minutes": 1, + "providers": { + "anthropic": { + "requests_per_min": 50, + "tokens_per_min": 40000 + }, + "openai": { + "requests_per_min": 500, + "tokens_per_min": 200000 + }, + "google": { + "requests_per_min": 60, + "tokens_per_min": 1000000 + }, + "deepseek": { + "requests_per_min": 60, + "tokens_per_min": 100000 + }, + "openrouter": { + "requests_per_min": 200, + "tokens_per_min": 500000 + }, + "groq": { + "requests_per_min": 30, + "tokens_per_min": 6000 + }, + "xai": { + "requests_per_min": 60, + "tokens_per_min": 100000 + } + } + }, + + // Gateway provider configuration for provider-level fallback routing. + "gateways": { + "openrouter": { + "enabled": false, + "endpoint": "https://openrouter.ai/api/v1", + "key_env_var": "OPENROUTER_API_KEY" + }, + "cloudflare": { + "enabled": false, + "account_id": "", + "gateway_id": "", + "key_env_var": "CF_AIG_TOKEN" + } + } + }, + + // --------------------------------------------------------------------------- + // quality — Code quality, linting, and CI/CD timing configuration + // --------------------------------------------------------------------------- + "quality": { + // Target quality grade for SonarCloud analysis. + "sonarcloud_grade": "A", + + // ShellCheck violation tolerance (0 = zero tolerance). + "shellcheck_max_violations": 0, + + // CI/CD service timing constants (seconds). + // Based on observed completion times across multiple PRs. + "ci_timing": { + "fast_wait": 10, + "fast_poll": 5, + "medium_wait": 60, + "medium_poll": 15, + "slow_wait": 120, + "slow_poll": 30, + "fast_timeout": 60, + "medium_timeout": 180, + "slow_timeout": 600, + "backoff_base": 15, + "backoff_max": 120, + "backoff_multiplier": 2 + } + }, + + // --------------------------------------------------------------------------- + // verification — High-stakes operation verification triggers + // --------------------------------------------------------------------------- + "verification": { + // Global verification policy. + "enabled": true, + "default_gate": "warn", + "cross_provider": true, + "verifier_tier": "sonnet", + "escalation_tier": "opus", + + // Per-category risk levels and gates. + // "block" = prevent execution, "warn" = log warning but allow. + "categories": { + "git_destructive": { "risk_level": "critical", "gate": "block" }, + "production_deploy": { "risk_level": "critical", "gate": "block" }, + "data_migration": { "risk_level": "high", "gate": "warn" }, + "security_sensitive": { "risk_level": "high", "gate": "warn" }, + "financial": { "risk_level": "high", "gate": "warn" }, + "infrastructure_destruction": { "risk_level": "critical", "gate": "block" } + } + }, + + // --------------------------------------------------------------------------- + // paths — Directory and file path configuration + // --------------------------------------------------------------------------- + "paths": { + // Base installation directory for aidevops agents. + "agents_dir": "~/.aidevops/agents", + + // User configuration directory. + "config_dir": "~/.config/aidevops", + + // Workspace directory for agent operations. + "workspace_dir": "~/.aidevops/.agent-workspace", + + // Log directory. + "log_dir": "~/.aidevops/logs", + + // SQLite memory database location. + "memory_db": "~/.aidevops/.agent-workspace/memory/memory.db", + + // Worktree registry database. + "worktree_registry_db": "~/.aidevops/.agent-workspace/worktree-registry.db" + } +} diff --git a/.agents/configs/feature-toggles.conf.defaults b/.agents/configs/feature-toggles.conf.defaults index e01fd1a23e..71af10b05f 100644 --- a/.agents/configs/feature-toggles.conf.defaults +++ b/.agents/configs/feature-toggles.conf.defaults @@ -1,27 +1,17 @@ # shellcheck shell=bash # ============================================================================= -# aidevops Feature Toggles — Default Configuration +# aidevops Feature Toggles — Legacy Default Configuration # ============================================================================= # -# This file documents all available feature toggles and their defaults. -# DO NOT edit this file — it is overwritten on every update. +# DEPRECATED: This file is kept for backward compatibility only. +# The primary config system is now JSONC: +# Defaults: ~/.aidevops/agents/configs/aidevops.defaults.jsonc +# User: ~/.config/aidevops/config.jsonc # -# To customise, create or edit: -# ~/.config/aidevops/feature-toggles.conf +# Run 'aidevops config migrate' to convert your overrides to JSONC. +# Run 'aidevops config list' to see all available options. # -# Or use the CLI: -# aidevops config set auto_update false -# aidevops config list -# aidevops config reset auto_update -# -# Format: KEY=value (shell-sourceable). Lines starting with # are comments. -# Values: true | false (booleans), or strings/numbers where noted. -# -# Environment variables (e.g. AIDEVOPS_AUTO_UPDATE=false) always take -# precedence over this file. The priority order is: -# 1. Environment variable (highest) -# 2. ~/.config/aidevops/feature-toggles.conf (user overrides) -# 3. This defaults file (lowest) +# This file is only used as a fallback when jq is not installed. # ============================================================================= # ----------------------------------------------------------------------------- diff --git a/.agents/scripts/auto-update-helper.sh b/.agents/scripts/auto-update-helper.sh index 0ed2eaa8bb..f2176cd3c0 100755 --- a/.agents/scripts/auto-update-helper.sh +++ b/.agents/scripts/auto-update-helper.sh @@ -384,19 +384,21 @@ update_state() { ####################################### # Check skill freshness and auto-update if stale (24h gate) # Called from cmd_check after the main aidevops update logic. -# Respects feature toggle: aidevops config set skill_auto_update false +# Respects config: aidevops config set updates.skill_auto_update false ####################################### check_skill_freshness() { - # Opt-out via feature toggle (env var or config file) + # Opt-out via config (env var or config file) if ! is_feature_enabled skill_auto_update 2>/dev/null; then - log_info "Skill auto-update disabled via feature toggle" + log_info "Skill auto-update disabled via config" return 0 fi - local freshness_hours="${AIDEVOPS_SKILL_FRESHNESS_HOURS:-$DEFAULT_SKILL_FRESHNESS_HOURS}" + # Read from JSONC config (handles env var > user config > defaults priority) + local freshness_hours + freshness_hours=$(get_feature_toggle skill_freshness_hours "$DEFAULT_SKILL_FRESHNESS_HOURS") # Validate freshness_hours is a positive integer (non-numeric crashes under set -e) if ! [[ "$freshness_hours" =~ ^[0-9]+$ ]] || [[ "$freshness_hours" -eq 0 ]]; then - log_warn "AIDEVOPS_SKILL_FRESHNESS_HOURS='${freshness_hours}' is not a positive integer — using default (${DEFAULT_SKILL_FRESHNESS_HOURS}h)" + log_warn "updates.skill_freshness_hours='${freshness_hours}' is not a positive integer — using default (${DEFAULT_SKILL_FRESHNESS_HOURS}h)" freshness_hours="$DEFAULT_SKILL_FRESHNESS_HOURS" fi local freshness_seconds=$((freshness_hours * 3600)) @@ -496,13 +498,13 @@ update_skill_check_timestamp() { ####################################### # Check openclaw freshness and auto-update if stale (24h gate) # Called from cmd_check after skill freshness check. -# Respects feature toggle: aidevops config set openclaw_auto_update false +# Respects config: aidevops config set updates.openclaw_auto_update false # Only runs if openclaw CLI is installed. ####################################### check_openclaw_freshness() { - # Opt-out via feature toggle (env var or config file) + # Opt-out via config (env var or config file) if ! is_feature_enabled openclaw_auto_update 2>/dev/null; then - log_info "OpenClaw auto-update disabled via feature toggle" + log_info "OpenClaw auto-update disabled via config" return 0 fi @@ -511,9 +513,11 @@ check_openclaw_freshness() { return 0 fi - local freshness_hours="${AIDEVOPS_OPENCLAW_FRESHNESS_HOURS:-$DEFAULT_OPENCLAW_FRESHNESS_HOURS}" + # Read from JSONC config (handles env var > user config > defaults priority) + local freshness_hours + freshness_hours=$(get_feature_toggle openclaw_freshness_hours "$DEFAULT_OPENCLAW_FRESHNESS_HOURS") if ! [[ "$freshness_hours" =~ ^[0-9]+$ ]] || [[ "$freshness_hours" -eq 0 ]]; then - log_warn "AIDEVOPS_OPENCLAW_FRESHNESS_HOURS='${freshness_hours}' is not a positive integer — using default (${DEFAULT_OPENCLAW_FRESHNESS_HOURS}h)" + log_warn "updates.openclaw_freshness_hours='${freshness_hours}' is not a positive integer — using default (${DEFAULT_OPENCLAW_FRESHNESS_HOURS}h)" freshness_hours="$DEFAULT_OPENCLAW_FRESHNESS_HOURS" fi local freshness_seconds=$((freshness_hours * 3600)) @@ -709,19 +713,20 @@ get_user_idle_seconds() { # Only runs when user has been idle for AIDEVOPS_TOOL_IDLE_HOURS. # Delegates to tool-version-check.sh --update --quiet. # Called from cmd_check after other freshness checks. -# Respects feature toggle: aidevops config set tool_auto_update false +# Respects config: aidevops config set updates.tool_auto_update false ####################################### check_tool_freshness() { - # Opt-out via feature toggle (env var or config file) + # Opt-out via config (env var or config file) if ! is_feature_enabled tool_auto_update 2>/dev/null; then - log_info "Tool auto-update disabled via feature toggle" + log_info "Tool auto-update disabled via config" return 0 fi + # Read from JSONC config (handles env var > user config > defaults priority) local freshness_hours - freshness_hours="${AIDEVOPS_TOOL_FRESHNESS_HOURS:-$DEFAULT_TOOL_FRESHNESS_HOURS}" + freshness_hours=$(get_feature_toggle tool_freshness_hours "$DEFAULT_TOOL_FRESHNESS_HOURS") if ! [[ "$freshness_hours" =~ ^[0-9]+$ ]] || [[ "$freshness_hours" -eq 0 ]]; then - log_warn "AIDEVOPS_TOOL_FRESHNESS_HOURS='${freshness_hours}' is not a positive integer — using default (${DEFAULT_TOOL_FRESHNESS_HOURS}h)" + log_warn "updates.tool_freshness_hours='${freshness_hours}' is not a positive integer — using default (${DEFAULT_TOOL_FRESHNESS_HOURS}h)" freshness_hours="$DEFAULT_TOOL_FRESHNESS_HOURS" fi local freshness_seconds @@ -757,10 +762,11 @@ check_tool_freshness() { fi # Check user idle time — only update when user is away + # Read from JSONC config (handles env var > user config > defaults priority) local idle_hours - idle_hours="${AIDEVOPS_TOOL_IDLE_HOURS:-$DEFAULT_TOOL_IDLE_HOURS}" + idle_hours=$(get_feature_toggle tool_idle_hours "$DEFAULT_TOOL_IDLE_HOURS") if ! [[ "$idle_hours" =~ ^[0-9]+$ ]] || [[ "$idle_hours" -eq 0 ]]; then - log_warn "AIDEVOPS_TOOL_IDLE_HOURS='${idle_hours}' is not a positive integer — using default (${DEFAULT_TOOL_IDLE_HOURS}h)" + log_warn "updates.tool_idle_hours='${idle_hours}' is not a positive integer — using default (${DEFAULT_TOOL_IDLE_HOURS}h)" idle_hours="$DEFAULT_TOOL_IDLE_HOURS" fi local idle_threshold_seconds @@ -857,9 +863,9 @@ update_tool_check_timestamp() { cmd_check() { ensure_dirs - # Respect feature toggle (env var or config file) + # Respect config (env var or config file) if ! is_feature_enabled auto_update 2>/dev/null; then - log_info "Auto-update disabled via feature toggle (env var or aidevops config)" + log_info "Auto-update disabled via config (updates.auto_update)" return 0 fi @@ -969,7 +975,14 @@ cmd_check() { cmd_enable() { ensure_dirs - local interval="${AIDEVOPS_UPDATE_INTERVAL:-$DEFAULT_INTERVAL}" + # Read from JSONC config (handles env var > user config > defaults priority) + local interval + interval=$(get_feature_toggle update_interval "$DEFAULT_INTERVAL") + # Validate interval is a positive integer + if ! [[ "$interval" =~ ^[0-9]+$ ]] || [[ "$interval" -eq 0 ]]; then + log_warn "updates.update_interval_minutes='${interval}' is not a positive integer — using default (${DEFAULT_INTERVAL}m)" + interval="$DEFAULT_INTERVAL" + fi local script_path="$HOME/.aidevops/agents/scripts/auto-update-helper.sh" # Verify the script exists at the deployed location @@ -1251,8 +1264,9 @@ cmd_status() { idle_secs=$(get_user_idle_seconds) idle_h=$((idle_secs / 3600)) idle_m=$(((idle_secs % 3600) / 60)) + # Read from JSONC config (handles env var > user config > defaults priority) local idle_threshold - idle_threshold="${AIDEVOPS_TOOL_IDLE_HOURS:-$DEFAULT_TOOL_IDLE_HOURS}" + idle_threshold=$(get_feature_toggle tool_idle_hours "$DEFAULT_TOOL_IDLE_HOURS") # Validate idle_threshold is a positive integer (mirrors check_tool_freshness) if ! [[ "$idle_threshold" =~ ^[0-9]+$ ]] || [[ "$idle_threshold" -eq 0 ]]; then idle_threshold="$DEFAULT_TOOL_IDLE_HOURS" @@ -1264,22 +1278,22 @@ cmd_status() { fi fi - # Check feature toggle overrides (env var or config file) + # Check config overrides (env var or config file) if ! is_feature_enabled auto_update 2>/dev/null; then echo "" - echo -e " ${YELLOW}Note: auto_update disabled (overrides scheduler)${NC}" + echo -e " ${YELLOW}Note: updates.auto_update disabled (overrides scheduler)${NC}" fi if ! is_feature_enabled skill_auto_update 2>/dev/null; then echo "" - echo -e " ${YELLOW}Note: skill_auto_update disabled (skill freshness disabled)${NC}" + echo -e " ${YELLOW}Note: updates.skill_auto_update disabled${NC}" fi if ! is_feature_enabled openclaw_auto_update 2>/dev/null; then echo "" - echo -e " ${YELLOW}Note: openclaw_auto_update disabled (OpenClaw auto-update disabled)${NC}" + echo -e " ${YELLOW}Note: updates.openclaw_auto_update disabled${NC}" fi if ! is_feature_enabled tool_auto_update 2>/dev/null; then echo "" - echo -e " ${YELLOW}Note: tool_auto_update disabled (tool auto-update disabled)${NC}" + echo -e " ${YELLOW}Note: updates.tool_auto_update disabled${NC}" fi echo "" diff --git a/.agents/scripts/config-helper.sh b/.agents/scripts/config-helper.sh new file mode 100755 index 0000000000..75c92b372a --- /dev/null +++ b/.agents/scripts/config-helper.sh @@ -0,0 +1,982 @@ +#!/usr/bin/env bash +# config-helper.sh - JSONC configuration reader/writer for aidevops +# +# Provides get/set/list/reset/migrate operations on the aidevops JSONC config. +# Called by `aidevops config ` CLI and sourced by shared-constants.sh. +# +# Usage (CLI): +# config-helper.sh list List all config with current values +# config-helper.sh get Get a config value (e.g. updates.auto_update) +# config-helper.sh set Set a config value +# config-helper.sh reset [dotpath] Reset one or all config to defaults +# config-helper.sh path Show config file paths +# config-helper.sh migrate Migrate from feature-toggles.conf +# config-helper.sh validate Validate user config against schema +# config-helper.sh help Show this help +# +# Usage (sourced by shared-constants.sh): +# _jsonc_get [default] Get a value from merged config +# _jsonc_get_raw Get a value from a specific file +# +# Files: +# Defaults: ~/.aidevops/agents/configs/aidevops.defaults.jsonc +# User config: ~/.config/aidevops/config.jsonc +# Old config: ~/.config/aidevops/feature-toggles.conf (migrated on first use) + +# Apply strict mode only when executed directly (not when sourced by another script) +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + set -euo pipefail +fi + +# Resolve script directory (works when sourced or executed) +_CONFIG_HELPER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" || return 2>/dev/null || exit + +# Only source shared-constants.sh when running standalone (not when sourced by it) +if [[ -z "${_SHARED_CONSTANTS_LOADED:-}" ]]; then + # shellcheck source=shared-constants.sh + source "${_CONFIG_HELPER_DIR}/shared-constants.sh" 2>/dev/null || true +fi + +# --------------------------------------------------------------------------- +# File paths (use defaults if not already set — allows override for testing) +# --------------------------------------------------------------------------- +JSONC_DEFAULTS="${JSONC_DEFAULTS:-${HOME}/.aidevops/agents/configs/aidevops.defaults.jsonc}" +JSONC_USER="${JSONC_USER:-${HOME}/.config/aidevops/config.jsonc}" +JSONC_SCHEMA="${JSONC_SCHEMA:-${HOME}/.aidevops/agents/configs/aidevops-config.schema.json}" +OLD_CONF_USER="${OLD_CONF_USER:-${HOME}/.config/aidevops/feature-toggles.conf}" +OLD_CONF_DEFAULTS="${OLD_CONF_DEFAULTS:-${HOME}/.aidevops/agents/configs/feature-toggles.conf.defaults}" +MIGRATE_FAILED_FLAG="${MIGRATE_FAILED_FLAG:-${HOME}/.aidevops/migrate_failed}" + +# Cache for merged config (avoid re-parsing on every call) +_JSONC_MERGED_CACHE="" +_JSONC_CACHE_MTIME="" + +# --------------------------------------------------------------------------- +# Validate a dotpath contains only safe characters (letters, digits, _, .) +# Returns 0 if valid, 1 if invalid. Prevents injection via dotpath args. +# --------------------------------------------------------------------------- +_validate_dotpath() { + local dotpath="$1" + if [[ ! "$dotpath" =~ ^[a-zA-Z_][a-zA-Z0-9_.]*$ ]]; then + echo "[ERROR] Invalid config key: $dotpath (only letters, digits, _, . allowed)" >&2 + return 1 + fi + return 0 +} + +# --------------------------------------------------------------------------- +# JSONC → JSON stripping (remove // and /* */ comments, trailing commas) +# Uses jq if available, falls back to sed for basic stripping. +# --------------------------------------------------------------------------- +_strip_jsonc() { + local file="$1" + if [[ ! -r "$file" ]]; then + echo "[config] Cannot read JSONC file: $file" >&2 + return 1 + fi + + # Strategy: use a line-by-line approach that's aware of string context. + # 1. Remove // comments only when not inside a JSON string value + # 2. Remove /* */ block comments (handles multiple per line via while loop) + # 3. Remove trailing commas before } or ] + # + # We use awk for context-aware comment stripping, then jq for validation. + local stripped + stripped=$(awk ' + BEGIN { in_block = 0 } + { + line = $0 + # Handle block comment start/end + if (in_block) { + idx = index(line, "*/") + if (idx > 0) { + line = substr(line, idx + 2) + in_block = 0 + } else { + next + } + } + # Remove all single-line block comments: /* ... */ (loop for multiple per line) + while (match(line, /\/\*[^*]*\*\//)) { + line = substr(line, 1, RSTART - 1) substr(line, RSTART + RLENGTH) + } + # Check for block comment start without end + idx = index(line, "/*") + if (idx > 0) { + line = substr(line, 1, idx - 1) + in_block = 1 + } + # Remove // line comments (only outside of strings) + # Simple heuristic: find // that is not preceded by : (URL context) + # and not inside a quoted string + n = split(line, chars, "") + result = "" + in_string = 0 + i = 1 + while (i <= n) { + c = chars[i] + if (c == "\"" && (i == 1 || chars[i-1] != "\\")) { + in_string = !in_string + result = result c + } else if (!in_string && c == "/" && i < n && chars[i+1] == "/") { + break # rest of line is comment + } else { + result = result c + } + i++ + } + print result + } + ' "$file" | + sed -e 's/,[[:space:]]*}/}/g' -e 's/,[[:space:]]*\]/]/g') || { + echo "[config] Failed to strip JSONC comments from: $file" >&2 + return 1 + } + + # Validate with jq + if command -v jq &>/dev/null; then + echo "$stripped" | jq '.' 2>/dev/null || { + echo "[config] Invalid JSON after stripping comments from: $file" >&2 + return 1 + } + else + echo "$stripped" + fi + return 0 +} + +# --------------------------------------------------------------------------- +# Merge defaults + user config (user overrides defaults via jq * operator) +# --------------------------------------------------------------------------- +_merge_configs() { + local defaults_json user_json + + defaults_json=$(_strip_jsonc "$JSONC_DEFAULTS") || { + echo "[config] Failed to parse defaults — config system unavailable" >&2 + echo "{}" + return 1 + } + # User config may not exist yet — that's normal, fall back to empty. + # But if it exists and is malformed, propagate the error — don't silently ignore. + if [[ -f "$JSONC_USER" ]]; then + user_json=$(_strip_jsonc "$JSONC_USER") || { + echo "[config] Malformed user config: $JSONC_USER" >&2 + echo " Run 'aidevops config validate' to diagnose, or 'aidevops config reset' to fix." >&2 + return 1 + } + else + user_json="{}" + fi + + if command -v jq &>/dev/null; then + # Deep merge: defaults * user (user wins on conflicts) + local merge_stderr merge_result + merge_stderr=$(mktemp 2>/dev/null || echo "/tmp/aidevops-merge-err.$$") + if merge_result=$(echo "$defaults_json" | jq --argjson user "$user_json" '. * $user' 2>"$merge_stderr"); then + echo "$merge_result" + else + echo "[config] Deep merge failed (defaults=$JSONC_DEFAULTS, user=$JSONC_USER), using defaults only" >&2 + if [[ -s "$merge_stderr" ]]; then + echo "[config] jq error: $(cat "$merge_stderr")" >&2 + fi + echo "$defaults_json" + fi + rm -f "$merge_stderr" + else + # No jq — return defaults only (user overrides not applied) + echo "$defaults_json" + fi + return 0 +} + +# --------------------------------------------------------------------------- +# Get merged config with caching +# --------------------------------------------------------------------------- +_get_merged_config() { + # Check if cache is still valid (based on file mtimes) + local current_mtime="" + if [[ -f "$JSONC_DEFAULTS" ]]; then + current_mtime=$(stat -c %Y "$JSONC_DEFAULTS" 2>/dev/null || stat -f %m "$JSONC_DEFAULTS" 2>/dev/null || echo "0") + fi + if [[ -f "$JSONC_USER" ]]; then + local user_mtime + user_mtime=$(stat -c %Y "$JSONC_USER" 2>/dev/null || stat -f %m "$JSONC_USER" 2>/dev/null || echo "0") + current_mtime="${current_mtime}:${user_mtime}" + fi + + if [[ -n "$_JSONC_MERGED_CACHE" && "$_JSONC_CACHE_MTIME" == "$current_mtime" ]]; then + echo "$_JSONC_MERGED_CACHE" + return 0 + fi + + _JSONC_MERGED_CACHE=$(_merge_configs) + _JSONC_CACHE_MTIME="$current_mtime" + echo "$_JSONC_MERGED_CACHE" + return 0 +} + +# --------------------------------------------------------------------------- +# Core get function: read a value from merged config by dot-path +# Usage: _jsonc_get [default] +# Example: _jsonc_get "updates.auto_update" "true" +# --------------------------------------------------------------------------- +_jsonc_get() { + local dotpath="$1" + local default="${2:-}" + + if ! command -v jq &>/dev/null; then + echo "$default" + return 0 + fi + + local merged + merged=$(_get_merged_config) + + # Use jq --arg to safely pass dotpath (no shell interpolation into filter) + local value + value=$(echo "$merged" | jq -r --arg p "$dotpath" 'getpath($p | split(".")) // empty' 2>/dev/null) || value="" + + if [[ -n "$value" && "$value" != "null" ]]; then + echo "$value" + else + echo "$default" + fi + return 0 +} + +# --------------------------------------------------------------------------- +# Get raw value from a specific file (no merging) +# --------------------------------------------------------------------------- +_jsonc_get_raw() { + local file="$1" + local dotpath="$2" + + if ! command -v jq &>/dev/null; then + echo "" + return 0 + fi + + local json + json=$(_strip_jsonc "$file") || { + echo "" + return 0 + } + echo "$json" | jq -r --arg p "$dotpath" 'getpath($p | split(".")) // empty' 2>/dev/null || echo "" + return 0 +} + +# --------------------------------------------------------------------------- +# Environment variable override map +# Maps config dot-paths to environment variable names +# --------------------------------------------------------------------------- +_config_env_map() { + local dotpath="$1" + case "$dotpath" in + updates.auto_update) echo "AIDEVOPS_AUTO_UPDATE" ;; + updates.update_interval_minutes) echo "AIDEVOPS_UPDATE_INTERVAL" ;; + updates.skill_auto_update) echo "AIDEVOPS_SKILL_AUTO_UPDATE" ;; + updates.skill_freshness_hours) echo "AIDEVOPS_SKILL_FRESHNESS_HOURS" ;; + updates.tool_auto_update) echo "AIDEVOPS_TOOL_AUTO_UPDATE" ;; + updates.tool_freshness_hours) echo "AIDEVOPS_TOOL_FRESHNESS_HOURS" ;; + updates.tool_idle_hours) echo "AIDEVOPS_TOOL_IDLE_HOURS" ;; + updates.openclaw_auto_update) echo "AIDEVOPS_OPENCLAW_AUTO_UPDATE" ;; + updates.openclaw_freshness_hours) echo "AIDEVOPS_OPENCLAW_FRESHNESS_HOURS" ;; + orchestration.supervisor_pulse) echo "AIDEVOPS_SUPERVISOR_PULSE" ;; + orchestration.repo_sync) echo "AIDEVOPS_REPO_SYNC" ;; + *) echo "" ;; + esac + return 0 +} + +# --------------------------------------------------------------------------- +# Get config value with env override support +# Priority: env var > user config > defaults +# Usage: config_get [default] +# --------------------------------------------------------------------------- +config_get() { + local dotpath="$1" + local default="${2:-}" + + # Check env var override first + local env_var + env_var=$(_config_env_map "$dotpath") + if [[ -n "$env_var" ]]; then + local env_val="${!env_var:-}" + if [[ -n "$env_val" ]]; then + echo "$env_val" + return 0 + fi + fi + + # Fall through to JSONC config + _jsonc_get "$dotpath" "$default" + return 0 +} + +# --------------------------------------------------------------------------- +# Check if a boolean config value is enabled (true) +# Usage: if config_enabled "updates.auto_update"; then ... +# Returns 0 (true) if value is "true" (case-insensitive), 1 otherwise. +# --------------------------------------------------------------------------- +config_enabled() { + local dotpath="$1" + local value + value=$(config_get "$dotpath" "true") + local lower + lower=$(echo "$value" | tr '[:upper:]' '[:lower:]') + [[ "$lower" == "true" ]] + return $? +} + +# --------------------------------------------------------------------------- +# Backward-compatible aliases for existing code +# These map the old flat key names to the new namespaced paths +# --------------------------------------------------------------------------- +_legacy_key_to_dotpath() { + local key="$1" + case "$key" in + auto_update) echo "updates.auto_update" ;; + update_interval) echo "updates.update_interval_minutes" ;; + skill_auto_update) echo "updates.skill_auto_update" ;; + skill_freshness_hours) echo "updates.skill_freshness_hours" ;; + tool_auto_update) echo "updates.tool_auto_update" ;; + tool_freshness_hours) echo "updates.tool_freshness_hours" ;; + tool_idle_hours) echo "updates.tool_idle_hours" ;; + openclaw_auto_update) echo "updates.openclaw_auto_update" ;; + openclaw_freshness_hours) echo "updates.openclaw_freshness_hours" ;; + manage_opencode_config) echo "integrations.manage_opencode_config" ;; + manage_claude_config) echo "integrations.manage_claude_config" ;; + supervisor_pulse) echo "orchestration.supervisor_pulse" ;; + repo_sync) echo "orchestration.repo_sync" ;; + session_greeting) echo "ui.session_greeting" ;; + safety_hooks) echo "safety.hooks_enabled" ;; + shell_aliases) echo "ui.shell_aliases" ;; + onboarding_prompt) echo "ui.onboarding_prompt" ;; + *) echo "$key" ;; # Pass through if already a dotpath + esac + return 0 +} + +# --------------------------------------------------------------------------- +# Migration: convert feature-toggles.conf to config.jsonc +# --------------------------------------------------------------------------- +_migrate_conf_to_jsonc() { + if [[ ! -f "$OLD_CONF_USER" ]]; then + return 0 + fi + + # Don't migrate if user already has a JSONC config + if [[ -f "$JSONC_USER" ]]; then + return 0 + fi + + if ! command -v jq &>/dev/null; then + echo "[config] Cannot migrate: jq is required. Install jq and retry." >&2 + return 1 + fi + + local json="{}" + local line key value dotpath + + while IFS= read -r line || [[ -n "$line" ]]; do + # Skip comments and blank lines + [[ -z "$line" || "$line" == \#* ]] && continue + # Parse key=value + key="${line%%=*}" + value="${line#*=}" + # Validate key + [[ "$key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || continue + + # Map to new dotpath + dotpath=$(_legacy_key_to_dotpath "$key") + + # Use jq --arg for safe dotpath and value passing (no shell interpolation) + case "$value" in + true | false) + json=$(echo "$json" | jq --arg p "$dotpath" --argjson v "$value" \ + 'setpath($p | split("."); $v)' 2>/dev/null) || continue + ;; + [0-9]*) + json=$(echo "$json" | jq --arg p "$dotpath" --argjson v "$value" \ + 'setpath($p | split("."); $v)' 2>/dev/null) || continue + ;; + *) + json=$(echo "$json" | jq --arg p "$dotpath" --arg v "$value" \ + 'setpath($p | split("."); $v)' 2>/dev/null) || continue + ;; + esac + done <"$OLD_CONF_USER" + + # Only write if we got some values + if [[ "$json" == "{}" ]]; then + return 0 + fi + + # Create user config directory + mkdir -p "$(dirname "$JSONC_USER")" + + # Write JSONC with header comment + { + echo "// aidevops Configuration — User Overrides" + echo "// =========================================" + echo "// Migrated from feature-toggles.conf on $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "// This file overrides defaults in:" + echo "// ~/.aidevops/agents/configs/aidevops.defaults.jsonc" + echo "//" + echo "// Run 'aidevops config list' to see all available options." + echo "// Run 'aidevops config reset' to remove all overrides." + echo "" + echo "$json" | jq '.' 2>/dev/null || echo "$json" + } >"$JSONC_USER" + + # Rename old config as backup + mv "$OLD_CONF_USER" "${OLD_CONF_USER}.migrated" 2>/dev/null || true + + echo "[config] Migrated feature-toggles.conf -> config.jsonc" >&2 + echo "[config] Old config backed up to: ${OLD_CONF_USER}.migrated" >&2 + return 0 +} + +# =========================================================================== +# CLI Commands (when run as standalone script) +# =========================================================================== + +cmd_list() { + if ! command -v jq &>/dev/null; then + echo "[ERROR] jq is required for config management. Install: sudo apt install jq" >&2 + return 1 + fi + + local defaults_json user_json + defaults_json=$(_strip_jsonc "$JSONC_DEFAULTS") || { + echo "[ERROR] Cannot read defaults config" >&2 + return 1 + } + if [[ -f "$JSONC_USER" ]]; then + user_json=$(_strip_jsonc "$JSONC_USER") || { + echo "[WARN] Malformed user config: $JSONC_USER — showing defaults only" >&2 + user_json="{}" + } + else + user_json="{}" + fi + + echo "" + echo -e "\033[1mConfiguration\033[0m" + echo "==============" + echo "" + printf " %-45s %-15s %-10s\n" "KEY" "VALUE" "SOURCE" + printf " %-45s %-15s %-10s\n" "---" "-----" "------" + + # Iterate all leaf values from defaults. + # Output "dotpathvalue" pairs using jq, handling array indices. + local entries + entries=$(echo "$defaults_json" | jq -r ' + . as $root | + [paths(scalars)] | .[] | + select(.[0] != "$schema") | + (map(tostring) | join(".")) as $dotpath | + (. as $p | $root | getpath($p) | tostring) as $val | + "\($dotpath)\t\($val)" + ' 2>/dev/null) || entries="" + + local dotpath default_val + while IFS=$'\t' read -r dotpath default_val; do + [[ -z "$dotpath" ]] && continue + + local user_val env_val effective source + + user_val=$(echo "$user_json" | jq -r --arg p "$dotpath" 'getpath($p | split(".")) // empty' 2>/dev/null) || user_val="" + + # Check env override + env_val="" + local env_var + env_var=$(_config_env_map "$dotpath") + if [[ -n "$env_var" ]]; then + env_val="${!env_var:-}" + fi + + # Determine source and effective value + if [[ -n "$env_val" ]]; then + effective="$env_val" + source="env" + elif [[ -n "$user_val" ]]; then + effective="$user_val" + source="user" + else + effective="$default_val" + source="default" + fi + + # Color the value based on source + local value_display + case "$source" in + env) value_display="\033[1;33m${effective}\033[0m" ;; + user) value_display="\033[0;32m${effective}\033[0m" ;; + default) value_display="${effective}" ;; + esac + + printf " %-45s %-15b %-10s\n" "$dotpath" "$value_display" "$source" + done <<<"$entries" + + echo "" + echo -e " \033[0;32mgreen\033[0m = user override \033[1;33myellow\033[0m = env override plain = default" + echo "" + echo " Config file: $JSONC_USER" + echo " Defaults: $JSONC_DEFAULTS" + echo " Schema: $JSONC_SCHEMA" + echo "" + echo " Set a value: aidevops config set " + echo " Reset a value: aidevops config reset " + echo " Reset all: aidevops config reset" + echo "" + return 0 +} + +cmd_get() { + local dotpath="$1" + + if [[ -z "$dotpath" ]]; then + echo "[ERROR] Usage: aidevops config get " >&2 + echo " Example: aidevops config get updates.auto_update" >&2 + return 1 + fi + + # Support legacy flat keys + dotpath=$(_legacy_key_to_dotpath "$dotpath") + + local value + value=$(config_get "$dotpath" "") + + if [[ -z "$value" ]]; then + echo "[ERROR] Unknown config key: $dotpath" >&2 + echo " Run 'aidevops config list' to see available options." >&2 + return 1 + fi + + echo "$value" + return 0 +} + +cmd_set() { + local dotpath="$1" + local value="$2" + + if [[ -z "$dotpath" || -z "$value" ]]; then + echo "[ERROR] Usage: aidevops config set " >&2 + echo " Example: aidevops config set updates.auto_update false" >&2 + return 1 + fi + + if ! command -v jq &>/dev/null; then + echo "[ERROR] jq is required. Install: sudo apt install jq" >&2 + return 1 + fi + + # Support legacy flat keys + dotpath=$(_legacy_key_to_dotpath "$dotpath") + + # Validate dotpath contains only safe characters + _validate_dotpath "$dotpath" || return 1 + + # Validate key exists in defaults + local defaults_json + defaults_json=$(_strip_jsonc "$JSONC_DEFAULTS") || return 1 + local default_val + default_val=$(echo "$defaults_json" | jq -r --arg p "$dotpath" 'getpath($p | split(".")) // empty' 2>/dev/null) || default_val="" + + if [[ -z "$default_val" ]]; then + echo "[ERROR] Unknown config key: $dotpath" >&2 + echo " Run 'aidevops config list' to see available options." >&2 + return 1 + fi + + # Validate value type from default and reject invalid input early + case "$default_val" in + true | false) + local lower_value + lower_value=$(echo "$value" | tr '[:upper:]' '[:lower:]') + if [[ "$lower_value" != "true" && "$lower_value" != "false" ]]; then + echo "[ERROR] Config '$dotpath' expects true or false, got: $value" >&2 + return 1 + fi + ;; + [0-9]*) + if ! [[ "$value" =~ ^[0-9]+$ ]]; then + echo "[ERROR] Config '$dotpath' expects a number, got: $value" >&2 + return 1 + fi + ;; + esac + + # Create user config directory if needed + mkdir -p "$(dirname "$JSONC_USER")" + + # Create user config file with header if it doesn't exist + if [[ ! -f "$JSONC_USER" ]]; then + cat >"$JSONC_USER" <<'HEADER' +// aidevops Configuration — User Overrides +// ========================================= +// This file overrides defaults in: +// ~/.aidevops/agents/configs/aidevops.defaults.jsonc +// +// Run 'aidevops config list' to see all available options. +// Run 'aidevops config reset' to remove all overrides. + +{} +HEADER + fi + + # Read existing user config, set the value, write back + local user_json + user_json=$(_strip_jsonc "$JSONC_USER") || { + echo "[ERROR] Malformed user config: $JSONC_USER — fix or reset before setting values" >&2 + return 1 + } + + # Use jq --arg for safe dotpath and value passing (no shell interpolation into filter) + local updated + case "$default_val" in + true | false) + local lower_value + lower_value=$(echo "$value" | tr '[:upper:]' '[:lower:]') + updated=$(echo "$user_json" | jq --arg p "$dotpath" --argjson v "$lower_value" \ + 'setpath($p | split("."); $v)' 2>/dev/null) || { + echo "[ERROR] Failed to update config" >&2 + return 1 + } + ;; + [0-9]*) + updated=$(echo "$user_json" | jq --arg p "$dotpath" --argjson v "$value" \ + 'setpath($p | split("."); $v)' 2>/dev/null) || { + echo "[ERROR] Failed to update config" >&2 + return 1 + } + ;; + *) + updated=$(echo "$user_json" | jq --arg p "$dotpath" --arg v "$value" \ + 'setpath($p | split("."); $v)' 2>/dev/null) || { + echo "[ERROR] Failed to update config" >&2 + return 1 + } + ;; + esac + + # Write back with JSONC header preserved + { + echo "// aidevops Configuration — User Overrides" + echo "// =========================================" + echo "// This file overrides defaults in:" + echo "// ~/.aidevops/agents/configs/aidevops.defaults.jsonc" + echo "//" + echo "// Run 'aidevops config list' to see all available options." + echo "// Run 'aidevops config reset' to remove all overrides." + echo "" + echo "$updated" | jq '.' 2>/dev/null || echo "$updated" + } >"$JSONC_USER" + + # Invalidate cache + _JSONC_MERGED_CACHE="" + _JSONC_CACHE_MTIME="" + + echo "[OK] Set ${dotpath}=${value}" >&2 + + # Show if an env var would override this + local env_val + local env_var + env_var=$(_config_env_map "$dotpath") + if [[ -n "$env_var" ]]; then + env_val="${!env_var:-}" + if [[ -n "$env_val" ]]; then + echo "[WARN] Environment variable ${env_var}=${env_val} will override this setting" >&2 + fi + fi + + echo " Change takes effect on next setup.sh run or script invocation." >&2 + return 0 +} + +cmd_reset() { + local dotpath="${1:-}" + + if [[ -n "$dotpath" ]]; then + # Support legacy flat keys + dotpath=$(_legacy_key_to_dotpath "$dotpath") + + # Validate dotpath contains only safe characters + _validate_dotpath "$dotpath" || return 1 + + # Reset a single key by removing it from user config + if [[ ! -f "$JSONC_USER" ]]; then + echo "[INFO] No user config file exists — already using defaults" >&2 + return 0 + fi + + if ! command -v jq &>/dev/null; then + echo "[ERROR] jq is required. Install: sudo apt install jq" >&2 + return 1 + fi + + local user_json + user_json=$(_strip_jsonc "$JSONC_USER") || { + echo "[ERROR] Malformed user config: $JSONC_USER — consider 'aidevops config reset' to remove it" >&2 + return 1 + } + + # Use jq --arg for safe dotpath passing (no shell interpolation into filter) + local updated + updated=$(echo "$user_json" | jq --arg p "$dotpath" 'delpaths([$p | split(".")])' 2>/dev/null) || { + echo "[ERROR] Failed to reset config key" >&2 + return 1 + } + + # Write back + { + echo "// aidevops Configuration — User Overrides" + echo "// =========================================" + echo "// This file overrides defaults in:" + echo "// ~/.aidevops/agents/configs/aidevops.defaults.jsonc" + echo "//" + echo "// Run 'aidevops config list' to see all available options." + echo "// Run 'aidevops config reset' to remove all overrides." + echo "" + echo "$updated" | jq '.' 2>/dev/null || echo "$updated" + } >"$JSONC_USER" + + # Invalidate cache + _JSONC_MERGED_CACHE="" + _JSONC_CACHE_MTIME="" + + local default_val + default_val=$(_jsonc_get "$dotpath" "") + echo "[OK] Reset ${dotpath} to default (${default_val})" >&2 + else + # Reset all — remove user config file + if [[ -f "$JSONC_USER" ]]; then + rm -f "$JSONC_USER" + _JSONC_MERGED_CACHE="" + _JSONC_CACHE_MTIME="" + echo "[OK] Removed all user overrides — using defaults" >&2 + else + echo "[INFO] No user config file exists — already using defaults" >&2 + fi + fi + return 0 +} + +cmd_path() { + echo "User config: $JSONC_USER" + echo "Defaults: $JSONC_DEFAULTS" + echo "Schema: $JSONC_SCHEMA" + if [[ -f "$JSONC_USER" ]]; then + echo "User config exists: yes" + else + echo "User config exists: no (using defaults only)" + fi + if [[ -f "$OLD_CONF_USER" ]]; then + echo "Legacy config: $OLD_CONF_USER (run 'aidevops config migrate' to convert)" + fi + return 0 +} + +cmd_migrate() { + if [[ ! -f "$OLD_CONF_USER" ]]; then + echo "[INFO] No legacy feature-toggles.conf found — nothing to migrate" >&2 + return 0 + fi + + if [[ -f "$JSONC_USER" ]]; then + echo "[WARN] User config.jsonc already exists. Migration would overwrite it." >&2 + echo " To force migration, remove $JSONC_USER first." >&2 + return 1 + fi + + _migrate_conf_to_jsonc + return $? +} + +cmd_validate() { + if ! command -v jq &>/dev/null; then + echo "[ERROR] jq is required for validation" >&2 + return 1 + fi + + local exit_code=0 + + # Validate defaults file + if [[ -f "$JSONC_DEFAULTS" ]]; then + local defaults_json + if ! defaults_json=$(_strip_jsonc "$JSONC_DEFAULTS"); then + echo "[ERROR] Defaults file has invalid JSONC: $JSONC_DEFAULTS" >&2 + exit_code=1 + elif echo "$defaults_json" | jq -e '.' >/dev/null 2>&1; then + echo "[OK] Defaults file is valid JSON" >&2 + else + echo "[ERROR] Defaults file has invalid JSON" >&2 + exit_code=1 + fi + else + echo "[WARN] Defaults file not found: $JSONC_DEFAULTS" >&2 + fi + + # Validate user config + if [[ -f "$JSONC_USER" ]]; then + local user_json + if ! user_json=$(_strip_jsonc "$JSONC_USER"); then + echo "[ERROR] User config has invalid JSONC: $JSONC_USER" >&2 + exit_code=1 + elif echo "$user_json" | jq -e '.' >/dev/null 2>&1; then + echo "[OK] User config is valid JSON" >&2 + else + echo "[ERROR] User config has invalid JSON: $JSONC_USER" >&2 + exit_code=1 + fi + else + echo "[INFO] No user config file (using defaults only)" >&2 + fi + + # JSON Schema validation (if schema file exists and a validator is available) + if [[ -f "$JSONC_SCHEMA" && $exit_code -eq 0 ]]; then + local merged_json + merged_json=$(_get_merged_config) + + if command -v ajv &>/dev/null; then + # ajv-cli: fast, Node-based JSON Schema validator + local tmpfile + tmpfile=$(mktemp) || { + echo "[WARN] Cannot create temp file for schema validation" >&2 + return $exit_code + } + echo "$merged_json" >"$tmpfile" + if ajv validate -s "$JSONC_SCHEMA" -d "$tmpfile" --strict=false >&2; then + echo "[OK] Config passes JSON Schema validation" >&2 + else + echo "[ERROR] Config fails JSON Schema validation (see above)" >&2 + exit_code=1 + fi + rm -f "$tmpfile" + elif command -v python3 &>/dev/null && python3 -c "import jsonschema" 2>/dev/null; then + # Python jsonschema module — pass schema path as argv[1] to avoid injection + if echo "$merged_json" | python3 -c ' +import sys, json +try: + from jsonschema import validate, ValidationError + schema = json.load(open(sys.argv[1])) + instance = json.load(sys.stdin) + validate(instance=instance, schema=schema) +except ValidationError as e: + print(f"Validation error: {e.message}", file=sys.stderr) + sys.exit(1) +except Exception as e: + print(f"Schema validation unavailable: {e}", file=sys.stderr) + sys.exit(2) +' "$JSONC_SCHEMA"; then + echo "[OK] Config passes JSON Schema validation" >&2 + else + echo "[ERROR] Config fails JSON Schema validation (see above)" >&2 + exit_code=1 + fi + else + echo "[INFO] No JSON Schema validator found (install ajv-cli or python3-jsonschema for schema checks)" >&2 + fi + fi + + return $exit_code +} + +cmd_help() { + cat <<'EOF' +config-helper.sh - Manage aidevops configuration (JSONC) + +USAGE: + aidevops config [args] + +COMMANDS: + list List all config with current values and sources + get Get the effective value of a config key + set Set a config value (persists in user config) + reset [dotpath] Reset one key or all config to defaults + path Show config file paths + migrate Migrate from legacy feature-toggles.conf + validate Validate config files + help Show this help + +EXAMPLES: + aidevops config list + aidevops config set updates.auto_update false + aidevops config set integrations.manage_opencode_config false + aidevops config get orchestration.supervisor_pulse + aidevops config reset updates.auto_update + aidevops config reset # reset all to defaults + +DOTPATH FORMAT: + Config keys use dot-notation for namespacing: + updates.auto_update + integrations.manage_opencode_config + orchestration.supervisor_pulse + safety.hooks_enabled + ui.session_greeting + models.tiers.haiku.models + quality.ci_timing.fast_wait + + Legacy flat keys (e.g. "auto_update") are also accepted for + backward compatibility and automatically mapped to their dotpath. + +PRIORITY ORDER: + 1. Environment variables (e.g. AIDEVOPS_AUTO_UPDATE=false) + 2. User config (~/.config/aidevops/config.jsonc) + 3. Defaults (~/.aidevops/agents/configs/aidevops.defaults.jsonc) + +FILES: + ~/.config/aidevops/config.jsonc User overrides (JSONC) + ~/.aidevops/agents/configs/aidevops.defaults.jsonc Shipped defaults (JSONC) + ~/.aidevops/agents/configs/aidevops-config.schema.json JSON Schema + +EOF + return 0 +} + +# =========================================================================== +# Main entry point (CLI mode) +# =========================================================================== +main() { + # Auto-migrate on first use if legacy config exists and no JSONC config yet + if [[ -f "$OLD_CONF_USER" && ! -f "$JSONC_USER" ]]; then + local migrate_stderr migrate_rc + migrate_stderr=$(_migrate_conf_to_jsonc 2>&1 >/dev/null) && migrate_rc=0 || migrate_rc=$? + if [[ "$migrate_rc" -ne 0 ]]; then + echo "[WARN] Auto-migration from legacy config failed (exit ${migrate_rc}). Run 'aidevops config migrate' manually." >&2 + if [[ -n "$migrate_stderr" ]]; then + echo "[WARN] Migration error: ${migrate_stderr}" >&2 + fi + touch "$MIGRATE_FAILED_FLAG" + else + rm -f "$MIGRATE_FAILED_FLAG" + fi + fi + + local command="${1:-help}" + shift || true + + case "$command" in + list | ls) cmd_list ;; + get) cmd_get "${1:-}" ;; + set) cmd_set "${1:-}" "${2:-}" ;; + reset) cmd_reset "${1:-}" ;; + path | paths) cmd_path ;; + migrate) cmd_migrate ;; + validate) cmd_validate ;; + help | --help | -h) cmd_help ;; + *) + echo "[ERROR] Unknown command: $command" >&2 + cmd_help + return 1 + ;; + esac + return 0 +} + +# Only run main if executed directly (not sourced) +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/.agents/scripts/feature-toggle-helper.sh b/.agents/scripts/feature-toggle-helper.sh index bbd4f55518..1bdddb7d11 100755 --- a/.agents/scripts/feature-toggle-helper.sh +++ b/.agents/scripts/feature-toggle-helper.sh @@ -1,381 +1,34 @@ #!/usr/bin/env bash -# feature-toggle-helper.sh - Manage aidevops feature toggles +# feature-toggle-helper.sh - Backward-compatible wrapper for aidevops config # -# Provides get/set/list/reset operations on ~/.config/aidevops/feature-toggles.conf. -# Called by `aidevops config ` CLI. +# DEPRECATED: This script delegates to config-helper.sh (JSONC config system). +# Kept for backward compatibility with existing `aidevops config` invocations. +# Legacy flat keys (e.g. "auto_update") are automatically mapped to namespaced +# dotpaths (e.g. "updates.auto_update"). +# +# New code should use config-helper.sh directly. # # Usage: -# feature-toggle-helper.sh list List all toggles with current values -# feature-toggle-helper.sh get Get a single toggle value -# feature-toggle-helper.sh set Set a toggle (creates user config if needed) -# feature-toggle-helper.sh reset [key] Reset one or all toggles to defaults +# feature-toggle-helper.sh list List all config with current values +# feature-toggle-helper.sh get Get a config value +# feature-toggle-helper.sh set Set a config value +# feature-toggle-helper.sh reset [key] Reset one or all config to defaults # feature-toggle-helper.sh path Show config file paths +# feature-toggle-helper.sh migrate Migrate from legacy .conf to JSONC # feature-toggle-helper.sh help Show this help -# -# The user config file is: ~/.config/aidevops/feature-toggles.conf -# Defaults are in: ~/.aidevops/agents/configs/feature-toggles.conf.defaults set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" || exit -source "${SCRIPT_DIR}/shared-constants.sh" - -readonly USER_CONFIG="${HOME}/.config/aidevops/feature-toggles.conf" -readonly DEFAULTS_CONFIG="${HOME}/.aidevops/agents/configs/feature-toggles.conf.defaults" - -# Get all known toggle keys from the defaults file -_get_all_keys() { - if [[ ! -r "$DEFAULTS_CONFIG" ]]; then - echo "" - return 0 - fi - grep -E '^[a-zA-Z_][a-zA-Z0-9_]*=' "$DEFAULTS_CONFIG" | cut -d= -f1 | sort - return 0 -} - -# Get the default value for a key -_get_default() { - local key="$1" - if [[ ! -r "$DEFAULTS_CONFIG" ]]; then - echo "" - return 0 - fi - grep -E "^${key}=" "$DEFAULTS_CONFIG" | head -1 | cut -d= -f2 - return 0 -} - -# Get the user override value for a key (empty if not overridden) -_get_user_override() { - local key="$1" - if [[ ! -r "$USER_CONFIG" ]]; then - echo "" - return 0 - fi - grep -E "^${key}=" "$USER_CONFIG" | tail -1 | cut -d= -f2 - return 0 -} - -# Get the env var override for a key (empty if not set) -_get_env_override() { - local key="$1" - local env_var - env_var=$(_ft_env_map "$key") - if [[ -n "$env_var" ]]; then - echo "${!env_var:-}" - else - echo "" - fi - return 0 -} - -# Get the effective value for a key (env > user > default) -_get_effective() { - local key="$1" - local env_val user_val default_val - - env_val=$(_get_env_override "$key") - if [[ -n "$env_val" ]]; then - echo "$env_val" - return 0 - fi - - user_val=$(_get_user_override "$key") - if [[ -n "$user_val" ]]; then - echo "$user_val" - return 0 - fi - - default_val=$(_get_default "$key") - echo "$default_val" - return 0 -} - -# Get the description comment for a key from the defaults file -_get_description() { - local key="$1" - if [[ ! -r "$DEFAULTS_CONFIG" ]]; then - echo "" - return 0 - fi - # Find the key line, then look backwards for the comment block - local line_num - line_num=$(grep -n "^${key}=" "$DEFAULTS_CONFIG" | head -1 | cut -d: -f1) - if [[ -z "$line_num" ]]; then - echo "" - return 0 - fi - # Get the comment line immediately before the key - local prev_line=$((line_num - 1)) - if [[ $prev_line -gt 0 ]]; then - local comment - comment=$(sed -n "${prev_line}p" "$DEFAULTS_CONFIG") - if [[ "$comment" == \#* ]]; then - # Strip leading "# " and "Env override: ..." suffix - echo "$comment" | sed 's/^# *//' | sed 's/ *Env override:.*//' - fi - fi - return 0 -} - -cmd_list() { - local keys - keys=$(_get_all_keys) - - if [[ -z "$keys" ]]; then - print_error "No feature toggles found. Run 'aidevops update' to install defaults." - return 1 - fi - - echo "" - echo -e "${BOLD:-\033[1m}Feature Toggles${NC}" - echo "================" - echo "" - printf " %-28s %-10s %-10s %s\n" "KEY" "VALUE" "SOURCE" "DESCRIPTION" - printf " %-28s %-10s %-10s %s\n" "---" "-----" "------" "-----------" - - local key - for key in $keys; do - local effective default_val user_val env_val source desc - - default_val=$(_get_default "$key") - user_val=$(_get_user_override "$key") - env_val=$(_get_env_override "$key") - desc=$(_get_description "$key") - - # Determine source and effective value - if [[ -n "$env_val" ]]; then - effective="$env_val" - source="env" - elif [[ -n "$user_val" ]]; then - effective="$user_val" - source="user" - else - effective="$default_val" - source="default" - fi - - # Color the value based on source - local value_display - case "$source" in - env) value_display="${YELLOW}${effective}${NC}" ;; - user) value_display="${GREEN}${effective}${NC}" ;; - default) value_display="${effective}" ;; - esac - - printf " %-28s %-10b %-10s %s\n" "$key" "$value_display" "$source" "$desc" - done - - echo "" - echo -e " ${GREEN}green${NC} = user override ${YELLOW}yellow${NC} = env override plain = default" - echo "" - echo " Config file: $USER_CONFIG" - echo " Defaults: $DEFAULTS_CONFIG" - echo "" - echo " Set a toggle: aidevops config set " - echo " Reset a toggle: aidevops config reset " - echo " Reset all: aidevops config reset" - echo "" - return 0 -} - -cmd_get() { - local key="$1" - - if [[ -z "$key" ]]; then - print_error "Usage: aidevops config get " - return 1 - fi - - # Validate key exists in defaults - local default_val - default_val=$(_get_default "$key") - if [[ -z "$default_val" ]] && ! grep -qE "^${key}=" "$DEFAULTS_CONFIG" 2>/dev/null; then - print_error "Unknown toggle: $key" - echo " Run 'aidevops config list' to see available toggles." - return 1 - fi - - local effective - effective=$(_get_effective "$key") - echo "$effective" - return 0 -} - -cmd_set() { - local key="$1" - local value="$2" - - if [[ -z "$key" || -z "$value" ]]; then - print_error "Usage: aidevops config set " - return 1 - fi - - # Validate key exists in defaults - if ! grep -qE "^${key}=" "$DEFAULTS_CONFIG" 2>/dev/null; then - print_error "Unknown toggle: $key" - echo " Run 'aidevops config list' to see available toggles." - return 1 - fi - - # Validate boolean values for boolean toggles - local default_val - default_val=$(_get_default "$key") - if [[ "$default_val" == "true" || "$default_val" == "false" ]]; then - local lower_value - lower_value=$(echo "$value" | tr '[:upper:]' '[:lower:]') - if [[ "$lower_value" != "true" && "$lower_value" != "false" ]]; then - print_error "Toggle '$key' expects true or false, got: $value" - return 1 - fi - value="$lower_value" - fi - - # Create user config directory if needed - mkdir -p "$(dirname "$USER_CONFIG")" - - # Create user config file with header if it doesn't exist - if [[ ! -f "$USER_CONFIG" ]]; then - cat >"$USER_CONFIG" <<'HEADER' -# aidevops Feature Toggles — User Overrides -# =========================================== -# This file overrides the defaults in: -# ~/.aidevops/agents/configs/feature-toggles.conf.defaults -# -# Format: key=value (one per line). Lines starting with # are comments. -# Run 'aidevops config list' to see all available toggles. -# Run 'aidevops config reset' to remove all overrides. - -HEADER - fi - - # Update or add the key - if grep -qE "^${key}=" "$USER_CONFIG" 2>/dev/null; then - # Update existing entry - if [[ "$(uname)" == "Darwin" ]]; then - sed -i '' "s|^${key}=.*|${key}=${value}|" "$USER_CONFIG" - else - sed -i "s|^${key}=.*|${key}=${value}|" "$USER_CONFIG" - fi - else - # Add new entry - echo "${key}=${value}" >>"$USER_CONFIG" - fi - - print_success "Set ${key}=${value}" - - # Show if an env var would override this - local env_val - env_val=$(_get_env_override "$key") - if [[ -n "$env_val" ]]; then - local env_var - env_var=$(_ft_env_map "$key") - print_warning "Note: environment variable ${env_var}=${env_val} will override this setting" - fi - - # Hint about when the change takes effect - echo " Change takes effect on next setup.sh run or script invocation." - return 0 -} - -cmd_reset() { - local key="${1:-}" - - if [[ -n "$key" ]]; then - # Reset a single key - if [[ ! -f "$USER_CONFIG" ]]; then - print_info "No user overrides file exists — already using defaults" - return 0 - fi - - if grep -qE "^${key}=" "$USER_CONFIG" 2>/dev/null; then - if [[ "$(uname)" == "Darwin" ]]; then - sed -i '' "/^${key}=/d" "$USER_CONFIG" - else - sed -i "/^${key}=/d" "$USER_CONFIG" - fi - local default_val - default_val=$(_get_default "$key") - print_success "Reset ${key} to default (${default_val})" - else - print_info "${key} is already using the default value" - fi - else - # Reset all — remove user config file - if [[ -f "$USER_CONFIG" ]]; then - rm -f "$USER_CONFIG" - print_success "Removed all user overrides — using defaults" - else - print_info "No user overrides file exists — already using defaults" - fi - fi - return 0 -} - -cmd_path() { - echo "User config: $USER_CONFIG" - echo "Defaults: $DEFAULTS_CONFIG" - if [[ -f "$USER_CONFIG" ]]; then - echo "User config exists: yes" - else - echo "User config exists: no (using defaults only)" - fi - return 0 -} - -cmd_help() { - cat <<'EOF' -feature-toggle-helper.sh - Manage aidevops feature toggles - -USAGE: - aidevops config [args] - -COMMANDS: - list List all toggles with current values and sources - get Get the effective value of a toggle - set Set a toggle (persists in user config file) - reset [key] Reset one toggle or all toggles to defaults - path Show config file paths - help Show this help - -EXAMPLES: - aidevops config list - aidevops config set auto_update false - aidevops config set manage_opencode_config false - aidevops config get supervisor_pulse - aidevops config reset auto_update - aidevops config reset # reset all to defaults - -PRIORITY ORDER: - 1. Environment variables (e.g. AIDEVOPS_AUTO_UPDATE=false) - 2. User config (~/.config/aidevops/feature-toggles.conf) - 3. Defaults (~/.aidevops/agents/configs/feature-toggles.conf.defaults) - -FILES: - ~/.config/aidevops/feature-toggles.conf User overrides - ~/.aidevops/agents/configs/feature-toggles.conf.defaults Shipped defaults - -EOF - return 0 -} - -main() { - local command="${1:-help}" - shift || true - - case "$command" in - list | ls) cmd_list ;; - get) cmd_get "${1:-}" ;; - set) cmd_set "${1:-}" "${2:-}" ;; - reset) cmd_reset "${1:-}" ;; - path | paths) cmd_path ;; - help | --help | -h) cmd_help ;; - *) - print_error "Unknown command: $command" - cmd_help - return 1 - ;; - esac - return 0 -} -main "$@" +# Delegate to config-helper.sh (the canonical implementation) +CONFIG_HELPER="${SCRIPT_DIR}/config-helper.sh" +if [[ -x "$CONFIG_HELPER" ]]; then + exec bash "$CONFIG_HELPER" "$@" +fi + +# config-helper.sh is missing — fail deterministically +echo "[ERROR] config-helper.sh not found at: ${CONFIG_HELPER}" >&2 +echo " This script requires config-helper.sh to function." >&2 +echo " Run 'aidevops update' to restore the JSONC config system." >&2 +exit 1 diff --git a/.agents/scripts/shared-constants.sh b/.agents/scripts/shared-constants.sh index cb235b3bf6..0bf333a3f2 100755 --- a/.agents/scripts/shared-constants.sh +++ b/.agents/scripts/shared-constants.sh @@ -1284,23 +1284,35 @@ get_provider_from_model() { } # ============================================================================= -# Feature Toggles Loader (issue #2721) +# Configuration Loader (issue #2730 — JSONC config system) # ============================================================================= -# Loads user-configurable feature toggles from: +# Loads user-configurable settings from JSONC config files: # 1. Defaults file (shipped with aidevops, overwritten on update) -# 2. User overrides (~/.config/aidevops/feature-toggles.conf) +# ~/.aidevops/agents/configs/aidevops.defaults.jsonc +# 2. User overrides (~/.config/aidevops/config.jsonc) # 3. Environment variables (highest priority) # -# All toggle values are exported as AIDEVOPS_FT_ (uppercase). -# Scripts check toggles via: get_feature_toggle [default] +# Requires jq for JSONC parsing. Falls back to legacy .conf if jq unavailable. # -# The loader is idempotent — safe to call multiple times. - +# Scripts check config via: +# config_get [default] — get any config value +# config_enabled — check boolean config +# get_feature_toggle [default] — backward-compatible (flat key) +# is_feature_enabled — backward-compatible (flat key) + +# Source config-helper.sh (provides _jsonc_get, config_get, config_enabled, etc.) +_CONFIG_HELPER="${BASH_SOURCE[0]%/*}/config-helper.sh" +if [[ -r "$_CONFIG_HELPER" ]]; then + # shellcheck source=config-helper.sh + source "$_CONFIG_HELPER" +fi + +# Legacy paths (kept for backward compatibility and migration) FEATURE_TOGGLES_DEFAULTS="${HOME}/.aidevops/agents/configs/feature-toggles.conf.defaults" FEATURE_TOGGLES_USER="${HOME}/.config/aidevops/feature-toggles.conf" -# Map from toggle key to environment variable name (for env override lookup). -# Only toggles with existing env var conventions are mapped here. +# Map from legacy toggle key to environment variable name. +# Used by both the new JSONC system and the legacy fallback. _ft_env_map() { local key="$1" case "$key" in @@ -1320,28 +1332,21 @@ _ft_env_map() { return 0 } -# Load feature toggles (called once when shared-constants.sh is sourced). -# Reads defaults, then user overrides, then env vars. -# Results are stored in _FT_* variables (not exported — use get_feature_toggle). -_load_feature_toggles() { - # Source defaults file (sets variables in current scope) +# --------------------------------------------------------------------------- +# Legacy fallback: load from .conf files when jq is not available +# --------------------------------------------------------------------------- +_load_feature_toggles_legacy() { if [[ -r "$FEATURE_TOGGLES_DEFAULTS" ]]; then - # Read key=value pairs, skipping comments and blank lines local line key value while IFS= read -r line || [[ -n "$line" ]]; do - # Skip comments and blank lines [[ -z "$line" || "$line" == \#* ]] && continue - # Parse key=value key="${line%%=*}" value="${line#*=}" - # Validate key is alphanumeric + underscore [[ "$key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || continue - # Store as _FT_ - eval "_FT_${key}=\"\${value}\"" + printf -v "_FT_${key}" '%s' "$value" done <"$FEATURE_TOGGLES_DEFAULTS" fi - # Source user overrides (same format, overwrites defaults) if [[ -r "$FEATURE_TOGGLES_USER" ]]; then local line key value while IFS= read -r line || [[ -n "$line" ]]; do @@ -1349,12 +1354,10 @@ _load_feature_toggles() { key="${line%%=*}" value="${line#*=}" [[ "$key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || continue - eval "_FT_${key}=\"\${value}\"" + printf -v "_FT_${key}" '%s' "$value" done <"$FEATURE_TOGGLES_USER" fi - # Environment variable overrides (highest priority) - # Only for toggles that have a known env var mapping local toggle_keys="auto_update update_interval skill_auto_update skill_freshness_hours tool_auto_update tool_freshness_hours tool_idle_hours supervisor_pulse repo_sync openclaw_auto_update openclaw_freshness_hours manage_opencode_config manage_claude_config session_greeting safety_hooks shell_aliases onboarding_prompt" local tk env_var env_val for tk in $toggle_keys; do @@ -1362,7 +1365,7 @@ _load_feature_toggles() { if [[ -n "$env_var" ]]; then env_val="${!env_var:-}" if [[ -n "$env_val" ]]; then - eval "_FT_${tk}=\"\${env_val}\"" + printf -v "_FT_${tk}" '%s' "$env_val" fi fi done @@ -1370,42 +1373,98 @@ _load_feature_toggles() { return 0 } -# Get a feature toggle value. +# --------------------------------------------------------------------------- +# Detect which config system to use and load accordingly +# --------------------------------------------------------------------------- +_AIDEVOPS_CONFIG_MODE="" + +_load_config() { + # Prefer JSONC if jq is available, defaults file exists, AND config-helper.sh + # functions (config_get/config_enabled) are loaded. Without the functions, + # having jq + defaults is not enough — callers would fail at runtime. + local jsonc_defaults="${JSONC_DEFAULTS:-${HOME}/.aidevops/agents/configs/aidevops.defaults.jsonc}" + if command -v jq &>/dev/null && [[ -r "$jsonc_defaults" ]] && + type config_get &>/dev/null && type config_enabled &>/dev/null; then + _AIDEVOPS_CONFIG_MODE="jsonc" + # config-helper.sh functions are already available via source above + # Auto-migrate legacy .conf if it exists and no JSONC user config yet + local jsonc_user="${JSONC_USER:-${HOME}/.config/aidevops/config.jsonc}" + if [[ -f "$FEATURE_TOGGLES_USER" && ! -f "$jsonc_user" ]]; then + if type _migrate_conf_to_jsonc &>/dev/null; then + if ! _migrate_conf_to_jsonc; then + echo "[WARN] Auto-migration from legacy config failed. Run 'aidevops config migrate' manually." >&2 + fi + fi + fi + else + _AIDEVOPS_CONFIG_MODE="legacy" + _load_feature_toggles_legacy + fi + + return 0 +} + +# --------------------------------------------------------------------------- +# Backward-compatible API: get_feature_toggle / is_feature_enabled +# These accept flat legacy keys (e.g. "auto_update") and route to the +# appropriate backend (JSONC or legacy .conf). +# --------------------------------------------------------------------------- + +# Get a feature toggle / config value. # Usage: get_feature_toggle [default] -# Returns the value on stdout. If the toggle is not set, returns the default -# (or empty string if no default provided). -# Example: if [[ "$(get_feature_toggle auto_update true)" == "false" ]]; then ... +# Accepts both legacy flat keys and new dotpath keys. get_feature_toggle() { local key="$1" local default="${2:-}" - local var_name="_FT_${key}" - local value="${!var_name:-}" - if [[ -n "$value" ]]; then - echo "$value" + if [[ "$_AIDEVOPS_CONFIG_MODE" == "jsonc" ]]; then + # Map legacy key to dotpath if needed + local dotpath + if type _legacy_key_to_dotpath &>/dev/null; then + dotpath=$(_legacy_key_to_dotpath "$key") + else + dotpath="$key" + fi + config_get "$dotpath" "$default" else - echo "$default" + # Legacy mode: read from _FT_* variables + local var_name="_FT_${key}" + local value="${!var_name:-}" + if [[ -n "$value" ]]; then + echo "$value" + else + echo "$default" + fi fi return 0 } -# Check if a feature toggle is enabled (true). +# Check if a feature toggle / config boolean is enabled (true). # Usage: if is_feature_enabled auto_update; then ... -# Returns 0 (true) if the toggle value is "true" (case-insensitive). -# Returns 1 (false) for "false", empty, or any other value. is_feature_enabled() { local key="$1" - local value - value="$(get_feature_toggle "$key" "true")" - # Lowercase comparison - local lower - lower=$(echo "$value" | tr '[:upper:]' '[:lower:]') - [[ "$lower" == "true" ]] - return $? + + if [[ "$_AIDEVOPS_CONFIG_MODE" == "jsonc" ]]; then + local dotpath + if type _legacy_key_to_dotpath &>/dev/null; then + dotpath=$(_legacy_key_to_dotpath "$key") + else + dotpath="$key" + fi + config_enabled "$dotpath" + return $? + else + local value + value="$(get_feature_toggle "$key" "true")" + local lower + lower=$(echo "$value" | tr '[:upper:]' '[:lower:]') + [[ "$lower" == "true" ]] + return $? + fi } -# Load toggles immediately when shared-constants.sh is sourced -_load_feature_toggles +# Load config immediately when shared-constants.sh is sourced +_load_config # This ensures all constants are available when this file is sourced export CONTENT_TYPE_JSON CONTENT_TYPE_FORM USER_AGENT diff --git a/aidevops.sh b/aidevops.sh index 73c98697a2..c4c37ebbe1 100755 --- a/aidevops.sh +++ b/aidevops.sh @@ -3525,14 +3525,22 @@ main() { ;; config | configure) shift - local ft_helper="$AGENTS_DIR/scripts/feature-toggle-helper.sh" - if [[ ! -f "$ft_helper" ]]; then - ft_helper="$INSTALL_DIR/.agents/scripts/feature-toggle-helper.sh" + # Prefer JSONC config-helper.sh, fall back to legacy feature-toggle-helper.sh + local config_helper="$AGENTS_DIR/scripts/config-helper.sh" + if [[ ! -f "$config_helper" ]]; then + config_helper="$INSTALL_DIR/.agents/scripts/config-helper.sh" fi - if [[ -f "$ft_helper" ]]; then - bash "$ft_helper" "$@" + if [[ ! -f "$config_helper" ]]; then + # Legacy fallback + config_helper="$AGENTS_DIR/scripts/feature-toggle-helper.sh" + fi + if [[ ! -f "$config_helper" ]]; then + config_helper="$INSTALL_DIR/.agents/scripts/feature-toggle-helper.sh" + fi + if [[ -f "$config_helper" ]]; then + bash "$config_helper" "$@" else - print_error "feature-toggle-helper.sh not found. Run: aidevops update" + print_error "config-helper.sh not found. Run: aidevops update" exit 1 fi ;; diff --git a/setup-modules/config.sh b/setup-modules/config.sh index a4e5828bbd..c59d901fff 100644 --- a/setup-modules/config.sh +++ b/setup-modules/config.sh @@ -100,9 +100,9 @@ _run_generator() { } update_opencode_config() { - # Respect feature toggle (env var or config file) + # Respect config (env var or config file) if ! is_feature_enabled manage_opencode_config 2>/dev/null; then - print_info "OpenCode config management disabled via feature toggle" + print_info "OpenCode config management disabled via config (integrations.manage_opencode_config)" return 0 fi @@ -145,9 +145,9 @@ update_opencode_config() { } update_claude_config() { - # Respect feature toggle (env var or config file) + # Respect config (env var or config file) if ! is_feature_enabled manage_claude_config 2>/dev/null; then - print_info "Claude config management disabled via feature toggle" + print_info "Claude config management disabled via config (integrations.manage_claude_config)" return 0 fi diff --git a/setup.sh b/setup.sh index 6f65afa148..aaae109a49 100755 --- a/setup.sh +++ b/setup.sh @@ -73,7 +73,7 @@ print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; } print_error() { echo -e "${RED}[ERROR]${NC} $1"; } -# Source shared-constants for feature toggle support (is_feature_enabled) +# Source shared-constants for config support (is_feature_enabled / config_enabled) # Try repo-local first, then deployed location _SHARED_CONSTANTS="${BASH_SOURCE[0]%/*}/.agents/scripts/shared-constants.sh" if [[ ! -f "$_SHARED_CONSTANTS" ]]; then @@ -606,12 +606,12 @@ main() { if is_feature_enabled manage_opencode_config 2>/dev/null; then update_opencode_config else - print_info "OpenCode config management disabled via feature toggle" + print_info "OpenCode config management disabled via config (integrations.manage_opencode_config)" fi if is_feature_enabled manage_claude_config 2>/dev/null; then update_claude_config else - print_info "Claude config management disabled via feature toggle" + print_info "Claude config management disabled via config (integrations.manage_claude_config)" fi disable_ondemand_mcps else @@ -696,7 +696,7 @@ main() { # Enable auto-update if not already enabled # Check both launchd (macOS) and cron (Linux) for existing installation - # Respects feature toggle: aidevops config set auto_update false + # Respects config: aidevops config set updates.auto_update false local auto_update_script="$HOME/.aidevops/agents/scripts/auto-update-helper.sh" if [[ -x "$auto_update_script" ]] && is_feature_enabled auto_update 2>/dev/null; then local _auto_update_installed=false @@ -746,7 +746,7 @@ main() { # macOS: launchd plist invoking wrapper | Linux: cron entry invoking wrapper # The plist is ALWAYS regenerated on setup.sh to pick up config changes (env vars, # thresholds). Only the first-install prompt is gated on _pulse_installed. - # Respects feature toggle: aidevops config set supervisor_pulse false + # Respects config: aidevops config set orchestration.supervisor_pulse false if is_feature_enabled supervisor_pulse 2>/dev/null; then local wrapper_script="$HOME/.aidevops/agents/scripts/pulse-wrapper.sh" local pulse_label="com.aidevops.aidevops-supervisor-pulse" @@ -878,7 +878,7 @@ PLIST # Enable repo-sync scheduler if not already installed # Keeps local git repos up to date with daily ff-only pulls - # Respects feature toggle: aidevops config set repo_sync false + # Respects config: aidevops config set orchestration.repo_sync false local repo_sync_script="$HOME/.aidevops/agents/scripts/repo-sync-helper.sh" if [[ -x "$repo_sync_script" ]] && is_feature_enabled repo_sync 2>/dev/null; then local _repo_sync_installed=false @@ -994,7 +994,7 @@ PLIST fi # Offer to launch onboarding for new users (only if not running inside OpenCode and not non-interactive) - # Respects feature toggle: aidevops config set onboarding_prompt false + # Respects config: aidevops config set ui.onboarding_prompt false if [[ "$NON_INTERACTIVE" != "true" ]] && [[ -z "${OPENCODE_SESSION:-}" ]] && is_feature_enabled onboarding_prompt 2>/dev/null && command -v opencode &>/dev/null; then echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo ""