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
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
"""System namespace: /system
|
|
2
|
+
|
|
3
|
+
Commands: info, history, restore, diff
|
|
4
|
+
Sub-namespaces: /system/config (list, get, set, which)
|
|
5
|
+
Manages CLI configuration, displays server info, and provides
|
|
6
|
+
copy-on-write history for config and primer files.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import difflib
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from kd.cli.core.command_base import Command, CommandResult
|
|
16
|
+
from kd.cli.core.namespaces import Namespace
|
|
17
|
+
from kd.cli.core.option_registry import OptionDeclaration, OptionLayer, ResolvedOptions
|
|
18
|
+
from kd.config import (
|
|
19
|
+
DEFAULT_CONFIG_PATH,
|
|
20
|
+
find_local_config,
|
|
21
|
+
update_config,
|
|
22
|
+
)
|
|
23
|
+
from kd.exceptions import ConfigError, ValidationError
|
|
24
|
+
from kd.history import (
|
|
25
|
+
SnapshotInfo,
|
|
26
|
+
atomic_write_text,
|
|
27
|
+
diff_text,
|
|
28
|
+
get_snapshot_path,
|
|
29
|
+
list_snapshots,
|
|
30
|
+
snapshot_before_write,
|
|
31
|
+
)
|
|
32
|
+
from kd.primer import PRIMER_FILENAME, require_workspace_dir
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SystemInfoCommand(Command):
|
|
36
|
+
"""Show server info and connection status."""
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
super().__init__(
|
|
40
|
+
"info",
|
|
41
|
+
"Show server info and connection status",
|
|
42
|
+
requires_client=True,
|
|
43
|
+
usage="kd /system info",
|
|
44
|
+
long_description="Connects to the YouTrack instance and displays the current "
|
|
45
|
+
"user, server URL, and connection status. Uses the active connection "
|
|
46
|
+
"(selected via -c or 'default_connection').",
|
|
47
|
+
examples=[
|
|
48
|
+
"kd /system info",
|
|
49
|
+
"kd -c upstream /system info",
|
|
50
|
+
],
|
|
51
|
+
see_also=["/system/config list"],
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def execute(
|
|
55
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
56
|
+
) -> CommandResult:
|
|
57
|
+
me = client._yt_api.get_me()
|
|
58
|
+
base_url = str(client._yt_http._client.base_url).rstrip("/")
|
|
59
|
+
# Strip /api suffix to show the instance URL
|
|
60
|
+
if base_url.endswith("/api"):
|
|
61
|
+
base_url = base_url[:-4]
|
|
62
|
+
return {
|
|
63
|
+
"url": base_url,
|
|
64
|
+
"user": me.get("login", "unknown"),
|
|
65
|
+
"name": me.get("name", ""),
|
|
66
|
+
"email": me.get("email", ""),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ConfigListCommand(Command):
|
|
71
|
+
"""Show all config values with source tracking."""
|
|
72
|
+
|
|
73
|
+
# Flat keys to display (exclude structured sections and connections)
|
|
74
|
+
_FLAT_KEYS = ("default_connection", "output", "limit", "debug")
|
|
75
|
+
|
|
76
|
+
def __init__(self) -> None:
|
|
77
|
+
super().__init__(
|
|
78
|
+
"list",
|
|
79
|
+
"Show all config values and their sources",
|
|
80
|
+
requires_config=True,
|
|
81
|
+
usage="kd /system/config list",
|
|
82
|
+
examples=[
|
|
83
|
+
"kd /system/config list",
|
|
84
|
+
"kd /system/config list --output json",
|
|
85
|
+
],
|
|
86
|
+
see_also=["/system/config get", "/system/config set", "/system/config which"],
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def execute(
|
|
90
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
91
|
+
) -> CommandResult:
|
|
92
|
+
rc = self.config
|
|
93
|
+
rows: list[dict[str, Any]] = []
|
|
94
|
+
|
|
95
|
+
# Flat keys
|
|
96
|
+
for key in self._FLAT_KEYS:
|
|
97
|
+
rv = rc.get(key)
|
|
98
|
+
if rv is None:
|
|
99
|
+
continue
|
|
100
|
+
rows.append({"key": key, "value": rv.value, "source": rv.source, "origin": rv.origin})
|
|
101
|
+
|
|
102
|
+
# Connections
|
|
103
|
+
for conn_name, conn_cfg in rc.connections.items():
|
|
104
|
+
origin = rc.connection_origins.get(conn_name)
|
|
105
|
+
source_label = origin.source if origin else ""
|
|
106
|
+
origin_path = origin.path if origin else ""
|
|
107
|
+
prefix = f"connections.{conn_name}"
|
|
108
|
+
rows.append(
|
|
109
|
+
{
|
|
110
|
+
"key": f"{prefix}.url",
|
|
111
|
+
"value": conn_cfg.url,
|
|
112
|
+
"source": source_label,
|
|
113
|
+
"origin": origin_path,
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
if conn_cfg.default_project:
|
|
117
|
+
rows.append(
|
|
118
|
+
{
|
|
119
|
+
"key": f"{prefix}.default_project",
|
|
120
|
+
"value": conn_cfg.default_project,
|
|
121
|
+
"source": source_label,
|
|
122
|
+
"origin": origin_path,
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Defaults section (local then global, deduped)
|
|
127
|
+
seen_defaults: set[str] = set()
|
|
128
|
+
for source_label, defaults in [
|
|
129
|
+
("local", rc.local_defaults),
|
|
130
|
+
("global", rc.global_defaults),
|
|
131
|
+
]:
|
|
132
|
+
for cmd_key, opts in sorted(defaults.items()):
|
|
133
|
+
for opt_name, opt_val in sorted(opts.items()):
|
|
134
|
+
dotted = f"defaults.{cmd_key}.{opt_name}"
|
|
135
|
+
if dotted in seen_defaults:
|
|
136
|
+
continue
|
|
137
|
+
seen_defaults.add(dotted)
|
|
138
|
+
rows.append(
|
|
139
|
+
{
|
|
140
|
+
"key": dotted,
|
|
141
|
+
"value": str(opt_val),
|
|
142
|
+
"source": source_label,
|
|
143
|
+
"origin": "",
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Projects section (local then global, deduped)
|
|
148
|
+
seen_projects: set[str] = set()
|
|
149
|
+
for source_label, projects in [
|
|
150
|
+
("local", rc.local_projects),
|
|
151
|
+
("global", rc.global_projects),
|
|
152
|
+
]:
|
|
153
|
+
for proj_key, proj_data in sorted(projects.items()):
|
|
154
|
+
if not isinstance(proj_data, dict):
|
|
155
|
+
continue
|
|
156
|
+
# Field aliases
|
|
157
|
+
aliases = proj_data.get("field_aliases", {})
|
|
158
|
+
if isinstance(aliases, dict):
|
|
159
|
+
for alias_name, alias_val in sorted(aliases.items()):
|
|
160
|
+
dotted = f"projects.{proj_key}.field_aliases.{alias_name}"
|
|
161
|
+
if dotted in seen_projects:
|
|
162
|
+
continue
|
|
163
|
+
seen_projects.add(dotted)
|
|
164
|
+
rows.append(
|
|
165
|
+
{
|
|
166
|
+
"key": dotted,
|
|
167
|
+
"value": str(alias_val),
|
|
168
|
+
"source": source_label,
|
|
169
|
+
"origin": "",
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
# Project defaults
|
|
173
|
+
proj_defaults = proj_data.get("defaults", {})
|
|
174
|
+
if isinstance(proj_defaults, dict):
|
|
175
|
+
for cmd_key, opts in sorted(proj_defaults.items()):
|
|
176
|
+
if not isinstance(opts, dict):
|
|
177
|
+
continue
|
|
178
|
+
for opt_name, opt_val in sorted(opts.items()):
|
|
179
|
+
dotted = f"projects.{proj_key}.defaults.{cmd_key}.{opt_name}"
|
|
180
|
+
if dotted in seen_projects:
|
|
181
|
+
continue
|
|
182
|
+
seen_projects.add(dotted)
|
|
183
|
+
rows.append(
|
|
184
|
+
{
|
|
185
|
+
"key": dotted,
|
|
186
|
+
"value": str(opt_val),
|
|
187
|
+
"source": source_label,
|
|
188
|
+
"origin": "",
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Aliases section (local then global, deduped)
|
|
193
|
+
seen_aliases: set[str] = set()
|
|
194
|
+
for source_label, aliases in [
|
|
195
|
+
("local", rc.local_aliases),
|
|
196
|
+
("global", rc.global_aliases),
|
|
197
|
+
]:
|
|
198
|
+
for alias_name, alias_cmd in sorted(aliases.items()):
|
|
199
|
+
dotted = f"aliases.{alias_name}"
|
|
200
|
+
if dotted in seen_aliases:
|
|
201
|
+
continue
|
|
202
|
+
seen_aliases.add(dotted)
|
|
203
|
+
rows.append(
|
|
204
|
+
{
|
|
205
|
+
"key": dotted,
|
|
206
|
+
"value": alias_cmd,
|
|
207
|
+
"source": source_label,
|
|
208
|
+
"origin": "",
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
return rows
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class ConfigGetCommand(Command):
|
|
216
|
+
"""Get a config value."""
|
|
217
|
+
|
|
218
|
+
def __init__(self) -> None:
|
|
219
|
+
super().__init__(
|
|
220
|
+
"get",
|
|
221
|
+
"Get a config value and its source",
|
|
222
|
+
requires_config=True,
|
|
223
|
+
usage="kd /system/config get KEY",
|
|
224
|
+
long_description="Valid keys: default_connection, output, limit, debug.",
|
|
225
|
+
examples=[
|
|
226
|
+
"kd /system/config get default_connection",
|
|
227
|
+
"kd /system/config get output",
|
|
228
|
+
],
|
|
229
|
+
see_also=["/system/config set", "/system/config list"],
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def execute(
|
|
233
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
234
|
+
) -> CommandResult:
|
|
235
|
+
if not args:
|
|
236
|
+
raise ValidationError("Usage: kd /system/config get KEY")
|
|
237
|
+
key = args[0]
|
|
238
|
+
flat_keys = ConfigListCommand._FLAT_KEYS
|
|
239
|
+
if key not in flat_keys:
|
|
240
|
+
valid_keys = ", ".join(flat_keys)
|
|
241
|
+
raise ConfigError(f"Unknown config key: {key}. Valid keys: {valid_keys}")
|
|
242
|
+
rc = self.config
|
|
243
|
+
rv = rc.get(key)
|
|
244
|
+
if rv is None:
|
|
245
|
+
return {key: None}
|
|
246
|
+
return {"key": key, "value": rv.value, "source": str(rv.source), "origin": rv.origin}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class ConfigSetCommand(Command):
|
|
250
|
+
"""Set a config value."""
|
|
251
|
+
|
|
252
|
+
def __init__(self) -> None:
|
|
253
|
+
super().__init__(
|
|
254
|
+
"set",
|
|
255
|
+
"Set a config value",
|
|
256
|
+
usage="kd /system/config set KEY VALUE [--local | --global]",
|
|
257
|
+
long_description="Writes to local .kd/config.yaml if it exists, "
|
|
258
|
+
"otherwise to ~/.config/kd/config.yaml. "
|
|
259
|
+
"Use --local or --global to force target."
|
|
260
|
+
"\n\n"
|
|
261
|
+
"Keys:\n"
|
|
262
|
+
" default_connection Active connection name\n"
|
|
263
|
+
" output Output format: table, json, yaml, md\n"
|
|
264
|
+
" limit Result limit for list commands (default: 50)\n"
|
|
265
|
+
" debug Enable debug output: true/false (default: false)",
|
|
266
|
+
examples=[
|
|
267
|
+
"kd /system/config set default_connection corp",
|
|
268
|
+
"kd /system/config set output json --local",
|
|
269
|
+
],
|
|
270
|
+
see_also=["/system/config get", "/system/config list"],
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def _register_options(self) -> None:
|
|
274
|
+
self.register_option(
|
|
275
|
+
OptionDeclaration(
|
|
276
|
+
name="local",
|
|
277
|
+
layer=OptionLayer.COMMAND,
|
|
278
|
+
type=bool,
|
|
279
|
+
help_text="Write to local .kd/config.yaml",
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
self.register_option(
|
|
283
|
+
OptionDeclaration(
|
|
284
|
+
name="global",
|
|
285
|
+
layer=OptionLayer.COMMAND,
|
|
286
|
+
type=bool,
|
|
287
|
+
help_text="Write to global ~/.config/kd/config.yaml",
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def execute(
|
|
292
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
293
|
+
) -> CommandResult:
|
|
294
|
+
if len(args) < 2:
|
|
295
|
+
raise ValidationError("Usage: kd /system/config set KEY VALUE")
|
|
296
|
+
key = args[0]
|
|
297
|
+
value = " ".join(args[1:])
|
|
298
|
+
|
|
299
|
+
use_local = options.command.get("local", False)
|
|
300
|
+
use_global = options.command.get("global", False)
|
|
301
|
+
if use_local and use_global:
|
|
302
|
+
raise ValidationError("Cannot use both --local and --global")
|
|
303
|
+
|
|
304
|
+
if use_local:
|
|
305
|
+
local_path = find_local_config()
|
|
306
|
+
if local_path is None:
|
|
307
|
+
raise ConfigError("No local config found. Run 'kd init' first.")
|
|
308
|
+
target = local_path
|
|
309
|
+
elif use_global:
|
|
310
|
+
target = DEFAULT_CONFIG_PATH
|
|
311
|
+
else:
|
|
312
|
+
# Default: local if it exists, otherwise global
|
|
313
|
+
target = find_local_config() or DEFAULT_CONFIG_PATH
|
|
314
|
+
|
|
315
|
+
update_config(key, value, target)
|
|
316
|
+
return {"ok": True, "action": "set", "key": key, "value": value, "file": str(target)}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class ConfigWhichCommand(Command):
|
|
320
|
+
"""Show which config files are active."""
|
|
321
|
+
|
|
322
|
+
def __init__(self) -> None:
|
|
323
|
+
super().__init__(
|
|
324
|
+
"which",
|
|
325
|
+
"Show active config file paths",
|
|
326
|
+
usage="kd /system/config which",
|
|
327
|
+
examples=["kd /system/config which"],
|
|
328
|
+
see_also=["/system/config list"],
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
def execute(
|
|
332
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
333
|
+
) -> CommandResult:
|
|
334
|
+
local_path = find_local_config()
|
|
335
|
+
result: dict[str, Any] = {
|
|
336
|
+
"global": str(DEFAULT_CONFIG_PATH),
|
|
337
|
+
"global_exists": DEFAULT_CONFIG_PATH.exists(),
|
|
338
|
+
}
|
|
339
|
+
if local_path:
|
|
340
|
+
result["local"] = str(local_path)
|
|
341
|
+
result["local_exists"] = True
|
|
342
|
+
else:
|
|
343
|
+
result["local"] = None
|
|
344
|
+
result["local_exists"] = False
|
|
345
|
+
return result
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# ---------------------------------------------------------------------------
|
|
349
|
+
# History helpers
|
|
350
|
+
# ---------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
_VALID_TARGETS = ("config", "primer")
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _resolve_target(name: str) -> tuple[Path, Path, str]:
|
|
356
|
+
"""Resolve a history target name to (live_path, history_dir, filename).
|
|
357
|
+
|
|
358
|
+
Raises ``ValidationError`` for unknown target names and ``ConfigError``
|
|
359
|
+
when the required ``.kd/`` directory is missing.
|
|
360
|
+
"""
|
|
361
|
+
if name == "config":
|
|
362
|
+
live = find_local_config()
|
|
363
|
+
if live is None:
|
|
364
|
+
raise ConfigError("No local config found. Run 'kd init' first.")
|
|
365
|
+
return live, live.parent / "history", live.name
|
|
366
|
+
if name == "primer":
|
|
367
|
+
workspace_dir = require_workspace_dir()
|
|
368
|
+
live = workspace_dir / PRIMER_FILENAME
|
|
369
|
+
return live, workspace_dir / "history", PRIMER_FILENAME
|
|
370
|
+
raise ValidationError(f"Unknown target: {name}. Valid targets: {', '.join(_VALID_TARGETS)}")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _diff_summary(snapshot: SnapshotInfo, current_text: str) -> str:
|
|
374
|
+
"""One-line diff summary: ``+N -M`` lines vs current file."""
|
|
375
|
+
snap_lines = snapshot.path.read_text().splitlines()
|
|
376
|
+
curr_lines = current_text.splitlines()
|
|
377
|
+
added = removed = 0
|
|
378
|
+
for line in difflib.unified_diff(snap_lines, curr_lines, n=0):
|
|
379
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
380
|
+
added += 1
|
|
381
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
382
|
+
removed += 1
|
|
383
|
+
return f"+{added} -{removed}"
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ---------------------------------------------------------------------------
|
|
387
|
+
# History commands
|
|
388
|
+
# ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class SystemHistoryCommand(Command):
|
|
392
|
+
"""List copy-on-write snapshots for config or primer."""
|
|
393
|
+
|
|
394
|
+
def __init__(self) -> None:
|
|
395
|
+
super().__init__(
|
|
396
|
+
"history",
|
|
397
|
+
"List config/primer snapshots",
|
|
398
|
+
usage="kd /system history [config|primer]",
|
|
399
|
+
long_description="Lists copy-on-write snapshots for config, primer, or both "
|
|
400
|
+
"(default). Each entry shows the timestamp, file size, and a summary of lines "
|
|
401
|
+
"changed relative to the current file.",
|
|
402
|
+
examples=[
|
|
403
|
+
"kd /system history",
|
|
404
|
+
"kd /system history config",
|
|
405
|
+
"kd /system history primer",
|
|
406
|
+
],
|
|
407
|
+
see_also=["/system restore", "/system diff"],
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
def execute(
|
|
411
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
412
|
+
) -> CommandResult:
|
|
413
|
+
targets = [args[0]] if args else list(_VALID_TARGETS)
|
|
414
|
+
rows: list[dict[str, Any]] = []
|
|
415
|
+
for target_name in targets:
|
|
416
|
+
live_path, history_dir, filename = _resolve_target(target_name)
|
|
417
|
+
current_text = live_path.read_text() if live_path.exists() else ""
|
|
418
|
+
for snap in list_snapshots(history_dir, filename):
|
|
419
|
+
rows.append(
|
|
420
|
+
{
|
|
421
|
+
"file": target_name,
|
|
422
|
+
"timestamp": snap.timestamp,
|
|
423
|
+
"size": snap.size,
|
|
424
|
+
"changes": _diff_summary(snap, current_text),
|
|
425
|
+
}
|
|
426
|
+
)
|
|
427
|
+
return rows
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class SystemRestoreCommand(Command):
|
|
431
|
+
"""Restore a config or primer file from a snapshot."""
|
|
432
|
+
|
|
433
|
+
def __init__(self) -> None:
|
|
434
|
+
super().__init__(
|
|
435
|
+
"restore",
|
|
436
|
+
"Restore config/primer from a snapshot",
|
|
437
|
+
usage="kd /system restore <config|primer> <timestamp>",
|
|
438
|
+
long_description="Replaces the live file with the specified snapshot. "
|
|
439
|
+
"The current file is snapshotted first, so restore is itself undoable.",
|
|
440
|
+
examples=[
|
|
441
|
+
"kd /system restore config 2026-04-09T10-30-00",
|
|
442
|
+
"kd /system restore primer 2026-04-09T14-20-00",
|
|
443
|
+
],
|
|
444
|
+
see_also=["/system history", "/system diff"],
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
def execute(
|
|
448
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
449
|
+
) -> CommandResult:
|
|
450
|
+
if len(args) < 2:
|
|
451
|
+
raise ValidationError("Usage: kd /system restore <config|primer> <timestamp>")
|
|
452
|
+
target_name, timestamp = args[0], args[1]
|
|
453
|
+
live_path, history_dir, filename = _resolve_target(target_name)
|
|
454
|
+
snap_path = get_snapshot_path(history_dir, filename, timestamp)
|
|
455
|
+
# Snapshot current before restoring (so restore is undoable)
|
|
456
|
+
snapshot_before_write(live_path)
|
|
457
|
+
restored_text = snap_path.read_text()
|
|
458
|
+
atomic_write_text(live_path, restored_text)
|
|
459
|
+
return {
|
|
460
|
+
"ok": True,
|
|
461
|
+
"action": "restore",
|
|
462
|
+
"target": target_name,
|
|
463
|
+
"timestamp": timestamp,
|
|
464
|
+
"file": str(live_path),
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
class SystemDiffCommand(Command):
|
|
469
|
+
"""Show a unified diff for a config or primer snapshot."""
|
|
470
|
+
|
|
471
|
+
def __init__(self) -> None:
|
|
472
|
+
super().__init__(
|
|
473
|
+
"diff",
|
|
474
|
+
"Diff config/primer snapshots",
|
|
475
|
+
usage="kd /system diff <config|primer> <timestamp> [<timestamp>]",
|
|
476
|
+
long_description="With one timestamp, shows the diff between the snapshot "
|
|
477
|
+
"and the current file. With two timestamps, diffs the two snapshots.",
|
|
478
|
+
examples=[
|
|
479
|
+
"kd /system diff config 2026-04-09T10-30-00",
|
|
480
|
+
"kd /system diff primer 2026-04-09T10-00-00 2026-04-09T14-00-00",
|
|
481
|
+
],
|
|
482
|
+
see_also=["/system history", "/system restore"],
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
def execute(
|
|
486
|
+
self, args: list[str], options: ResolvedOptions, client: Any = None
|
|
487
|
+
) -> CommandResult:
|
|
488
|
+
if len(args) < 2:
|
|
489
|
+
raise ValidationError(
|
|
490
|
+
"Usage: kd /system diff <config|primer> <timestamp> [<timestamp>]"
|
|
491
|
+
)
|
|
492
|
+
target_name = args[0]
|
|
493
|
+
live_path, history_dir, filename = _resolve_target(target_name)
|
|
494
|
+
|
|
495
|
+
snap_a = get_snapshot_path(history_dir, filename, args[1])
|
|
496
|
+
text_a = snap_a.read_text()
|
|
497
|
+
label_a = f"{filename}.{args[1]}"
|
|
498
|
+
|
|
499
|
+
if len(args) >= 3:
|
|
500
|
+
snap_b = get_snapshot_path(history_dir, filename, args[2])
|
|
501
|
+
text_b = snap_b.read_text()
|
|
502
|
+
label_b = f"{filename}.{args[2]}"
|
|
503
|
+
else:
|
|
504
|
+
text_b = live_path.read_text() if live_path.exists() else ""
|
|
505
|
+
label_b = filename
|
|
506
|
+
|
|
507
|
+
result = diff_text(text_a, text_b, label_a, label_b)
|
|
508
|
+
if not result:
|
|
509
|
+
return "No differences."
|
|
510
|
+
return result
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def create_system_namespace() -> Namespace:
|
|
514
|
+
"""Build the /system namespace tree."""
|
|
515
|
+
system_ns = Namespace("system", "System management and configuration", path="system")
|
|
516
|
+
system_ns.register_command("info", SystemInfoCommand())
|
|
517
|
+
system_ns.register_command("history", SystemHistoryCommand())
|
|
518
|
+
system_ns.register_command("restore", SystemRestoreCommand())
|
|
519
|
+
system_ns.register_command("diff", SystemDiffCommand())
|
|
520
|
+
|
|
521
|
+
config_ns = Namespace("config", "CLI configuration", path="system/config")
|
|
522
|
+
config_ns.register_command("list", ConfigListCommand())
|
|
523
|
+
config_ns.register_command("get", ConfigGetCommand())
|
|
524
|
+
config_ns.register_command("set", ConfigSetCommand())
|
|
525
|
+
config_ns.register_command("which", ConfigWhichCommand())
|
|
526
|
+
|
|
527
|
+
system_ns.register_sub_namespace(config_ns)
|
|
528
|
+
return system_ns
|