1"""Config building for headless ll-init."""

2

3 from __future__ import annotations

4

5 from typing import Any

6

7 from little_loops.init.detect import TemplateMatch

8

9 SCHEMA_URL = (

10 "https://raw.githubusercontent.com/BrennonTWilliams/little-loops/main/config-schema.json"

11)

12

13_DEFAULT_ANALYTICS_CAPTURE: dict[str, Any] = {

14 "skills": ["*"],

15 "cli_commands": ["*"],

16 "corrections": True,

17 "file_events": True,

18}

19

20

21 def strip_none_leaves(config: dict[str, Any]) -> dict[str, Any]:

22 """Return a deep copy of *config* with all ``None``-valued leaves removed.

23

24 ``config.core.deep_merge`` treats a ``None`` in the override as a key-removal

25 sentinel (BUG-2310). ``build_config`` emitted ``None`` leaves (e.g.

26 ``loops.run_defaults.mode``, ``project.build_cmd``) and the TUI emitted

27 ``project.<cmd>`` ``None`` for cleared fields; merging those over an existing

28 config would silently delete the user's corresponding keys. Stripping them

29 from generated output makes the merge additive (fix for BUG-2311).

30

31 Nested dicts are recursed; every other value type passes through unchanged.

32 """

33 result: dict[str, Any] = {}

34 for key, value in config.items():

35 if value is None:

36 continue

37 if isinstance(value, dict):

38 result[key] = strip_none_leaves(value)

39 else:

40 result[key] = value

41 return result

42

43

44 def build_config(

45 template: TemplateMatch,

46 choices: dict[str, Any] | None = None,

47) -> dict[str, Any]:

48 """Build the output ll-config.json dict from a template and optional choices.

49

50 Ports the skill's Step 4 and Step 8 config-building logic. The resulting

51 dict is ready for serialisation with ``atomic_write_json``.

52

53 Args:

54 template: Matched template from detect_project_type().

55 choices: Optional overrides. Recognised keys:

56 - ``project_name`` (str): value written to project.name.

57 - ``src_dir`` (str): override project.src_dir.

58 - ``product_enabled`` (bool, default True): include product section.

59 - ``analytics_enabled`` (bool, default True): include analytics section.

60 - ``context_monitor_enabled`` (bool, default True): include context_monitor.

61 - ``learning_tests_enabled`` (bool, default True): include learning_tests.

62 - ``decisions_enabled`` (bool, default False): include decisions section.

63 - ``scratch_pad_enabled`` (bool, default False): include scratch_pad section.

64 - ``session_capture_enabled`` (bool, default False): include session_capture.

65 - ``prompt_optimization_enabled`` (bool, default True): when False, write

66 prompt_optimization.enabled=false (opt-out of a default-on feature).

67 - ``loop_clear_default`` (bool, default True): write loops.run_defaults.clear.

68 - ``loop_show_diagrams_default`` (str | None, default "clean"): write loops.run_defaults.show_diagrams.

69

70 Returns:

71 Complete config dict (``$schema`` key first, then sections).

72 """

73 choices = choices or {}

74 data = template.data

75

76 config: dict[str, Any] = {"$schema": SCHEMA_URL}

77

78 # --- project ---

79 project: dict[str, Any] = dict(data.get("project", {}))

80 if choices.get("project_name"):

81 project["name"] = choices["project_name"]

82 if choices.get("src_dir"):

83 project["src_dir"] = choices["src_dir"]

84 config["project"] = project

85

86 # --- issues ---

87 config["issues"] = dict(data.get("issues", {}))

88

89 # --- scan ---

90 config["scan"] = dict(data.get("scan", {}))

91

92 # --- learning_tests (always written) ---

93 learning_tests_enabled = bool(choices.get("learning_tests_enabled", True))

94 config["learning_tests"] = {"enabled": learning_tests_enabled}

95

96 # --- analytics (always written) ---

97 analytics_enabled = bool(choices.get("analytics_enabled", True))

98 if analytics_enabled:

99 config["analytics"] = {

100 "enabled": True,

101 "capture": dict(_DEFAULT_ANALYTICS_CAPTURE),

102 }

103 else:

104 config["analytics"] = {"enabled": False}

105

106 # --- context_monitor (omit if disabled) ---

107 context_monitor_enabled = bool(choices.get("context_monitor_enabled", True))

108 if context_monitor_enabled:

109 config["context_monitor"] = {"enabled": True}

110

111 # --- product (omit if disabled; default True for --yes mode) ---

112 product_enabled = bool(choices.get("product_enabled", True))

113 if product_enabled:

114 config["product"] = {"enabled": True}

115

116 # --- decisions (opt-in; omit if disabled) ---

117 if choices.get("decisions_enabled"):

118 config["decisions"] = {"enabled": True}

119

120 # --- scratch_pad (opt-in; omit if disabled) ---

121 if choices.get("scratch_pad_enabled"):

122 config["scratch_pad"] = {"enabled": True}

123

124 # --- session_capture (opt-in; omit if disabled) ---

125 if choices.get("session_capture_enabled"):

126 config["session_capture"] = {"enabled": True}

127

128 # --- prompt_optimization (default-on; only write when opting out) ---

129 if choices.get("prompt_optimization_enabled", True) is False:

130 config["prompt_optimization"] = {"enabled": False}

131

132 # --- history.session_digest (always written) ---

133 session_digest_enabled = bool(choices.get("session_digest_enabled", True))

134 config["history"] = {

135 "session_digest": {

136 "enabled": session_digest_enabled,

137 "days": 7,

138 }

139 }

140

141 # --- loops.run_defaults (always written; exposes the feature at init time) ---

142 loop_clear = bool(choices.get("loop_clear_default", True))

143 loop_show_diagrams = choices.get("loop_show_diagrams_default", "clean")

144 config["loops"] = {

145 "run_defaults": {

146 "clear": loop_clear,

147 "show_diagrams": loop_show_diagrams,

148 }

149 }

150

151 return strip_none_leaves(config)