kd-cli 2.0.4__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.
Files changed (64) hide show
  1. kd/__init__.py +45 -0
  2. kd/api/__init__.py +15 -0
  3. kd/api/articles.py +232 -0
  4. kd/api/http.py +234 -0
  5. kd/api/hub.py +69 -0
  6. kd/api/saved_queries.py +76 -0
  7. kd/api/tags.py +107 -0
  8. kd/api/youtrack.py +414 -0
  9. kd/article_marker.py +348 -0
  10. kd/auth.py +96 -0
  11. kd/cli/__init__.py +5 -0
  12. kd/cli/core/__init__.py +25 -0
  13. kd/cli/core/command_base.py +133 -0
  14. kd/cli/core/command_parser.py +246 -0
  15. kd/cli/core/namespaces.py +175 -0
  16. kd/cli/core/option_registry.py +265 -0
  17. kd/cli/input.py +42 -0
  18. kd/cli/main.py +612 -0
  19. kd/cli/namespaces/__init__.py +38 -0
  20. kd/cli/namespaces/alias/__init__.py +87 -0
  21. kd/cli/namespaces/article/__init__.py +843 -0
  22. kd/cli/namespaces/init.py +42 -0
  23. kd/cli/namespaces/issue/__init__.py +959 -0
  24. kd/cli/namespaces/primer/__init__.py +337 -0
  25. kd/cli/namespaces/project/__init__.py +243 -0
  26. kd/cli/namespaces/query/__init__.py +465 -0
  27. kd/cli/namespaces/search/__init__.py +82 -0
  28. kd/cli/namespaces/system/__init__.py +528 -0
  29. kd/cli/namespaces/tag/__init__.py +337 -0
  30. kd/cli/namespaces/time/__init__.py +307 -0
  31. kd/cli/namespaces/user/__init__.py +99 -0
  32. kd/cli/output.py +287 -0
  33. kd/cli/tree.py +65 -0
  34. kd/client.py +1953 -0
  35. kd/config.py +853 -0
  36. kd/connection.py +156 -0
  37. kd/debug.py +107 -0
  38. kd/exceptions.py +189 -0
  39. kd/history.py +169 -0
  40. kd/models/__init__.py +75 -0
  41. kd/models/article.py +64 -0
  42. kd/models/assist.py +47 -0
  43. kd/models/attachment.py +20 -0
  44. kd/models/comment.py +25 -0
  45. kd/models/common.py +39 -0
  46. kd/models/issue.py +67 -0
  47. kd/models/project.py +60 -0
  48. kd/models/saved_query.py +54 -0
  49. kd/models/search.py +20 -0
  50. kd/models/tag.py +30 -0
  51. kd/models/user.py +42 -0
  52. kd/models/work_item.py +59 -0
  53. kd/primer.py +231 -0
  54. kd/query_parser.py +181 -0
  55. kd/resources/__init__.py +1 -0
  56. kd/resources/command-synonyms.yaml +19 -0
  57. kd/resources/config-template.yaml +71 -0
  58. kd/resources/error-messages.yaml +101 -0
  59. kd_cli-2.0.4.dist-info/METADATA +149 -0
  60. kd_cli-2.0.4.dist-info/RECORD +64 -0
  61. kd_cli-2.0.4.dist-info/WHEEL +5 -0
  62. kd_cli-2.0.4.dist-info/entry_points.txt +2 -0
  63. kd_cli-2.0.4.dist-info/licenses/LICENSE +21 -0
  64. kd_cli-2.0.4.dist-info/top_level.txt +1 -0
