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.
- kd/__init__.py +45 -0
- kd/api/__init__.py +15 -0
- kd/api/articles.py +232 -0
- kd/api/http.py +234 -0
- kd/api/hub.py +69 -0
- kd/api/saved_queries.py +76 -0
- kd/api/tags.py +107 -0
- kd/api/youtrack.py +414 -0
- kd/article_marker.py +348 -0
- kd/auth.py +96 -0
- kd/cli/__init__.py +5 -0
- kd/cli/core/__init__.py +25 -0
- kd/cli/core/command_base.py +133 -0
- kd/cli/core/command_parser.py +246 -0
- kd/cli/core/namespaces.py +175 -0
- kd/cli/core/option_registry.py +265 -0
- kd/cli/input.py +42 -0
- kd/cli/main.py +612 -0
- kd/cli/namespaces/__init__.py +38 -0
- kd/cli/namespaces/alias/__init__.py +87 -0
- kd/cli/namespaces/article/__init__.py +843 -0
- kd/cli/namespaces/init.py +42 -0
- kd/cli/namespaces/issue/__init__.py +959 -0
- kd/cli/namespaces/primer/__init__.py +337 -0
- kd/cli/namespaces/project/__init__.py +243 -0
- kd/cli/namespaces/query/__init__.py +465 -0
- kd/cli/namespaces/search/__init__.py +82 -0
- kd/cli/namespaces/system/__init__.py +528 -0
- kd/cli/namespaces/tag/__init__.py +337 -0
- kd/cli/namespaces/time/__init__.py +307 -0
- kd/cli/namespaces/user/__init__.py +99 -0
- kd/cli/output.py +287 -0
- kd/cli/tree.py +65 -0
- kd/client.py +1953 -0
- kd/config.py +853 -0
- kd/connection.py +156 -0
- kd/debug.py +107 -0
- kd/exceptions.py +189 -0
- kd/history.py +169 -0
- kd/models/__init__.py +75 -0
- kd/models/article.py +64 -0
- kd/models/assist.py +47 -0
- kd/models/attachment.py +20 -0
- kd/models/comment.py +25 -0
- kd/models/common.py +39 -0
- kd/models/issue.py +67 -0
- kd/models/project.py +60 -0
- kd/models/saved_query.py +54 -0
- kd/models/search.py +20 -0
- kd/models/tag.py +30 -0
- kd/models/user.py +42 -0
- kd/models/work_item.py +59 -0
- kd/primer.py +231 -0
- kd/query_parser.py +181 -0
- kd/resources/__init__.py +1 -0
- kd/resources/command-synonyms.yaml +19 -0
- kd/resources/config-template.yaml +71 -0
- kd/resources/error-messages.yaml +101 -0
- kd_cli-2.0.4.dist-info/METADATA +149 -0
- kd_cli-2.0.4.dist-info/RECORD +64 -0
- kd_cli-2.0.4.dist-info/WHEEL +5 -0
- kd_cli-2.0.4.dist-info/entry_points.txt +2 -0
- kd_cli-2.0.4.dist-info/licenses/LICENSE +21 -0
- 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)
|