CliConfig.fromYaml constructor

CliConfig.fromYaml(
  1. YamlMap map
)

Implementation

factory CliConfig.fromYaml(YamlMap map) {
  final skillsSection = _parseSkillsSection(map['skills']);
  // The power section (sleep-prevention level + hold lifecycle) is
  // parsed once, strictly (issues #325/#326).
  final powerSection = parsePowerSection(map['power']);
  // The quota section (issue #823) is parsed once, strictly too.
  final quotaSection = parseQuotaSection(map['quota']);
  final agentSection = _parseAgentSection(map['agent']);
  // The tui section (issue #805) is strict: theme, classic kill
  // switch and statusLine parsed once, schema errors throw.
  final tui = parseTuiSection(map['tui']);
  return CliConfig(
    // Issue #772: the persisted provider identity is the catalog KIND.
    // Old name-shaped values (`chatgpt`, `chatgpt.com`) canonicalize on
    // load — the file itself is rewritten only on the next save.
    providerKind:
        _canonicalSavedProvider(map['provider']) ?? 'openai-completions',
    modelId: map['model'] as String? ?? 'openai/gpt-4o-mini',
    baseUrl: map['baseUrl'] as String? ?? 'https://openrouter.ai/api/v1',
    mode: map['mode'] as String? ?? 'code',
    approvalMode: map['approvalMode'] as String? ?? 'yolo',
    allowedTools: switch (map['allowedTools']) {
      final YamlList list => [for (final entry in list) '$entry'],
      _ => const [],
    },
    // The prompts section is parsed strictly: unknown prompt names throw
    // [ConfigException] instead of silently doing nothing.
    promptOverrides: parsePromptOverrideMap(map['prompts']),
    // The roles section is parsed strictly: schema errors throw
    // [ConfigException] instead of silently resetting to defaults.
    modelRoles: map['roles'] == null && map['modelOverrides'] == null
        ? null
        : ModelRolesConfig.fromYaml(map),
    // The theme section: `tui.theme` names a built-in or user TUI theme;
    // unknown names surface at boot (the default applies instead).
    tuiTheme: tui.theme,
    // The classic-chrome kill switch + statusLine section (issue #805);
    // the renderer wires them in S3.
    tuiClassic: tui.classic,
    statusLine: tui.statusLine,
    // The ttsr section is parsed strictly too (bad rules must surface).
    ttsr: map['ttsr'] == null
        ? null
        : TtsrConfig.fromYaml(map['ttsr'], sourcePath: '~/.fah/config.yaml'),
    // The memory section: storage path overrides; strict like the rest.
    memory: map['memory'] == null
        ? null
        : MemoryConfig.fromYaml(map['memory']),
    cube: map['cube'] == null ? null : CubeSettings.fromYaml(map['cube']),
    // The tools section (capability-gated tool availability); strict too.
    tools: map['tools'] == null ? null : ToolsConfig.fromYaml(map['tools']),
    // The redact section (layered secret redaction); tolerant like
    // RedactionConfig.fromJson — invalid values fall back to defaults.
    redact: map['redact'] == null
        ? null
        : RedactionConfig.fromYaml(map['redact']),
    // The spills section (issue #678); tolerant by design — unknown
    // keys and mistyped scalars become notes, never boot failures.
    spills: map['spills'] == null
        ? null
        : SpillsConfig.fromYaml(map['spills']),
    // Saved custom providers; entry-level errors throw [ConfigException].
    // Entries named after a built-in catalog provider are dropped (issue
    // #221's ghost "openai"): they shadow `/provider <name>` routing and
    // must never re-enter memory — a stale snapshot save would
    // re-persist them after the user deleted them.
    customProviders: switch (map['customProviders']) {
      null => const [],
      final YamlList list => [
        for (final entry in list)
          if (!_isGhostProviderEntry(entry))
            CustomProviderEntry.fromYaml(entry),
      ],
      final other => throw ConfigException(
        'customProviders must be a list, got: $other',
      ),
    },
    // The models section (media slot overrides + custom model
    // definitions) is parsed strictly too.
    models: map['models'] == null
        ? null
        : ModelsConfig.fromYaml(map['models']),
    // The mcp section (external tool servers) is parsed strictly too.
    mcp: map['mcp'] == null ? null : McpConfig.fromYaml(map['mcp']),
    // The a2a section (remote Agent2Agent endpoints) is strict too; its
    // `${NAME}` token references resolve against the environment.
    a2a: map['a2a'] == null
        ? null
        : A2aConfig.fromYaml(
            map['a2a'],
            (name) => Platform.environment[name],
          ),
    // The providerTimeouts section (provider watchdog overrides) is strict
    // too.
    providerTimeouts: parseProviderTimeouts(map['providerTimeouts']),
    // The compaction section (engine selector, issue #148; judge
    // budget knob, issue #541) is strict: a typo throws instead of
    // silently running the classic engine or restoring the default
    // 300s budget.
    compactionEngine: CompactionEngine.fromSection(
      map['compaction'],
      label: '~/.fah/config.yaml',
    ),
    compactionJudgeBudgetSeconds: parseJudgeBudgetSeconds(
      map['compaction'] is Map
          ? (map['compaction'] as Map)['judgeBudgetSeconds']
          : null,
      label: '~/.fah/config.yaml',
    ),
    // The trajectory section (issue #385) is a plain boolean today:
    // `wireDump` opts the raw outbound payloads into the session ledger.
    wireDump: _parseTrajectorySection(map['trajectory']),
    images: parseImagesSection(map['images']),
    powerSleepPrevention: powerSection.sleepPrevention,
    powerHold: powerSection.hold,
    // The quota section (issue #823): badge opt-in + cache TTL, strict.
    quota: quotaSection,
    // The agent section (owner-side context cap + mode, issues
    // #273/#679/#680) is strict too.
    contextWindowCap: agentSection?.contextWindowCap,
    stuckTool: agentSection?.stuckTool,
    agentMode: agentSection?.agentMode,
    agentLoadMode: agentSection?.mode,
    misuseBreaker: agentSection?.misuseBreaker ?? true,
    // The subagents section (background-subagent heartbeat, issue #383)
    subagents: SubagentsConfig.fromYaml(map['subagents']),
    waiting: WaitingConfig.fromYaml(map['waiting']),
    jobs: JobsConfig.fromYaml(map['jobs']),
    // The fabric section (issue #27 phase 2 discovery announcements) is
    // strict too.
    fabric: map['fabric'] == null
        ? null
        : FabricConfig.fromYaml(map['fabric']),
    // The links section (issue #691) is strict on shape, tolerant on
    // unknown keys (notes, never boot failures — AC7).
    links: map['links'] == null
        ? const LinksConfig()
        : LinksConfig.fromYaml(map['links']),
    // The skills section (third-party skills access consent + shell
    // execution toggle + per-skill on/off entries) is strict too.
    skillsAccess:
        skillsSection['skillsAccess'] as SkillsAccess? ??
        SkillsAccess.granted,
    skillsDisableShellExecution:
        skillsSection['skillsDisableShellExecution'] as bool? ?? false,
    skillToggles:
        skillsSection['skillToggles'] as Map<String, bool>? ?? const {},
    // The output section (gh-1198) is strict: `streamThinking` opts
    // line-mode/headless runs into the live dimmed thinking stream;
    // absent = false = the byte-identical legacy output.
    streamThinking: _outputStreamThinking(map['output']),
  );
}