kanon-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,349 @@
1
+ """Multi-source Kanon install business logic.
2
+
3
+ Parses .kanon, validates sources, creates isolated source workspaces
4
+ under ``.kanon-data/sources/<name>/``, runs ``repo init``/``envsubst``/``sync``
5
+ per source, aggregates symlinks into ``.packages/``, detects collisions,
6
+ updates ``.gitignore``, and optionally installs marketplace plugins.
7
+ """
8
+
9
+ import os
10
+ import pathlib
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+
15
+ from kanon_cli.core.marketplace import install_marketplace_plugins
16
+ from kanon_cli.core.kanonenv import parse_kanonenv
17
+ from kanon_cli.version import resolve_version
18
+
19
+
20
+ def create_source_dirs(
21
+ source_names: list[str],
22
+ base_dir: pathlib.Path,
23
+ ) -> dict[str, pathlib.Path]:
24
+ """Create .kanon-data/sources/<name>/ directories for each source.
25
+
26
+ Args:
27
+ source_names: Ordered list of source names (auto-discovered, alphabetical).
28
+ base_dir: Project root directory.
29
+
30
+ Returns:
31
+ Dict mapping source name to its directory path.
32
+ """
33
+ result: dict[str, pathlib.Path] = {}
34
+ for name in source_names:
35
+ source_dir = base_dir / ".kanon-data" / "sources" / name
36
+ source_dir.mkdir(parents=True, exist_ok=True)
37
+ result[name] = source_dir
38
+ return result
39
+
40
+
41
+ def run_repo_init(
42
+ source_dir: pathlib.Path,
43
+ url: str,
44
+ revision: str,
45
+ manifest_path: str,
46
+ repo_rev: str = "",
47
+ ) -> None:
48
+ """Run ``repo init -u <URL> -b <REVISION> -m <PATH>`` in source directory.
49
+
50
+ Args:
51
+ source_dir: Path to ``.kanon-data/sources/<name>/``.
52
+ url: Repository URL for repo init.
53
+ revision: Branch/tag/revision for repo init.
54
+ manifest_path: Manifest file path for repo init.
55
+ repo_rev: Repo tool version tag for ``--repo-rev``.
56
+
57
+ Raises:
58
+ SystemExit: If repo init exits non-zero.
59
+ """
60
+ cmd = [
61
+ "repo",
62
+ "--color=never",
63
+ "init",
64
+ "--no-repo-verify",
65
+ "-u",
66
+ url,
67
+ "-b",
68
+ revision,
69
+ "-m",
70
+ manifest_path,
71
+ ]
72
+ if repo_rev:
73
+ cmd.extend(["--repo-rev", repo_rev])
74
+ result = subprocess.run(
75
+ cmd,
76
+ cwd=source_dir,
77
+ capture_output=True,
78
+ text=True,
79
+ check=False,
80
+ input="n\n",
81
+ )
82
+ if result.returncode != 0:
83
+ print(
84
+ f"Error: repo init failed in {source_dir}: {result.stderr}",
85
+ file=sys.stderr,
86
+ )
87
+ sys.exit(1)
88
+
89
+
90
+ def run_repo_envsubst(
91
+ source_dir: pathlib.Path,
92
+ env_vars: dict[str, str],
93
+ ) -> None:
94
+ """Run ``repo envsubst`` in source directory with exported env vars.
95
+
96
+ Args:
97
+ source_dir: Path to ``.kanon-data/sources/<name>/``.
98
+ env_vars: Environment variables to export (GITBASE, CLAUDE_MARKETPLACES_DIR).
99
+
100
+ Raises:
101
+ SystemExit: If repo envsubst exits non-zero.
102
+ """
103
+ run_env = {**os.environ, **env_vars}
104
+ result = subprocess.run(
105
+ ["repo", "envsubst"],
106
+ cwd=source_dir,
107
+ env=run_env,
108
+ capture_output=True,
109
+ text=True,
110
+ check=False,
111
+ )
112
+ if result.returncode != 0:
113
+ print(
114
+ f"Error: repo envsubst failed in {source_dir}: {result.stderr}",
115
+ file=sys.stderr,
116
+ )
117
+ sys.exit(1)
118
+
119
+
120
+ def run_repo_sync(source_dir: pathlib.Path) -> None:
121
+ """Run ``repo sync`` in source directory.
122
+
123
+ Args:
124
+ source_dir: Path to ``.kanon-data/sources/<name>/``.
125
+
126
+ Raises:
127
+ SystemExit: If repo sync exits non-zero.
128
+ """
129
+ result = subprocess.run(
130
+ ["repo", "sync"],
131
+ cwd=source_dir,
132
+ capture_output=True,
133
+ text=True,
134
+ check=False,
135
+ )
136
+ if result.returncode != 0:
137
+ print(
138
+ f"Error: repo sync failed in {source_dir}: {result.stderr}",
139
+ file=sys.stderr,
140
+ )
141
+ sys.exit(1)
142
+
143
+
144
+ def aggregate_symlinks(
145
+ source_names: list[str],
146
+ base_dir: pathlib.Path,
147
+ ) -> dict[str, str]:
148
+ """Aggregate packages from all sources into ``.packages/``.
149
+
150
+ For each ``.kanon-data/sources/<name>/.packages/*``, creates a symlink in
151
+ the top-level ``.packages/`` directory. Detects collisions when two
152
+ sources produce the same package name.
153
+
154
+ Args:
155
+ source_names: Ordered list of source names.
156
+ base_dir: Project root directory.
157
+
158
+ Returns:
159
+ Dict mapping package name to source name.
160
+
161
+ Raises:
162
+ SystemExit: If two sources produce the same package name.
163
+ """
164
+ packages_dir = base_dir / ".packages"
165
+ packages_dir.mkdir(exist_ok=True)
166
+
167
+ package_owners: dict[str, str] = {}
168
+
169
+ for name in source_names:
170
+ source_packages = base_dir / ".kanon-data" / "sources" / name / ".packages"
171
+ if not source_packages.exists():
172
+ continue
173
+ for pkg in source_packages.iterdir():
174
+ pkg_name = pkg.name
175
+ if pkg_name in package_owners:
176
+ print(
177
+ f"Error: Package collision for '{pkg_name}': "
178
+ f"provided by both '{package_owners[pkg_name]}' "
179
+ f"and '{name}'",
180
+ file=sys.stderr,
181
+ )
182
+ sys.exit(1)
183
+ package_owners[pkg_name] = name
184
+ link_path = packages_dir / pkg_name
185
+ if link_path.exists() or link_path.is_symlink():
186
+ link_path.unlink()
187
+ link_path.symlink_to(pkg.resolve())
188
+
189
+ return package_owners
190
+
191
+
192
+ def update_gitignore(
193
+ base_dir: pathlib.Path,
194
+ entries: list[str] | None = None,
195
+ ) -> None:
196
+ """Ensure ``.gitignore`` contains the required entries.
197
+
198
+ Creates ``.gitignore`` if it does not exist. Appends missing entries
199
+ without duplicating existing ones.
200
+
201
+ Args:
202
+ base_dir: Project root directory.
203
+ entries: List of gitignore entries to ensure. Defaults to
204
+ ``.packages/`` and ``.kanon-data/``.
205
+ """
206
+ gitignore = base_dir / ".gitignore"
207
+ required_entries = entries if entries is not None else [".packages/", ".kanon-data/"]
208
+
209
+ existing_content = ""
210
+ if gitignore.exists():
211
+ existing_content = gitignore.read_text()
212
+
213
+ existing_lines = existing_content.splitlines()
214
+ missing = [entry for entry in required_entries if entry not in existing_lines]
215
+
216
+ if missing:
217
+ with gitignore.open("a") as f:
218
+ if existing_content and not existing_content.endswith("\n"):
219
+ f.write("\n")
220
+ for entry in missing:
221
+ f.write(f"{entry}\n")
222
+
223
+
224
+ def prepare_marketplace_dir(marketplace_dir: pathlib.Path) -> None:
225
+ """Create and clean the marketplace directory for pre-sync setup.
226
+
227
+ Creates the directory if it does not exist, then removes all
228
+ contents for a clean slate before sync.
229
+
230
+ Args:
231
+ marketplace_dir: Path to CLAUDE_MARKETPLACES_DIR.
232
+ """
233
+ marketplace_dir.mkdir(parents=True, exist_ok=True)
234
+ for item in marketplace_dir.iterdir():
235
+ if item.is_symlink() or not item.is_dir():
236
+ item.unlink()
237
+ else:
238
+ shutil.rmtree(item)
239
+
240
+
241
+ def _print_package_summary(
242
+ package_owners: dict[str, str],
243
+ source_names: list[str],
244
+ ) -> None:
245
+ """Print a structured summary of synced packages grouped by source.
246
+
247
+ Args:
248
+ package_owners: Dict mapping package name to source name.
249
+ source_names: Ordered list of source names.
250
+ """
251
+ if not package_owners:
252
+ print("\nkanon install: no packages synced.")
253
+ return
254
+
255
+ # Group packages by source, preserving source order
256
+ by_source: dict[str, list[str]] = {name: [] for name in source_names}
257
+ for pkg_name, source_name in sorted(package_owners.items()):
258
+ by_source[source_name].append(pkg_name)
259
+
260
+ total = len(package_owners)
261
+ print(f"\nkanon install: {total} packages synced to .packages/")
262
+ for source_name in source_names:
263
+ pkgs = by_source[source_name]
264
+ if not pkgs:
265
+ continue
266
+ print(f"\n [{source_name}] ({len(pkgs)} packages)")
267
+ for pkg in pkgs:
268
+ print(f" - {pkg}")
269
+
270
+
271
+ def install(kanonenv_path: pathlib.Path) -> None:
272
+ """Execute the full Kanon install lifecycle.
273
+
274
+ Steps:
275
+ 1. Parse .kanon and validate sources
276
+ 2. If KANON_MARKETPLACE_INSTALL=true: create and clean marketplace dir
277
+ 3. For each source: mkdir, repo init, envsubst, sync
278
+ 4. Aggregate symlinks into .packages/
279
+ 5. Update .gitignore
280
+ 6. If KANON_MARKETPLACE_INSTALL=true: run install script
281
+
282
+ Args:
283
+ kanonenv_path: Path to the .kanon configuration file.
284
+
285
+ Raises:
286
+ SystemExit: On any failure during the install process.
287
+ """
288
+ print(f"kanon install: parsing {kanonenv_path}...")
289
+ config = parse_kanonenv(kanonenv_path)
290
+ base_dir = kanonenv_path.parent
291
+ source_names = config["KANON_SOURCES"]
292
+ sources = config["sources"]
293
+ marketplace_install = config["KANON_MARKETPLACE_INSTALL"]
294
+ globals_dict = config["globals"]
295
+
296
+ marketplace_dir_str = globals_dict.get("CLAUDE_MARKETPLACES_DIR", "")
297
+
298
+ if marketplace_install and not marketplace_dir_str:
299
+ print(
300
+ "Error: KANON_MARKETPLACE_INSTALL=true but CLAUDE_MARKETPLACES_DIR is not defined in .kanon",
301
+ file=sys.stderr,
302
+ )
303
+ sys.exit(1)
304
+
305
+ if marketplace_install:
306
+ marketplace_dir = pathlib.Path(marketplace_dir_str)
307
+ print("kanon install: preparing marketplace directory...")
308
+ prepare_marketplace_dir(marketplace_dir)
309
+
310
+ repo_rev = globals_dict.get("REPO_REV", "")
311
+
312
+ env_vars: dict[str, str] = {}
313
+ if "GITBASE" in globals_dict:
314
+ env_vars["GITBASE"] = globals_dict["GITBASE"]
315
+ if marketplace_dir_str:
316
+ env_vars["CLAUDE_MARKETPLACES_DIR"] = marketplace_dir_str
317
+
318
+ source_dirs = create_source_dirs(source_names, base_dir)
319
+
320
+ for name in source_names:
321
+ source_dir = source_dirs[name]
322
+ source_data = sources[name]
323
+ print(f"kanon install: syncing source '{name}'...")
324
+ resolved_revision = resolve_version(source_data["url"], source_data["revision"])
325
+ print(f" repo init ({source_data['path']})...")
326
+ run_repo_init(
327
+ source_dir,
328
+ source_data["url"],
329
+ resolved_revision,
330
+ source_data["path"],
331
+ repo_rev,
332
+ )
333
+ print(" repo envsubst...")
334
+ run_repo_envsubst(source_dir, env_vars)
335
+ print(" repo sync...")
336
+ run_repo_sync(source_dir)
337
+
338
+ print("kanon install: aggregating packages into .packages/...")
339
+ package_owners = aggregate_symlinks(source_names, base_dir)
340
+ update_gitignore(base_dir)
341
+
342
+ _print_package_summary(package_owners, source_names)
343
+
344
+ if marketplace_install:
345
+ print("\nkanon install: installing marketplace plugins...")
346
+ marketplace_dir = pathlib.Path(marketplace_dir_str)
347
+ install_marketplace_plugins(marketplace_dir)
348
+
349
+ print("\nkanon install: done.")
@@ -0,0 +1,322 @@
1
+ """Multi-source .kanon file parser.
2
+
3
+ Parses KEY=VALUE configuration files used by Kanon bootstrap. The .kanon
4
+ format supports:
5
+ - Comments (lines starting with #) and blank lines
6
+ - Shell variable expansion (``${VAR}``) resolved from environment
7
+ - Environment variable overrides (env vars take precedence over file values)
8
+ - Auto-discovered named source groups from ``KANON_SOURCE_<name>_URL`` keys
9
+ - Boolean parsing for KANON_MARKETPLACE_INSTALL
10
+
11
+ Source names are auto-discovered by scanning for keys matching the
12
+ ``KANON_SOURCE_<name>_URL`` pattern. Names are sorted alphabetically for
13
+ deterministic ordering. Each discovered source must also define
14
+ ``KANON_SOURCE_<name>_REVISION`` and ``KANON_SOURCE_<name>_PATH``.
15
+
16
+ The parser reads the file, applies environment overrides, expands shell
17
+ variables, validates required fields, and returns a structured dict.
18
+ """
19
+
20
+ import os
21
+ import pathlib
22
+ import re
23
+
24
+ from kanon_cli.constants import (
25
+ SHELL_VAR_PATTERN,
26
+ SOURCE_PREFIX,
27
+ SOURCE_SUFFIXES,
28
+ SUFFIX_TO_KEY,
29
+ )
30
+
31
+
32
+ def parse_kanonenv(path: pathlib.Path) -> dict:
33
+ """Parse a .kanon file into a structured configuration dict.
34
+
35
+ Reads KEY=VALUE pairs from the file, applies environment variable
36
+ overrides, expands shell variables (``${VAR}``), auto-discovers
37
+ source names from ``KANON_SOURCE_<name>_URL`` keys, and groups
38
+ source-specific variables.
39
+
40
+ Args:
41
+ path: Path to the .kanon file.
42
+
43
+ Returns:
44
+ A dict with the following keys:
45
+
46
+ - ``KANON_SOURCES``: list of source names (auto-discovered,
47
+ sorted alphabetically)
48
+ - ``KANON_MARKETPLACE_INSTALL``: bool (defaults to False)
49
+ - ``sources``: dict mapping each source name to a dict with
50
+ ``url``, ``revision``, and ``path`` keys
51
+ - ``globals``: dict of all other KEY=VALUE pairs
52
+
53
+ Raises:
54
+ FileNotFoundError: If the file does not exist.
55
+ ValueError: If KANON_SOURCES is explicitly set (no longer supported),
56
+ if no sources are discovered, if a named source is missing
57
+ required variables (URL, REVISION, PATH), or if a shell
58
+ variable reference cannot be resolved.
59
+ """
60
+ if not path.exists():
61
+ msg = f".kanon file not found: {path}"
62
+ raise FileNotFoundError(msg)
63
+
64
+ raw_vars = _read_key_value_pairs(path)
65
+ merged = _apply_env_overrides(raw_vars)
66
+ expanded = _expand_shell_variables(merged)
67
+
68
+ return _build_result(expanded)
69
+
70
+
71
+ def _read_key_value_pairs(path: pathlib.Path) -> dict[str, str]:
72
+ """Read KEY=VALUE pairs from a file, ignoring comments and blanks.
73
+
74
+ Args:
75
+ path: Path to the .kanon file.
76
+
77
+ Returns:
78
+ Dict of raw string key-value pairs.
79
+ """
80
+ result: dict[str, str] = {}
81
+ for line in path.read_text().splitlines():
82
+ stripped = line.strip()
83
+ if not stripped or stripped.startswith("#"):
84
+ continue
85
+ if "=" not in stripped:
86
+ continue
87
+ key, _, value = stripped.partition("=")
88
+ result[key.strip()] = value.strip()
89
+ return result
90
+
91
+
92
+ def _apply_env_overrides(raw_vars: dict[str, str]) -> dict[str, str]:
93
+ """Override file values with environment variables of the same name.
94
+
95
+ Args:
96
+ raw_vars: Dict of KEY=VALUE pairs from the file.
97
+
98
+ Returns:
99
+ Dict with environment overrides applied.
100
+ """
101
+ merged = dict(raw_vars)
102
+ for key in merged:
103
+ env_value = os.environ.get(key)
104
+ if env_value is not None:
105
+ merged[key] = env_value
106
+ # Also check for env vars that define source groups not in the file
107
+ for key, value in os.environ.items():
108
+ if key.startswith(SOURCE_PREFIX) and key not in merged:
109
+ merged[key] = value
110
+ return merged
111
+
112
+
113
+ def _expand_shell_variables(merged: dict[str, str]) -> dict[str, str]:
114
+ """Expand ``${VAR}`` references in values using environment variables.
115
+
116
+ Args:
117
+ merged: Dict of KEY=VALUE pairs with overrides applied.
118
+
119
+ Returns:
120
+ Dict with shell variables expanded.
121
+
122
+ Raises:
123
+ ValueError: If a referenced variable is not defined in
124
+ the environment.
125
+ """
126
+ expanded: dict[str, str] = {}
127
+ for key, value in merged.items():
128
+ expanded[key] = _expand_value(value)
129
+ return expanded
130
+
131
+
132
+ def _expand_value(value: str) -> str:
133
+ """Expand all ``${VAR}`` references in a single value.
134
+
135
+ Args:
136
+ value: The raw value string potentially containing ${VAR}.
137
+
138
+ Returns:
139
+ The value with all variables expanded.
140
+
141
+ Raises:
142
+ ValueError: If a referenced variable is not in the environment.
143
+ """
144
+
145
+ def _replace(match: re.Match) -> str:
146
+ var_name = match.group(1)
147
+ env_val = os.environ.get(var_name)
148
+ if env_val is None:
149
+ msg = f"Undefined shell variable '${{{var_name}}}' referenced in .kanon value"
150
+ raise ValueError(msg)
151
+ return env_val
152
+
153
+ return SHELL_VAR_PATTERN.sub(_replace, value)
154
+
155
+
156
+ def _discover_source_names(expanded: dict[str, str]) -> list[str]:
157
+ """Auto-discover source names from ``KANON_SOURCE_<name>_URL`` keys.
158
+
159
+ Scans all keys for the ``KANON_SOURCE_<name>_URL`` pattern, extracts
160
+ the ``<name>`` portion, and returns a sorted list for deterministic
161
+ ordering.
162
+
163
+ Args:
164
+ expanded: Dict of expanded KEY=VALUE pairs.
165
+
166
+ Returns:
167
+ Sorted list of discovered source names.
168
+
169
+ Raises:
170
+ ValueError: If no ``KANON_SOURCE_<name>_URL`` keys are found.
171
+ """
172
+ url_suffix = "_URL"
173
+ names: list[str] = []
174
+ for key in expanded:
175
+ if key.startswith(SOURCE_PREFIX) and key.endswith(url_suffix):
176
+ name = key[len(SOURCE_PREFIX) : -len(url_suffix)]
177
+ if name:
178
+ names.append(name)
179
+
180
+ if not names:
181
+ msg = (
182
+ "No sources found. Define at least one source using "
183
+ "KANON_SOURCE_<name>_URL, KANON_SOURCE_<name>_REVISION, "
184
+ "and KANON_SOURCE_<name>_PATH variables in .kanon"
185
+ )
186
+ raise ValueError(msg)
187
+
188
+ return sorted(names)
189
+
190
+
191
+ def _build_result(expanded: dict[str, str]) -> dict:
192
+ """Build the structured result dict from expanded variables.
193
+
194
+ Auto-discovers source names from ``KANON_SOURCE_<name>_URL`` keys
195
+ and sorts them alphabetically. Raises an error if ``KANON_SOURCES``
196
+ is explicitly defined (no longer supported).
197
+
198
+ Args:
199
+ expanded: Dict of expanded KEY=VALUE pairs.
200
+
201
+ Returns:
202
+ Structured dict with KANON_SOURCES (auto-discovered), sources,
203
+ globals, and KANON_MARKETPLACE_INSTALL.
204
+
205
+ Raises:
206
+ ValueError: If KANON_SOURCES is explicitly set, if no sources
207
+ are discovered, or if a named source is missing required
208
+ variables.
209
+ """
210
+ if "KANON_SOURCES" in expanded:
211
+ msg = (
212
+ "KANON_SOURCES is no longer supported. Source names are "
213
+ "auto-discovered from KANON_SOURCE_<name>_URL variables. "
214
+ "Remove the KANON_SOURCES line from your .kanon file."
215
+ )
216
+ raise ValueError(msg)
217
+
218
+ source_names = _discover_source_names(expanded)
219
+ sources = _extract_sources(expanded, source_names)
220
+ globals_dict = _extract_globals(expanded, source_names)
221
+ marketplace_install = _parse_bool(expanded.get("KANON_MARKETPLACE_INSTALL", "false"))
222
+
223
+ return {
224
+ "KANON_SOURCES": source_names,
225
+ "KANON_MARKETPLACE_INSTALL": marketplace_install,
226
+ "sources": sources,
227
+ "globals": globals_dict,
228
+ }
229
+
230
+
231
+ def validate_sources(
232
+ expanded: dict[str, str],
233
+ source_names: list[str],
234
+ ) -> None:
235
+ """Validate that all named sources have required variables.
236
+
237
+ Each source name in ``source_names`` must have three corresponding
238
+ variables defined in ``expanded``:
239
+ - ``KANON_SOURCE_<name>_URL``
240
+ - ``KANON_SOURCE_<name>_REVISION``
241
+ - ``KANON_SOURCE_<name>_PATH``
242
+
243
+ Args:
244
+ expanded: Dict of expanded KEY=VALUE pairs from the .kanon file.
245
+ source_names: List of source names (auto-discovered, alphabetical).
246
+
247
+ Raises:
248
+ ValueError: If any named source is missing a required variable.
249
+ The error message includes both the source name and the
250
+ missing variable name for actionable diagnostics.
251
+ """
252
+ for name in source_names:
253
+ for suffix in SOURCE_SUFFIXES:
254
+ var_name = f"{SOURCE_PREFIX}{name}{suffix}"
255
+ if var_name not in expanded:
256
+ msg = f"Missing required variable '{var_name}' for source '{name}'"
257
+ raise ValueError(msg)
258
+
259
+
260
+ def _extract_sources(
261
+ expanded: dict[str, str],
262
+ source_names: list[str],
263
+ ) -> dict[str, dict[str, str]]:
264
+ """Extract named source groups after validation.
265
+
266
+ Args:
267
+ expanded: Dict of expanded KEY=VALUE pairs.
268
+ source_names: List of source names (auto-discovered, alphabetical).
269
+
270
+ Returns:
271
+ Dict mapping source name to {url, revision, path}.
272
+
273
+ Raises:
274
+ ValueError: If a source is missing a required variable.
275
+ """
276
+ validate_sources(expanded, source_names)
277
+ sources: dict[str, dict[str, str]] = {}
278
+ for name in source_names:
279
+ source_data: dict[str, str] = {}
280
+ for suffix in SOURCE_SUFFIXES:
281
+ var_name = f"{SOURCE_PREFIX}{name}{suffix}"
282
+ result_key = SUFFIX_TO_KEY[suffix]
283
+ source_data[result_key] = expanded[var_name]
284
+ sources[name] = source_data
285
+ return sources
286
+
287
+
288
+ def _extract_globals(
289
+ expanded: dict[str, str],
290
+ source_names: list[str],
291
+ ) -> dict[str, str]:
292
+ """Extract non-source, non-special variables as globals.
293
+
294
+ Args:
295
+ expanded: Dict of expanded KEY=VALUE pairs.
296
+ source_names: List of source names (auto-discovered, alphabetical).
297
+
298
+ Returns:
299
+ Dict of global variables (excludes KANON_MARKETPLACE_INSTALL
300
+ and source-specific variables).
301
+ """
302
+ source_keys: set[str] = set()
303
+ for name in source_names:
304
+ for suffix in SOURCE_SUFFIXES:
305
+ source_keys.add(f"{SOURCE_PREFIX}{name}{suffix}")
306
+
307
+ special_keys = {"KANON_MARKETPLACE_INSTALL"}
308
+ exclude = source_keys | special_keys
309
+
310
+ return {k: v for k, v in expanded.items() if k not in exclude}
311
+
312
+
313
+ def _parse_bool(value: str) -> bool:
314
+ """Parse a string boolean value (case-insensitive).
315
+
316
+ Args:
317
+ value: String value to parse ('true' or 'false').
318
+
319
+ Returns:
320
+ True if value is 'true' (case-insensitive), False otherwise.
321
+ """
322
+ return value.strip().lower() == "true"