kd/config.py ADDED
@@ -0,0 +1,853 @@
1
+ """YAML-based configuration with hierarchical resolution.
2
+
3
+ Flat-key resolution chain (first match wins per key):
4
+
5
+ 1. CLI flags
6
+ 2. Local config — ``.kd/config.yaml`` found by walking up from cwd
7
+ 3. Global config — ``~/.config/kd/config.yaml``
8
+ 4. Built-in default
9
+
10
+ Command-option resolution chain (10-level, via :func:`resolve_command_defaults`):
11
+
12
+ 1. CLI flag (caller skips options already in ``raw_options``)
13
+ 2. Env var (N/A for command options)
14
+ 3. Project command default — ``projects.<PROJ>.defaults.<cmd>.<opt>``
15
+ 4. Local command default — ``defaults.<cmd>.<opt>`` in local config
16
+ 5. Local flat key — ``limit``/``output`` in local config
17
+ 6. Connection command default — ``connections.<name>.defaults.<cmd>.<opt>``
18
+ 7. Connection flat key — ``limit``/``output`` on the active connection
19
+ 8. Global command default — ``defaults.<cmd>.<opt>`` in global config
20
+ 9. Global flat key — ``limit``/``output`` in global config
21
+ 10. Built-in default — from ``OptionDeclaration.default``
22
+
23
+ Use :func:`resolve_config` to get a :class:`ResolvedConfig` with per-key
24
+ source tracking. Lower-level :func:`load_config` / :func:`save_config`
25
+ remain available for single-file operations.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ from enum import StrEnum
32
+ from pathlib import Path
33
+ from typing import TYPE_CHECKING, Any, NamedTuple
34
+
35
+ import yaml
36
+ from pydantic import BaseModel
37
+
38
+ from kd.connection import ConnectionConfig
39
+ from kd.debug import DebugLog
40
+ from kd.exceptions import ConfigError
41
+
42
+ if TYPE_CHECKING:
43
+ from kd.cli.core.option_registry import OptionDeclaration
44
+
45
+ DEFAULT_CONFIG_PATH = Path.home() / ".config" / "kd" / "config.yaml"
46
+
47
+ # Environment variable names for config keys that support env override.
48
+ # url/token moved to per-connection auth (token_cmd / token_env).
49
+ _ENV_MAP: dict[str, str] = {
50
+ "default_connection": "KD_CONNECTION",
51
+ }
52
+
53
+ # Origin labels for flag-sourced entries (overrides "--{key}" default).
54
+ _FLAG_ORIGINS: dict[str, str] = {
55
+ "default_connection": "-c",
56
+ }
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Resolution types
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ class ConfigSource(StrEnum):
65
+ """Where a resolved config value came from."""
66
+
67
+ FLAG = "flag"
68
+ ENV = "env"
69
+ LOCAL = "local"
70
+ GLOBAL = "global"
71
+ DEFAULT = "default"
72
+
73
+
74
+ class ResolvedValue(NamedTuple):
75
+ """A config value together with its provenance."""
76
+
77
+ value: str
78
+ source: ConfigSource
79
+ origin: str # e.g. "-c", "KD_CONNECTION", "/path/.kd/config.yaml"
80
+
81
+
82
+ class ConnectionOrigin(NamedTuple):
83
+ """Provenance of a connection entry loaded from config files."""
84
+
85
+ source: str # "local" or "global"
86
+ path: str # absolute path to the file the entry was loaded from
87
+
88
+
89
+ class ResolvedConfig:
90
+ """Config resolved from all sources with per-key source tracking.
91
+
92
+ Flat keys are resolved via the standard chain (flag > local > global > default).
93
+ Structured sections (defaults, projects, connections) are stored as raw dicts
94
+ from each config file, enabling the 10-level command-option resolution chain.
95
+ """
96
+
97
+ def __init__(
98
+ self,
99
+ entries: dict[str, ResolvedValue],
100
+ *,
101
+ local_defaults: dict[str, dict[str, Any]] | None = None,
102
+ global_defaults: dict[str, dict[str, Any]] | None = None,
103
+ local_projects: dict[str, Any] | None = None,
104
+ global_projects: dict[str, Any] | None = None,
105
+ local_flat: dict[str, Any] | None = None,
106
+ global_flat: dict[str, Any] | None = None,
107
+ local_aliases: dict[str, str] | None = None,
108
+ global_aliases: dict[str, str] | None = None,
109
+ connections: dict[str, ConnectionConfig] | None = None,
110
+ connection_origins: dict[str, ConnectionOrigin] | None = None,
111
+ default_connection_name: str | None = None,
112
+ connection_defaults: dict[str, dict[str, Any]] | None = None,
113
+ connection_flat: dict[str, Any] | None = None,
114
+ ) -> None:
115
+ self._entries = dict(entries)
116
+ self._local_defaults = local_defaults or {}
117
+ self._global_defaults = global_defaults or {}
118
+ self._local_projects = local_projects or {}
119
+ self._global_projects = global_projects or {}
120
+ self._local_flat = local_flat or {}
121
+ self._global_flat = global_flat or {}
122
+ self._local_aliases = local_aliases or {}
123
+ self._global_aliases = global_aliases or {}
124
+ self._connections = connections or {}
125
+ self._connection_origins = connection_origins or {}
126
+ self._default_connection_name = default_connection_name
127
+ self._connection_defaults = connection_defaults or {}
128
+ self._connection_flat = connection_flat or {}
129
+
130
+ # -- flat key accessors --------------------------------------------------
131
+
132
+ @property
133
+ def default_project(self) -> str | None:
134
+ rv = self._entries.get("default_project")
135
+ return rv.value if rv else None
136
+
137
+ @property
138
+ def output(self) -> str:
139
+ rv = self._entries.get("output")
140
+ return rv.value if rv else "table"
141
+
142
+ @property
143
+ def limit(self) -> int:
144
+ rv = self._entries.get("limit")
145
+ if rv:
146
+ try:
147
+ return int(rv.value)
148
+ except (ValueError, TypeError):
149
+ return 50
150
+ return 50
151
+
152
+ @property
153
+ def debug(self) -> bool:
154
+ rv = self._entries.get("debug")
155
+ if rv:
156
+ return rv.value.lower() in ("true", "1", "yes")
157
+ return False
158
+
159
+ # -- dict-like access ----------------------------------------------------
160
+
161
+ def get(self, key: str) -> ResolvedValue | None:
162
+ return self._entries.get(key)
163
+
164
+ @property
165
+ def entries(self) -> dict[str, ResolvedValue]:
166
+ """Read-only view of all resolved entries."""
167
+ return dict(self._entries)
168
+
169
+ # -- connection accessors -----------------------------------------------
170
+
171
+ @property
172
+ def connections(self) -> dict[str, ConnectionConfig]:
173
+ return self._connections
174
+
175
+ @property
176
+ def connection_origins(self) -> dict[str, ConnectionOrigin]:
177
+ """Provenance per connection: ``source`` (local/global) and ``path``."""
178
+ return self._connection_origins
179
+
180
+ @property
181
+ def default_connection_name(self) -> str | None:
182
+ return self._default_connection_name
183
+
184
+ @property
185
+ def connection_defaults(self) -> dict[str, dict[str, Any]]:
186
+ return self._connection_defaults
187
+
188
+ @property
189
+ def connection_flat(self) -> dict[str, Any]:
190
+ return self._connection_flat
191
+
192
+ # -- structured section accessors ----------------------------------------
193
+
194
+ @property
195
+ def local_defaults(self) -> dict[str, dict[str, Any]]:
196
+ return self._local_defaults
197
+
198
+ @property
199
+ def global_defaults(self) -> dict[str, dict[str, Any]]:
200
+ return self._global_defaults
201
+
202
+ @property
203
+ def local_projects(self) -> dict[str, Any]:
204
+ return self._local_projects
205
+
206
+ @property
207
+ def global_projects(self) -> dict[str, Any]:
208
+ return self._global_projects
209
+
210
+ @property
211
+ def local_flat(self) -> dict[str, Any]:
212
+ return self._local_flat
213
+
214
+ @property
215
+ def global_flat(self) -> dict[str, Any]:
216
+ return self._global_flat
217
+
218
+ @property
219
+ def local_aliases(self) -> dict[str, str]:
220
+ return self._local_aliases
221
+
222
+ @property
223
+ def global_aliases(self) -> dict[str, str]:
224
+ return self._global_aliases
225
+
226
+ @property
227
+ def aliases(self) -> dict[str, str]:
228
+ """Merged aliases: global first, local overrides."""
229
+ merged = dict(self._global_aliases)
230
+ merged.update(self._local_aliases)
231
+ return merged
232
+
233
+
234
+ # ---------------------------------------------------------------------------
235
+ # Directory walk
236
+ # ---------------------------------------------------------------------------
237
+
238
+
239
+ def find_local_config(start: Path | None = None) -> Path | None:
240
+ """Walk up from *start* to filesystem root, return first ``.kd/config.yaml`` found."""
241
+ current = (start or Path.cwd()).resolve()
242
+ while True:
243
+ candidate = current / ".kd" / "config.yaml"
244
+ if candidate.is_file():
245
+ return candidate
246
+ parent = current.parent
247
+ if parent == current: # filesystem root
248
+ return None
249
+ current = parent
250
+
251
+
252
+ # ---------------------------------------------------------------------------
253
+ # Data model (single-file)
254
+ # ---------------------------------------------------------------------------
255
+
256
+
257
+ class ProjectConfig(BaseModel):
258
+ """Per-project configuration: field aliases and scoped defaults."""
259
+
260
+ field_aliases: dict[str, str] = {}
261
+ defaults: dict[str, dict[str, Any]] = {}
262
+
263
+
264
+ class HistoryConfig(BaseModel):
265
+ """Copy-on-write snapshot settings."""
266
+
267
+ retention: int = 20
268
+
269
+
270
+ class YtctlConfig(BaseModel):
271
+ """Configuration model.
272
+
273
+ Connection-first: ``default_connection`` + ``connections:`` replace
274
+ the former flat ``url``/``token``/``default_project`` keys.
275
+ Global ``output``/``limit``/``debug`` are fallbacks for connections
276
+ that don't specify their own.
277
+ """
278
+
279
+ # Connection routing
280
+ default_connection: str | None = None
281
+ connections: dict[str, ConnectionConfig] = {}
282
+
283
+ # Global flat keys (fallbacks for all connections)
284
+ output: str = "table"
285
+ limit: int = 50
286
+ debug: bool = False
287
+
288
+ # Structured sections
289
+ defaults: dict[str, dict[str, Any]] = {}
290
+ projects: dict[str, ProjectConfig] = {}
291
+ aliases: dict[str, str] = {}
292
+ history: HistoryConfig = HistoryConfig()
293
+
294
+
295
+ def load_config(path: Path | None = None) -> YtctlConfig:
296
+ """Load config from YAML file. Returns empty config if file doesn't exist."""
297
+ config_path = path or DEFAULT_CONFIG_PATH
298
+ if not config_path.exists():
299
+ return YtctlConfig()
300
+ try:
301
+ raw = yaml.safe_load(config_path.read_text()) or {}
302
+ return YtctlConfig(**raw)
303
+ except (yaml.YAMLError, TypeError, ValueError) as e:
304
+ raise ConfigError(f"Invalid config at {config_path}: {e}") from e
305
+
306
+
307
+ def save_config(config: YtctlConfig, path: Path | None = None) -> None:
308
+ """Save config to YAML file. Creates parent directories if needed.
309
+
310
+ Snapshots the current file before writing (copy-on-write) and uses
311
+ atomic writes to prevent partial-write corruption.
312
+ """
313
+ from kd.history import atomic_write_text, snapshot_before_write
314
+
315
+ config_path = path or DEFAULT_CONFIG_PATH
316
+ config_path.parent.mkdir(parents=True, exist_ok=True)
317
+ snapshot_before_write(config_path, retention=config.history.retention)
318
+ data = config.model_dump(exclude_none=True)
319
+ # Exclude history section when at default to keep config files clean
320
+ if data.get("history") == HistoryConfig().model_dump():
321
+ data.pop("history", None)
322
+ atomic_write_text(config_path, yaml.dump(data, default_flow_style=False, sort_keys=False))
323
+
324
+
325
+ # Flat keys settable via `config set` (excludes structured sections and connections)
326
+ FLAT_KEYS = ("default_connection", "output", "limit", "debug")
327
+
328
+
329
+ def update_config(key: str, value: str, path: Path | None = None) -> None:
330
+ """Update a single flat config key and save.
331
+
332
+ Validates that the key is a known flat key (not a structured section).
333
+ Connection-level values are not settable through this function.
334
+ """
335
+ config = load_config(path)
336
+ if key not in FLAT_KEYS:
337
+ valid_keys = ", ".join(FLAT_KEYS)
338
+ raise ConfigError(f"Unknown config key: {key}. Valid keys: {valid_keys}")
339
+ # Coerce types for non-string flat keys
340
+ if key == "limit":
341
+ try:
342
+ setattr(config, key, int(value))
343
+ except ValueError:
344
+ raise ConfigError(f"Invalid integer for limit: {value}") from None
345
+ elif key == "debug":
346
+ setattr(config, key, value.lower() in ("true", "1", "yes"))
347
+ else:
348
+ setattr(config, key, value)
349
+ save_config(config, path)
350
+
351
+
352
+ # ---------------------------------------------------------------------------
353
+ # Hierarchical resolution
354
+ # ---------------------------------------------------------------------------
355
+
356
+
357
+ def _load_raw_yaml(path: Path) -> dict[str, str]:
358
+ """Load raw YAML dict from a config file. Returns empty dict if missing."""
359
+ if not path.exists():
360
+ return {}
361
+ try:
362
+ return yaml.safe_load(path.read_text()) or {}
363
+ except yaml.YAMLError:
364
+ return {}
365
+
366
+
367
+ def resolve_config(
368
+ *,
369
+ flags: dict[str, str] | None = None,
370
+ start_path: Path | None = None,
371
+ debug_log: DebugLog | None = None,
372
+ ) -> ResolvedConfig:
373
+ """Resolve config from all sources in priority order.
374
+
375
+ Per flat key: CLI flag → local config → global config → default.
376
+ Connections are merged from global then local (local overrides by name).
377
+ The active connection's ``default_project`` is injected into entries.
378
+ """
379
+ flags = flags or {}
380
+ d = debug_log or DebugLog()
381
+
382
+ # Load raw YAML dicts (not Pydantic) to distinguish explicit values from defaults
383
+ local_path = find_local_config(start_path)
384
+ local_raw = _load_raw_yaml(local_path) if local_path else {}
385
+ global_raw = _load_raw_yaml(DEFAULT_CONFIG_PATH)
386
+
387
+ d.trace("config", f"local config path: {local_path or 'not found'}")
388
+ d.trace("config", f"global config path: {DEFAULT_CONFIG_PATH}")
389
+
390
+ # Flat keys that have built-in defaults
391
+ _DEFAULTS: dict[str, str] = {
392
+ "output": "table",
393
+ "limit": "50",
394
+ "debug": "false",
395
+ }
396
+
397
+ # Only resolve flat keys (not structured sections or connections)
398
+ _FLAT_KEYS = {"default_connection", "output", "limit", "debug"}
399
+
400
+ entries: dict[str, ResolvedValue] = {}
401
+
402
+ for key in _FLAT_KEYS:
403
+ # 1. CLI flag
404
+ if key in flags and flags[key]:
405
+ d.trace("config", f"flag: {key} -> {flags[key]}")
406
+ origin = _FLAG_ORIGINS.get(key, f"--{key}")
407
+ entries[key] = ResolvedValue(flags[key], ConfigSource.FLAG, origin)
408
+ _log_resolved(d, key, flags[key], ConfigSource.FLAG)
409
+ continue
410
+ d.trace("config", f"flag: {key} -> not set")
411
+
412
+ # 2. Environment variable
413
+ env_var = _ENV_MAP.get(key)
414
+ if env_var:
415
+ env_val = os.environ.get(env_var)
416
+ if env_val:
417
+ d.trace("config", f"env: {env_var} -> {env_val}")
418
+ entries[key] = ResolvedValue(env_val, ConfigSource.ENV, env_var)
419
+ _log_resolved(d, key, env_val, ConfigSource.ENV)
420
+ continue
421
+ d.trace("config", f"env: {env_var} -> not set")
422
+ else:
423
+ d.trace("config", f"env: (no env var for {key})")
424
+
425
+ # 3. Local config (raw YAML — only keys explicitly in the file)
426
+ if key in local_raw and local_raw[key] is not None:
427
+ val = str(local_raw[key])
428
+ d.trace("config", f"local: {local_path} -> {key}={val}")
429
+ entries[key] = ResolvedValue(val, ConfigSource.LOCAL, str(local_path))
430
+ _log_resolved(d, key, val, ConfigSource.LOCAL)
431
+ continue
432
+ d.trace("config", f"local: {key} -> not set")
433
+
434
+ # 4. Global config (raw YAML)
435
+ if key in global_raw and global_raw[key] is not None:
436
+ val = str(global_raw[key])
437
+ d.trace("config", f"global: {DEFAULT_CONFIG_PATH} -> {key}={val}")
438
+ entries[key] = ResolvedValue(val, ConfigSource.GLOBAL, str(DEFAULT_CONFIG_PATH))
439
+ _log_resolved(d, key, val, ConfigSource.GLOBAL)
440
+ continue
441
+ d.trace("config", f"global: {key} -> not set")
442
+
443
+ # 5. Built-in default
444
+ if key in _DEFAULTS:
445
+ default_val = _DEFAULTS[key]
446
+ d.trace("config", f"default: {key}={default_val}")
447
+ entries[key] = ResolvedValue(default_val, ConfigSource.DEFAULT, "default")
448
+ _log_resolved(d, key, default_val, ConfigSource.DEFAULT)
449
+
450
+ # -- Connections: merge global then local (local overrides by name) ------
451
+ connections, connection_origins = _resolve_connections(
452
+ global_raw, local_raw, DEFAULT_CONFIG_PATH, local_path, d
453
+ )
454
+ default_conn_name = entries.get("default_connection")
455
+ default_conn_name_str = default_conn_name.value if default_conn_name else None
456
+
457
+ # Inject default_project from active connection into entries
458
+ conn_defaults: dict[str, dict[str, Any]] = {}
459
+ conn_flat: dict[str, Any] = {}
460
+ if connections and default_conn_name_str:
461
+ active = connections.get(default_conn_name_str)
462
+ if active:
463
+ if active.default_project:
464
+ entries["default_project"] = ResolvedValue(
465
+ active.default_project,
466
+ ConfigSource.GLOBAL,
467
+ f"connection:{default_conn_name_str}",
468
+ )
469
+ conn_defaults = active.defaults
470
+ if active.output is not None:
471
+ conn_flat["output"] = active.output
472
+ if active.limit is not None:
473
+ conn_flat["limit"] = active.limit
474
+ elif connections and not default_conn_name_str and len(connections) == 1:
475
+ # Single connection — use it implicitly
476
+ single_name = next(iter(connections))
477
+ default_conn_name_str = single_name
478
+ active = connections[single_name]
479
+ if active.default_project:
480
+ entries["default_project"] = ResolvedValue(
481
+ active.default_project, ConfigSource.GLOBAL, f"connection:{single_name}"
482
+ )
483
+ conn_defaults = active.defaults
484
+ if active.output is not None:
485
+ conn_flat["output"] = active.output
486
+ if active.limit is not None:
487
+ conn_flat["limit"] = active.limit
488
+
489
+ # Extract structured sections from raw YAML
490
+ local_defaults = _extract_defaults(local_raw)
491
+ global_defaults = _extract_defaults(global_raw)
492
+ local_projects = _extract_projects(local_raw)
493
+ global_projects = _extract_projects(global_raw)
494
+ local_aliases = _extract_aliases(local_raw)
495
+ global_aliases = _extract_aliases(global_raw)
496
+
497
+ # Extract flat-only keys for command option resolution (levels 5/9)
498
+ local_flat = {k: v for k, v in local_raw.items() if k in _FLAT_KEYS and v is not None}
499
+ global_flat = {k: v for k, v in global_raw.items() if k in _FLAT_KEYS and v is not None}
500
+
501
+ if local_defaults or local_projects:
502
+ d.debug(
503
+ "config",
504
+ f"local structured: {len(local_defaults)} defaults, {len(local_projects)} projects",
505
+ )
506
+ if global_defaults or global_projects:
507
+ d.debug(
508
+ "config",
509
+ f"global structured: {len(global_defaults)} defaults, {len(global_projects)} projects",
510
+ )
511
+ if local_aliases or global_aliases:
512
+ d.debug(
513
+ "config",
514
+ f"aliases: {len(local_aliases)} local, {len(global_aliases)} global",
515
+ )
516
+ if connections:
517
+ d.debug("config", f"connections: {len(connections)} ({', '.join(connections)})")
518
+
519
+ return ResolvedConfig(
520
+ entries,
521
+ local_defaults=local_defaults,
522
+ global_defaults=global_defaults,
523
+ local_projects=local_projects,
524
+ global_projects=global_projects,
525
+ local_flat=local_flat,
526
+ global_flat=global_flat,
527
+ local_aliases=local_aliases,
528
+ global_aliases=global_aliases,
529
+ connections=connections,
530
+ connection_origins=connection_origins,
531
+ default_connection_name=default_conn_name_str,
532
+ connection_defaults=conn_defaults,
533
+ connection_flat=conn_flat,
534
+ )
535
+
536
+
537
+ def _log_resolved(d: DebugLog, key: str, value: str, source: ConfigSource) -> None:
538
+ """Log the winning resolution for a config key."""
539
+ if key == "default_connection":
540
+ d.info("config", f"{key}={value} ({source})")
541
+ else:
542
+ d.debug("config", f"{key}={value} ({source})")
543
+
544
+
545
+ def _resolve_connections(
546
+ global_raw: dict[str, Any],
547
+ local_raw: dict[str, Any],
548
+ global_path: Path,
549
+ local_path: Path | None,
550
+ d: DebugLog,
551
+ ) -> tuple[dict[str, ConnectionConfig], dict[str, ConnectionOrigin]]:
552
+ """Merge and validate connections from global and local config.
553
+
554
+ Global connections load first; local connections override by name.
555
+ Returns the validated connections and a parallel origin dict so the
556
+ caller can report which file each connection was loaded from.
557
+ """
558
+ merged: dict[str, Any] = {}
559
+ origins: dict[str, ConnectionOrigin] = {}
560
+
561
+ # Global first so local overrides (both by name and origin).
562
+ sources: list[tuple[str, dict[str, Any], Path | None]] = [
563
+ ("global", global_raw, global_path),
564
+ ("local", local_raw, local_path),
565
+ ]
566
+ for source_label, raw, path in sources:
567
+ conns = raw.get("connections")
568
+ if not isinstance(conns, dict):
569
+ continue
570
+ for name, data in conns.items():
571
+ if isinstance(data, dict) and path is not None:
572
+ merged[str(name)] = data
573
+ origins[str(name)] = ConnectionOrigin(source=source_label, path=str(path))
574
+ d.trace("config", f"connection '{name}' from {source_label} ({path})")
575
+
576
+ if not merged:
577
+ return {}, {}
578
+
579
+ result: dict[str, ConnectionConfig] = {}
580
+ for name, data in merged.items():
581
+ try:
582
+ result[name] = ConnectionConfig(**data)
583
+ except (TypeError, ValueError) as e:
584
+ raise ConfigError(f"Invalid connection '{name}': {e}") from e
585
+
586
+ return result, origins
587
+
588
+
589
+ def _extract_defaults(raw: dict[str, Any]) -> dict[str, dict[str, Any]]:
590
+ """Extract the ``defaults:`` section from raw YAML, validating structure."""
591
+ defaults = raw.get("defaults")
592
+ if not isinstance(defaults, dict):
593
+ return {}
594
+ result: dict[str, dict[str, Any]] = {}
595
+ for cmd_key, opts in defaults.items():
596
+ if isinstance(opts, dict):
597
+ result[str(cmd_key)] = {str(k): v for k, v in opts.items()}
598
+ return result
599
+
600
+
601
+ def _extract_projects(raw: dict[str, Any]) -> dict[str, Any]:
602
+ """Extract the ``projects:`` section from raw YAML, validating structure."""
603
+ projects = raw.get("projects")
604
+ if not isinstance(projects, dict):
605
+ return {}
606
+ result: dict[str, Any] = {}
607
+ for proj_key, proj_data in projects.items():
608
+ if isinstance(proj_data, dict):
609
+ result[str(proj_key)] = proj_data
610
+ return result
611
+
612
+
613
+ def _extract_aliases(raw: dict[str, Any]) -> dict[str, str]:
614
+ """Extract the ``aliases:`` section from raw YAML, validating structure."""
615
+ aliases = raw.get("aliases")
616
+ if not isinstance(aliases, dict):
617
+ return {}
618
+ result: dict[str, str] = {}
619
+ for name, command in aliases.items():
620
+ if isinstance(command, str):
621
+ result[str(name)] = command
622
+ return result
623
+
624
+
625
+ # Keys that exist as both flat config keys and command/output options.
626
+ # These participate in levels 5/7 of the command option resolution chain.
627
+ _FLAT_OPTION_KEYS = {"limit", "output"}
628
+
629
+
630
+ def resolve_command_defaults(
631
+ command_key: str,
632
+ project: str | None,
633
+ raw_options: dict[str, Any],
634
+ config: ResolvedConfig,
635
+ declarations: list[OptionDeclaration],
636
+ *,
637
+ debug_log: DebugLog | None = None,
638
+ ) -> dict[str, Any]:
639
+ """Resolve command option defaults from the config hierarchy.
640
+
641
+ Returns a dict of option values for options **not** present in
642
+ *raw_options* (CLI flags always win — level 1).
643
+
644
+ Resolution per option (first non-None wins):
645
+
646
+ 1. CLI flag — skipped (present in *raw_options*)
647
+ 2. Env var — N/A for command options
648
+ 3. Project command default — ``projects.<proj>.defaults.<cmd>.<opt>``
649
+ 4. Local command default — ``defaults.<cmd>.<opt>`` in local config
650
+ 5. Local flat key — for ``limit``/``output`` only
651
+ 6. Connection command default — ``connections.<name>.defaults.<cmd>.<opt>``
652
+ 7. Connection flat key — for ``limit``/``output`` only
653
+ 8. Global command default — ``defaults.<cmd>.<opt>`` in global config
654
+ 9. Global flat key — for ``limit``/``output`` only
655
+ 10. Built-in default — from ``OptionDeclaration.default``
656
+ """
657
+ d = debug_log or DebugLog()
658
+ resolved: dict[str, Any] = {}
659
+
660
+ for decl in declarations:
661
+ opt = decl.name
662
+
663
+ # Level 1: CLI flag already set — skip
664
+ if opt in raw_options:
665
+ continue
666
+
667
+ value = _resolve_single_option(opt, command_key, project, config, d)
668
+ if value is not None:
669
+ resolved[opt] = value
670
+
671
+ return resolved
672
+
673
+
674
+ def _resolve_single_option(
675
+ opt: str,
676
+ command_key: str,
677
+ project: str | None,
678
+ config: ResolvedConfig,
679
+ d: DebugLog,
680
+ ) -> Any:
681
+ """Walk levels 3–9 for a single option. Returns None if no config value found."""
682
+ # Level 3: Project command default (local then global)
683
+ if project:
684
+ val = _project_command_default(project, command_key, opt, config.local_projects)
685
+ if val is not None:
686
+ d.debug("config", f"option {opt}: project({project}).local.{command_key} -> {val}")
687
+ return val
688
+ val = _project_command_default(project, command_key, opt, config.global_projects)
689
+ if val is not None:
690
+ d.debug("config", f"option {opt}: project({project}).global.{command_key} -> {val}")
691
+ return val
692
+
693
+ # Level 4: Local command default
694
+ val = config.local_defaults.get(command_key, {}).get(opt)
695
+ if val is not None:
696
+ d.debug("config", f"option {opt}: local.defaults.{command_key} -> {val}")
697
+ return val
698
+
699
+ # Level 5: Local flat key (only for limit/output)
700
+ if opt in _FLAT_OPTION_KEYS:
701
+ val = config.local_flat.get(opt)
702
+ if val is not None:
703
+ d.debug("config", f"option {opt}: local.flat -> {val}")
704
+ return val
705
+
706
+ # Level 6: Connection command default
707
+ val = config.connection_defaults.get(command_key, {}).get(opt)
708
+ if val is not None:
709
+ d.debug("config", f"option {opt}: connection.defaults.{command_key} -> {val}")
710
+ return val
711
+
712
+ # Level 7: Connection flat key (only for limit/output)
713
+ if opt in _FLAT_OPTION_KEYS:
714
+ val = config.connection_flat.get(opt)
715
+ if val is not None:
716
+ d.debug("config", f"option {opt}: connection.flat -> {val}")
717
+ return val
718
+
719
+ # Level 8: Global command default
720
+ val = config.global_defaults.get(command_key, {}).get(opt)
721
+ if val is not None:
722
+ d.debug("config", f"option {opt}: global.defaults.{command_key} -> {val}")
723
+ return val
724
+
725
+ # Level 9: Global flat key (only for limit/output)
726
+ if opt in _FLAT_OPTION_KEYS:
727
+ val = config.global_flat.get(opt)
728
+ if val is not None:
729
+ d.debug("config", f"option {opt}: global.flat -> {val}")
730
+ return val
731
+
732
+ # Level 10: Built-in default handled by OptionRegistry.resolve() already
733
+ return None
734
+
735
+
736
+ def _project_command_default(
737
+ project: str,
738
+ command_key: str,
739
+ opt: str,
740
+ projects_raw: dict[str, Any],
741
+ ) -> Any:
742
+ """Look up a project command default from a raw projects dict."""
743
+ proj_data = projects_raw.get(project)
744
+ if not isinstance(proj_data, dict):
745
+ return None
746
+ defaults = proj_data.get("defaults")
747
+ if not isinstance(defaults, dict):
748
+ return None
749
+ cmd_defaults = defaults.get(command_key)
750
+ if not isinstance(cmd_defaults, dict):
751
+ return None
752
+ return cmd_defaults.get(opt)
753
+
754
+
755
+ # ---------------------------------------------------------------------------
756
+ # Project init
757
+ # ---------------------------------------------------------------------------
758
+
759
+
760
+ class InitResult(NamedTuple):
761
+ """Result of init_project_config()."""
762
+
763
+ created: Path
764
+ gitignore_action: str # "added", "already_ignored", "no_git", "created"
765
+
766
+
767
+ def _find_git_root(start: Path) -> Path | None:
768
+ """Walk up from *start* looking for a ``.git`` directory."""
769
+ current = start.resolve()
770
+ while True:
771
+ if (current / ".git").is_dir():
772
+ return current
773
+ parent = current.parent
774
+ if parent == current:
775
+ return None
776
+ current = parent
777
+
778
+
779
+ _GITIGNORE_PATTERNS = {".kd/", ".kd", "/.kd/", "/.kd"}
780
+
781
+
782
+ def _manage_gitignore(directory: Path) -> str:
783
+ """Ensure ``.kd/`` is in ``.gitignore``. Returns action taken."""
784
+ git_root = _find_git_root(directory)
785
+ if git_root is None:
786
+ return "no_git"
787
+
788
+ gitignore = git_root / ".gitignore"
789
+ if gitignore.exists():
790
+ content = gitignore.read_text()
791
+ lines = {line.strip() for line in content.splitlines()}
792
+ if lines & _GITIGNORE_PATTERNS:
793
+ return "already_ignored"
794
+ # Append with a trailing newline if file doesn't end with one
795
+ sep = "" if content.endswith("\n") else "\n"
796
+ gitignore.write_text(f"{content}{sep}\n# kd local config\n.kd/\n")
797
+ return "added"
798
+
799
+ gitignore.write_text("# kd local config\n.kd/\n")
800
+ return "created"
801
+
802
+
803
+ def init_project_config(
804
+ directory: Path | None = None,
805
+ url: str | None = None,
806
+ ) -> InitResult:
807
+ """Scaffold ``.kd/config.yaml`` in *directory* (default: cwd).
808
+
809
+ Loads the config template from package resources, optionally fills
810
+ the URL, and manages ``.gitignore`` for the nearest git root.
811
+
812
+ Raises ConfigError if ``.kd/config.yaml`` already exists.
813
+ """
814
+ import importlib.resources
815
+
816
+ target_dir = (directory or Path.cwd()).resolve()
817
+ config_dir = target_dir / ".kd"
818
+ config_path = config_dir / "config.yaml"
819
+
820
+ if config_path.exists():
821
+ raise ConfigError(f"Config already exists at {config_path}")
822
+
823
+ # Load template from package resources
824
+ template = (
825
+ importlib.resources.files("kd.resources")
826
+ .joinpath("config-template.yaml")
827
+ .read_text(encoding="utf-8")
828
+ )
829
+
830
+ # Pre-fill URL: uncomment connection block and fill URL
831
+ if url:
832
+ template = template.replace("# default_connection: main", "default_connection: main")
833
+ template = template.replace(
834
+ "# connections:\n"
835
+ "# main:\n"
836
+ "# provider: youtrack\n"
837
+ "# url: https://your-instance.youtrack.cloud\n"
838
+ "# # token_cmd: gopass show -o path/to/token\n"
839
+ "# token_env: KD_TOKEN",
840
+ "connections:\n"
841
+ " main:\n"
842
+ " provider: youtrack\n"
843
+ f" url: {url}\n"
844
+ " # token_cmd: gopass show -o path/to/token\n"
845
+ " token_env: KD_TOKEN",
846
+ )
847
+
848
+ config_dir.mkdir(parents=True, exist_ok=True)
849
+ config_path.write_text(template)
850
+
851
+ gitignore_action = _manage_gitignore(target_dir)
852
+
853
+ return InitResult(created=config_path, gitignore_action=gitignore_action)