contree-cli 0.2.3__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.
- contree_cli/__init__.py +27 -0
- contree_cli/__main__.py +62 -0
- contree_cli/arguments.py +141 -0
- contree_cli/cli/__init__.py +0 -0
- contree_cli/cli/auth.py +124 -0
- contree_cli/cli/cat.py +74 -0
- contree_cli/cli/cd.py +49 -0
- contree_cli/cli/cp.py +107 -0
- contree_cli/cli/file.py +179 -0
- contree_cli/cli/images.py +88 -0
- contree_cli/cli/kill.py +86 -0
- contree_cli/cli/ls.py +83 -0
- contree_cli/cli/ps.py +120 -0
- contree_cli/cli/run.py +438 -0
- contree_cli/cli/session.py +282 -0
- contree_cli/cli/show.py +97 -0
- contree_cli/cli/tag.py +50 -0
- contree_cli/cli/use.py +119 -0
- contree_cli/client.py +222 -0
- contree_cli/config.py +116 -0
- contree_cli/log.py +40 -0
- contree_cli/mapped_file.py +112 -0
- contree_cli/output.py +376 -0
- contree_cli/session.py +761 -0
- contree_cli/shell/__init__.py +59 -0
- contree_cli/shell/completer.py +465 -0
- contree_cli/shell/history.py +53 -0
- contree_cli/shell/parser.py +107 -0
- contree_cli/shell/repl.py +486 -0
- contree_cli/shell/trie.py +113 -0
- contree_cli/types.py +87 -0
- contree_cli-0.2.3.dist-info/METADATA +323 -0
- contree_cli-0.2.3.dist-info/RECORD +35 -0
- contree_cli-0.2.3.dist-info/WHEEL +4 -0
- contree_cli-0.2.3.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Start an interactive shell session.
|
|
2
|
+
|
|
3
|
+
Launches a REPL where bare commands (e.g. `apt install curl`) are
|
|
4
|
+
executed in the session container via `run --shell`, and prefixed
|
|
5
|
+
commands (e.g. `contree ls /etc`) are dispatched as management
|
|
6
|
+
commands.
|
|
7
|
+
|
|
8
|
+
Built-in commands: cd, pwd, history, help, exit/quit.
|
|
9
|
+
Tab completion for commands, flags, image paths, tags, and branches.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from contree_cli import CLIENT, SESSION_STORE, ArgumentsProtocol, SetupResult
|
|
17
|
+
from contree_cli.shell.completer import ShellCompleter
|
|
18
|
+
from contree_cli.shell.history import load_history, save_history
|
|
19
|
+
from contree_cli.shell.parser import build_shell_parser
|
|
20
|
+
from contree_cli.shell.repl import ContreeShell
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ShellArgs(ArgumentsProtocol):
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_args(cls, ns: argparse.Namespace) -> ShellArgs:
|
|
27
|
+
return cls()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
|
|
31
|
+
"""Register 'contree shell' subcommand."""
|
|
32
|
+
return cmd_shell, ShellArgs
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cmd_shell(_args: ShellArgs) -> int | None:
|
|
36
|
+
"""Build parser + completer, start REPL."""
|
|
37
|
+
parser, commands = build_shell_parser()
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
client = CLIENT.get()
|
|
41
|
+
except LookupError:
|
|
42
|
+
client = None
|
|
43
|
+
try:
|
|
44
|
+
store = SESSION_STORE.get()
|
|
45
|
+
except LookupError:
|
|
46
|
+
store = None
|
|
47
|
+
|
|
48
|
+
completer = ShellCompleter(commands, client=client, store=store, root_parser=parser)
|
|
49
|
+
shell = ContreeShell(parser, completer)
|
|
50
|
+
|
|
51
|
+
if store is not None:
|
|
52
|
+
load_history(store)
|
|
53
|
+
try:
|
|
54
|
+
shell.run()
|
|
55
|
+
finally:
|
|
56
|
+
if store is not None:
|
|
57
|
+
save_history(store)
|
|
58
|
+
|
|
59
|
+
return None
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
"""Tab completion for the interactive shell."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import shlex
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from contree_cli.output import FORMATTERS
|
|
11
|
+
from contree_cli.shell.parser import CommandInfo, ShellArgumentParser, get_command_names
|
|
12
|
+
from contree_cli.shell.trie import Handler, PrefixRouter
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from contree_cli.client import ContreeClient
|
|
16
|
+
from contree_cli.session import ImageCache, SessionStore
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ShellCompleter:
|
|
22
|
+
"""Readline completer with PrefixRouter-based dispatch."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
commands: dict[str, CommandInfo],
|
|
27
|
+
client: ContreeClient | None = None,
|
|
28
|
+
store: SessionStore | None = None,
|
|
29
|
+
root_parser: ShellArgumentParser | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self._commands = commands
|
|
32
|
+
self._command_names = get_command_names()
|
|
33
|
+
self._client = client
|
|
34
|
+
self._store = store
|
|
35
|
+
self._root_parser = root_parser
|
|
36
|
+
self._matches: list[str] = []
|
|
37
|
+
|
|
38
|
+
self.router: PrefixRouter = PrefixRouter()
|
|
39
|
+
self._build_router()
|
|
40
|
+
|
|
41
|
+
def _build_router(self) -> None:
|
|
42
|
+
r = self.router
|
|
43
|
+
|
|
44
|
+
# Shell builtins with no argument completion
|
|
45
|
+
for name in ("exit", "quit", "pwd", "history", "clear"):
|
|
46
|
+
r[(name,)] = self._complete_noop
|
|
47
|
+
|
|
48
|
+
r[("help",)] = self._complete_help_names
|
|
49
|
+
r[("cd",)] = self._complete_dir_only
|
|
50
|
+
|
|
51
|
+
# Editors → container path
|
|
52
|
+
for name in ("vim", "vi", "nano"):
|
|
53
|
+
r[(name,)] = self._complete_container_path
|
|
54
|
+
|
|
55
|
+
# Bare aliases → container path (same as contree ls/cat)
|
|
56
|
+
r[("ls",)] = self._complete_container_path
|
|
57
|
+
r[("cat",)] = self._complete_container_path
|
|
58
|
+
|
|
59
|
+
# Contree subcommand argument completers
|
|
60
|
+
arg_map: dict[str, Handler] = {
|
|
61
|
+
"ls": self._complete_container_path,
|
|
62
|
+
"cat": self._complete_container_path,
|
|
63
|
+
"cp": self._complete_container_path,
|
|
64
|
+
"cd": self._complete_dir_only,
|
|
65
|
+
"use": self._complete_image,
|
|
66
|
+
"tag": self._complete_image,
|
|
67
|
+
"show": self._complete_operation,
|
|
68
|
+
"kill": self._complete_operation,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# Register all contree commands (names + aliases)
|
|
72
|
+
for name in self._command_names:
|
|
73
|
+
handler = arg_map.get(name, self._complete_noop)
|
|
74
|
+
r[("contree", name)] = handler
|
|
75
|
+
|
|
76
|
+
# Register subparser children so subcommand name completion works.
|
|
77
|
+
# E.g. "contree file <TAB>" should show "edit", "cp", etc.
|
|
78
|
+
for name, info in self._commands.items():
|
|
79
|
+
for sub_name in self._get_subcommand_names(info.parser):
|
|
80
|
+
key = ("contree", name, sub_name)
|
|
81
|
+
if key not in r:
|
|
82
|
+
r[key] = self._complete_noop
|
|
83
|
+
|
|
84
|
+
# --format / -f — complete format names
|
|
85
|
+
r[("--format",)] = self._complete_format_name
|
|
86
|
+
r[("-f",)] = self._complete_format_name
|
|
87
|
+
|
|
88
|
+
# Nested subcommands with specific completers
|
|
89
|
+
r[("contree", "session", "use")] = self._complete_session_name
|
|
90
|
+
r[("contree", "session", "checkout")] = self._complete_branch
|
|
91
|
+
r[("contree", "session", "co")] = self._complete_branch
|
|
92
|
+
r[("contree", "session", "branch")] = self._complete_branch
|
|
93
|
+
r[("contree", "session", "br")] = self._complete_branch
|
|
94
|
+
r[("contree", "file", "edit")] = self._complete_container_path
|
|
95
|
+
r[("contree", "file", "e")] = self._complete_container_path
|
|
96
|
+
|
|
97
|
+
def complete(self, text: str, state: int) -> str | None:
|
|
98
|
+
"""Readline completer function (called repeatedly with state=0,1,...)."""
|
|
99
|
+
if state == 0:
|
|
100
|
+
try:
|
|
101
|
+
import readline
|
|
102
|
+
line = readline.get_line_buffer()
|
|
103
|
+
begidx = readline.get_begidx()
|
|
104
|
+
except (ImportError, AttributeError):
|
|
105
|
+
line = text
|
|
106
|
+
begidx = 0
|
|
107
|
+
self._matches = self.compute_completions(text, line, begidx)
|
|
108
|
+
if state < len(self._matches):
|
|
109
|
+
return self._matches[state]
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
def compute_completions(
|
|
113
|
+
self, text: str, line: str, begidx: int,
|
|
114
|
+
) -> list[str]:
|
|
115
|
+
"""Determine context and return matching completions."""
|
|
116
|
+
before_cursor = line[:begidx]
|
|
117
|
+
try:
|
|
118
|
+
tokens = shlex.split(before_cursor)
|
|
119
|
+
except ValueError:
|
|
120
|
+
return []
|
|
121
|
+
|
|
122
|
+
# No tokens yet → root child names
|
|
123
|
+
if not tokens:
|
|
124
|
+
return [
|
|
125
|
+
n + " " for n in self.router.children
|
|
126
|
+
if n.startswith(text)
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
node, depth = self.router.resolve(tuple(tokens))
|
|
130
|
+
remaining = tuple(tokens[depth:])
|
|
131
|
+
|
|
132
|
+
# Flag-value completion: -f/--format <TAB> → format names
|
|
133
|
+
if tokens and tokens[-1] in ("-f", "--format"):
|
|
134
|
+
return self._complete_format_name((), text)
|
|
135
|
+
|
|
136
|
+
# Flags: look up the parser from commands dict
|
|
137
|
+
if text.startswith("-"):
|
|
138
|
+
parser = self._find_parser(tokens)
|
|
139
|
+
if parser is not None:
|
|
140
|
+
return self._complete_flags(parser, text)
|
|
141
|
+
|
|
142
|
+
# Children → subcommand name completion
|
|
143
|
+
if node.children:
|
|
144
|
+
matches = [
|
|
145
|
+
n + " " for n in node.children
|
|
146
|
+
if n.startswith(text)
|
|
147
|
+
]
|
|
148
|
+
if matches:
|
|
149
|
+
return matches
|
|
150
|
+
|
|
151
|
+
# Handler with remaining tokens
|
|
152
|
+
if node.value is not None:
|
|
153
|
+
return node.value(remaining, text)
|
|
154
|
+
|
|
155
|
+
# Fallback: implicit run mode — path-like text gets path completion,
|
|
156
|
+
# anything else gets root command names as suggestions.
|
|
157
|
+
if self._looks_like_path(text):
|
|
158
|
+
return self._complete_container_path((), text)
|
|
159
|
+
|
|
160
|
+
return [
|
|
161
|
+
n + " " for n in self.router.children
|
|
162
|
+
if n.startswith(text)
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
# ------------------------------------------------------------------
|
|
166
|
+
# Parser lookup for flag completion
|
|
167
|
+
# ------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def _find_parser(
|
|
170
|
+
self, tokens: list[str],
|
|
171
|
+
) -> argparse.ArgumentParser | None:
|
|
172
|
+
"""Find the argparse parser for the current command context."""
|
|
173
|
+
# Strip "contree" prefix if present
|
|
174
|
+
cmd_tokens = tokens[1:] if tokens and tokens[0] == "contree" else tokens
|
|
175
|
+
if not cmd_tokens:
|
|
176
|
+
return None
|
|
177
|
+
cmd_name = cmd_tokens[0]
|
|
178
|
+
if cmd_name not in self._commands:
|
|
179
|
+
return None
|
|
180
|
+
return self._commands[cmd_name].parser
|
|
181
|
+
|
|
182
|
+
# ------------------------------------------------------------------
|
|
183
|
+
# Completion handlers (remaining, text) -> list[str]
|
|
184
|
+
# ------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def _complete_noop(
|
|
187
|
+
self, remaining: tuple[str, ...], text: str,
|
|
188
|
+
) -> list[str]:
|
|
189
|
+
return []
|
|
190
|
+
|
|
191
|
+
def _complete_help_names(
|
|
192
|
+
self, remaining: tuple[str, ...], text: str,
|
|
193
|
+
) -> list[str]:
|
|
194
|
+
"""``help <name>`` — all known command / alias / shell names."""
|
|
195
|
+
all_names = sorted({*self._command_names, *self.router.children})
|
|
196
|
+
return [n + " " for n in all_names if n.startswith(text)]
|
|
197
|
+
|
|
198
|
+
def _complete_format_name(
|
|
199
|
+
self, remaining: tuple[str, ...], text: str,
|
|
200
|
+
) -> list[str]:
|
|
201
|
+
"""Complete output format names (``--format json``, ``-f table``)."""
|
|
202
|
+
return [n + " " for n in sorted(FORMATTERS) if n.startswith(text)]
|
|
203
|
+
|
|
204
|
+
def _complete_dir_only(
|
|
205
|
+
self, remaining: tuple[str, ...], text: str,
|
|
206
|
+
) -> list[str]:
|
|
207
|
+
"""Complete container directory paths (no files)."""
|
|
208
|
+
return self._complete_container_path_inner(text, dirs_only=True)
|
|
209
|
+
|
|
210
|
+
def _complete_container_path(
|
|
211
|
+
self, remaining: tuple[str, ...], text: str,
|
|
212
|
+
) -> list[str]:
|
|
213
|
+
"""Complete a container file/directory path."""
|
|
214
|
+
return self._complete_container_path_inner(text)
|
|
215
|
+
|
|
216
|
+
@staticmethod
|
|
217
|
+
def _looks_like_path(text: str) -> bool:
|
|
218
|
+
"""Return True when *text* looks like a filesystem path."""
|
|
219
|
+
return (
|
|
220
|
+
"/" in text
|
|
221
|
+
or text.startswith(".")
|
|
222
|
+
or text.startswith("~")
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# ------------------------------------------------------------------
|
|
226
|
+
# Low-level helpers
|
|
227
|
+
# ------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _get_subcommand_names(
|
|
231
|
+
parser: argparse.ArgumentParser,
|
|
232
|
+
) -> list[str]:
|
|
233
|
+
"""Extract subcommand names from a parser (if it has subparsers)."""
|
|
234
|
+
for action in parser._actions:
|
|
235
|
+
if isinstance(action, argparse._SubParsersAction):
|
|
236
|
+
return list(action.choices.keys())
|
|
237
|
+
return []
|
|
238
|
+
|
|
239
|
+
def _complete_flags(
|
|
240
|
+
self, parser: argparse.ArgumentParser, text: str,
|
|
241
|
+
) -> list[str]:
|
|
242
|
+
"""Complete flag names from parser actions."""
|
|
243
|
+
flags: list[str] = []
|
|
244
|
+
for action in parser._actions:
|
|
245
|
+
for opt in action.option_strings:
|
|
246
|
+
if opt.startswith(text):
|
|
247
|
+
flags.append(opt + " ")
|
|
248
|
+
return flags
|
|
249
|
+
|
|
250
|
+
def _complete_container_path_inner(
|
|
251
|
+
self, text: str, *, dirs_only: bool = False,
|
|
252
|
+
) -> list[str]:
|
|
253
|
+
"""Complete a container file path via the inspect API."""
|
|
254
|
+
if self._client is None or self._store is None:
|
|
255
|
+
return []
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
session = self._store.session
|
|
259
|
+
if session is None:
|
|
260
|
+
return []
|
|
261
|
+
image_uuid = session.current_image
|
|
262
|
+
except (SystemExit, Exception):
|
|
263
|
+
return []
|
|
264
|
+
|
|
265
|
+
# Split into directory + prefix for the API query.
|
|
266
|
+
# resolve_path handles cwd joining and .. normalisation.
|
|
267
|
+
if "/" in text:
|
|
268
|
+
last_slash = text.rindex("/")
|
|
269
|
+
user_dir = text[: last_slash + 1] or "/"
|
|
270
|
+
prefix = text[last_slash + 1 :]
|
|
271
|
+
resolved = self._store.resolve_path(user_dir)
|
|
272
|
+
api_dir = resolved if resolved == "/" else resolved + "/"
|
|
273
|
+
else:
|
|
274
|
+
user_dir = ""
|
|
275
|
+
prefix = text
|
|
276
|
+
resolved = self._store.resolve_path("")
|
|
277
|
+
api_dir = resolved if resolved == "/" else resolved + "/"
|
|
278
|
+
|
|
279
|
+
entries = self._list_dir(image_uuid, api_dir)
|
|
280
|
+
if entries is None:
|
|
281
|
+
return []
|
|
282
|
+
|
|
283
|
+
results: list[str] = []
|
|
284
|
+
for entry in entries:
|
|
285
|
+
path = entry.get("path", "")
|
|
286
|
+
if not isinstance(path, str) or not path:
|
|
287
|
+
continue
|
|
288
|
+
is_dir = bool(entry.get("is_dir"))
|
|
289
|
+
if dirs_only and not is_dir:
|
|
290
|
+
continue
|
|
291
|
+
# API returns full paths like "/etc/hosts" — extract basename
|
|
292
|
+
name = path.rsplit("/", 1)[-1]
|
|
293
|
+
if not name.startswith(prefix):
|
|
294
|
+
continue
|
|
295
|
+
# Return the path as the user typed it (relative or absolute)
|
|
296
|
+
full = user_dir + name
|
|
297
|
+
if is_dir:
|
|
298
|
+
full += "/"
|
|
299
|
+
else:
|
|
300
|
+
full += " "
|
|
301
|
+
results.append(full)
|
|
302
|
+
return results
|
|
303
|
+
|
|
304
|
+
def _complete_image(
|
|
305
|
+
self, remaining: tuple[str, ...], text: str,
|
|
306
|
+
) -> list[str]:
|
|
307
|
+
"""Complete image references (``tag:NAME`` or UUID).
|
|
308
|
+
|
|
309
|
+
When the user types a ``tag:`` prefix, matches are filtered
|
|
310
|
+
against the full ``tag:NAME`` candidate. Otherwise bare text
|
|
311
|
+
is matched against tag names directly and the completion
|
|
312
|
+
inserts the ``tag:`` prefix for the user.
|
|
313
|
+
"""
|
|
314
|
+
images = self._list_images()
|
|
315
|
+
if images is None:
|
|
316
|
+
return []
|
|
317
|
+
|
|
318
|
+
results: list[str] = []
|
|
319
|
+
for img in images:
|
|
320
|
+
tag = img.get("tag")
|
|
321
|
+
if isinstance(tag, str) and tag:
|
|
322
|
+
prefixed = f"tag:{tag}"
|
|
323
|
+
if text.startswith("tag:"):
|
|
324
|
+
if prefixed.startswith(text):
|
|
325
|
+
results.append(prefixed + " ")
|
|
326
|
+
elif tag.startswith(text):
|
|
327
|
+
results.append(prefixed + " ")
|
|
328
|
+
uuid_str = img.get("uuid")
|
|
329
|
+
if isinstance(uuid_str, str) and uuid_str.startswith(text):
|
|
330
|
+
results.append(uuid_str + " ")
|
|
331
|
+
return results
|
|
332
|
+
|
|
333
|
+
def _complete_operation(
|
|
334
|
+
self, remaining: tuple[str, ...], text: str,
|
|
335
|
+
) -> list[str]:
|
|
336
|
+
"""Complete operation UUIDs for ``show`` and ``kill``."""
|
|
337
|
+
ops = self._list_operations()
|
|
338
|
+
if ops is None:
|
|
339
|
+
return []
|
|
340
|
+
results: list[str] = []
|
|
341
|
+
for op in ops:
|
|
342
|
+
uuid_str = op.get("uuid")
|
|
343
|
+
if isinstance(uuid_str, str) and uuid_str.startswith(text):
|
|
344
|
+
results.append(uuid_str + " ")
|
|
345
|
+
return results
|
|
346
|
+
|
|
347
|
+
def _list_operations(self) -> list[dict[str, object]] | None:
|
|
348
|
+
"""Fetch recent operations from the API (no caching)."""
|
|
349
|
+
if self._client is None:
|
|
350
|
+
return None
|
|
351
|
+
try:
|
|
352
|
+
resp = self.client.get(
|
|
353
|
+
"/v1/operations", params={"limit": "100"},
|
|
354
|
+
)
|
|
355
|
+
data = json.loads(resp.read())
|
|
356
|
+
return data.get("operations", []) # type: ignore[no-any-return]
|
|
357
|
+
except Exception:
|
|
358
|
+
log.debug("Operation completion failed")
|
|
359
|
+
return None
|
|
360
|
+
|
|
361
|
+
def _complete_session_name(
|
|
362
|
+
self, remaining: tuple[str, ...], text: str,
|
|
363
|
+
) -> list[str]:
|
|
364
|
+
"""Complete session names for ``session use``."""
|
|
365
|
+
if self._store is None:
|
|
366
|
+
return []
|
|
367
|
+
try:
|
|
368
|
+
sessions = self._store.list_sessions()
|
|
369
|
+
except Exception:
|
|
370
|
+
return []
|
|
371
|
+
results: list[str] = []
|
|
372
|
+
for s in sessions:
|
|
373
|
+
key = s.session_key
|
|
374
|
+
# Match full key
|
|
375
|
+
if key.startswith(text):
|
|
376
|
+
results.append(key + " ")
|
|
377
|
+
# Match suffix (last component after _)
|
|
378
|
+
suffix = key.rsplit("_", 1)[-1] if "_" in key else ""
|
|
379
|
+
if suffix and suffix != key and suffix.startswith(text):
|
|
380
|
+
results.append(suffix + " ")
|
|
381
|
+
return results
|
|
382
|
+
|
|
383
|
+
def _complete_branch(
|
|
384
|
+
self, remaining: tuple[str, ...], text: str,
|
|
385
|
+
) -> list[str]:
|
|
386
|
+
"""Complete branch names for ``session checkout/branch``."""
|
|
387
|
+
if self._store is None:
|
|
388
|
+
return []
|
|
389
|
+
try:
|
|
390
|
+
branches = self._store.list_branches()
|
|
391
|
+
except Exception:
|
|
392
|
+
return []
|
|
393
|
+
return [
|
|
394
|
+
name + " " for name, _active in branches
|
|
395
|
+
if name.startswith(text)
|
|
396
|
+
]
|
|
397
|
+
|
|
398
|
+
@property
|
|
399
|
+
def client(self) -> ContreeClient:
|
|
400
|
+
if self._client is None:
|
|
401
|
+
raise RuntimeError("ContreeClient is not set")
|
|
402
|
+
return self._client
|
|
403
|
+
|
|
404
|
+
@property
|
|
405
|
+
def cache(self) -> ImageCache:
|
|
406
|
+
if self._store is None:
|
|
407
|
+
raise RuntimeError("SessionStore is not set")
|
|
408
|
+
return self._store.cache
|
|
409
|
+
|
|
410
|
+
def cached(
|
|
411
|
+
self, key: tuple[str, str],
|
|
412
|
+
) -> list[dict[str, object]] | None:
|
|
413
|
+
"""Return a cached value or ``None``."""
|
|
414
|
+
result = self.cache.get(key)
|
|
415
|
+
return result # type: ignore[return-value]
|
|
416
|
+
|
|
417
|
+
def _list_images(self) -> list[dict[str, object]] | None:
|
|
418
|
+
"""Fetch image list from the API, with persistent caching."""
|
|
419
|
+
if self._client is None or self._store is None:
|
|
420
|
+
return None
|
|
421
|
+
|
|
422
|
+
cache_key = ("", "images")
|
|
423
|
+
cached = self.cached(cache_key)
|
|
424
|
+
if cached is not None:
|
|
425
|
+
return cached
|
|
426
|
+
|
|
427
|
+
try:
|
|
428
|
+
resp = self.client.get(
|
|
429
|
+
"/v1/images", params={"limit": "100"},
|
|
430
|
+
)
|
|
431
|
+
data = json.loads(resp.read())
|
|
432
|
+
images: list[dict[str, object]] = data.get("images", [])
|
|
433
|
+
self.cache[cache_key] = images
|
|
434
|
+
return images
|
|
435
|
+
except Exception:
|
|
436
|
+
log.debug("Image completion failed")
|
|
437
|
+
return None
|
|
438
|
+
|
|
439
|
+
def _list_dir(
|
|
440
|
+
self, image_uuid: str, dir_path: str,
|
|
441
|
+
) -> list[dict[str, object]] | None:
|
|
442
|
+
"""List a container directory, with persistent caching."""
|
|
443
|
+
cache_key = (image_uuid, f"files:{dir_path}")
|
|
444
|
+
|
|
445
|
+
cached = self.cached(cache_key)
|
|
446
|
+
if cached is not None:
|
|
447
|
+
return cached
|
|
448
|
+
|
|
449
|
+
try:
|
|
450
|
+
from contree_cli.client import resolve_image
|
|
451
|
+
uuid = resolve_image(self.client, image_uuid)
|
|
452
|
+
resp = self.client.get(
|
|
453
|
+
f"/v1/inspect/{uuid}/list",
|
|
454
|
+
params={"path": dir_path},
|
|
455
|
+
)
|
|
456
|
+
data = json.loads(resp.read())
|
|
457
|
+
file_list: list[dict[str, object]] = data.get("files", [])
|
|
458
|
+
self.cache[cache_key] = file_list
|
|
459
|
+
return file_list
|
|
460
|
+
except Exception:
|
|
461
|
+
log.debug(
|
|
462
|
+
"Path completion failed for %s:%s",
|
|
463
|
+
image_uuid, dir_path,
|
|
464
|
+
)
|
|
465
|
+
return None
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Readline history persistence backed by the session SQLite DB."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from contree_cli.session import SessionStore
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_history(store: SessionStore) -> None:
|
|
14
|
+
"""Populate readline history from the session database."""
|
|
15
|
+
try:
|
|
16
|
+
import readline
|
|
17
|
+
except ImportError:
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
lines = store.load_shell_history()
|
|
21
|
+
for line in lines:
|
|
22
|
+
readline.add_history(line)
|
|
23
|
+
log.debug("Loaded %d history lines from session DB", len(lines))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def save_history(store: SessionStore) -> None:
|
|
27
|
+
"""Persist current readline history into the session database.
|
|
28
|
+
|
|
29
|
+
Compares what readline holds against what the DB already has and
|
|
30
|
+
appends only the new tail entries. Then trims to the maximum.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
import readline
|
|
34
|
+
except ImportError:
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
total = readline.get_current_history_length()
|
|
38
|
+
existing = store.load_shell_history()
|
|
39
|
+
existing_count = len(existing)
|
|
40
|
+
|
|
41
|
+
# Readline history is 1-indexed.
|
|
42
|
+
new_count = total - existing_count
|
|
43
|
+
if new_count <= 0:
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
start = existing_count + 1
|
|
47
|
+
for i in range(start, total + 1):
|
|
48
|
+
item = readline.get_history_item(i)
|
|
49
|
+
if item is not None:
|
|
50
|
+
store.add_shell_history(item)
|
|
51
|
+
|
|
52
|
+
store.trim_shell_history()
|
|
53
|
+
log.debug("Saved %d new history lines to session DB", new_count)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Shell-safe argument parser that raises instead of calling sys.exit()."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import NoReturn
|
|
7
|
+
|
|
8
|
+
from contree_cli import Handler
|
|
9
|
+
from contree_cli.output import FORMATTERS
|
|
10
|
+
from contree_cli.types import COMMAND_REGISTRY, ArgumentsFormatter, get_command_docs
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ShellParseError(Exception):
|
|
14
|
+
"""Raised instead of sys.exit() when argparse encounters bad input."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str, usage: str, status: int = 2) -> None:
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.message = message
|
|
19
|
+
self.usage = usage
|
|
20
|
+
self.status = status
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ShellArgumentParser(argparse.ArgumentParser):
|
|
24
|
+
"""ArgumentParser that raises ShellParseError instead of calling sys.exit()."""
|
|
25
|
+
|
|
26
|
+
def exit(self, status: int = 0, message: str | None = None) -> NoReturn:
|
|
27
|
+
raise ShellParseError(message or "", usage="", status=status)
|
|
28
|
+
|
|
29
|
+
def error(self, message: str) -> NoReturn:
|
|
30
|
+
raise ShellParseError(message, usage=self.format_usage())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class CommandInfo:
|
|
35
|
+
"""Metadata about a registered shell command."""
|
|
36
|
+
|
|
37
|
+
name: str
|
|
38
|
+
handler: Handler
|
|
39
|
+
parser: ShellArgumentParser
|
|
40
|
+
aliases: list[str]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _ensure_registered() -> None:
|
|
44
|
+
"""Import arguments module to trigger register() calls if needed."""
|
|
45
|
+
if not COMMAND_REGISTRY:
|
|
46
|
+
import contree_cli.arguments # noqa: F401
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_shell_parser() -> tuple[ShellArgumentParser, dict[str, CommandInfo]]:
|
|
50
|
+
"""Build a parser tree from the same setup_parser() functions used by the CLI.
|
|
51
|
+
|
|
52
|
+
Returns the root parser and a dict mapping command names (and aliases)
|
|
53
|
+
to their CommandInfo for use by the completer.
|
|
54
|
+
"""
|
|
55
|
+
_ensure_registered()
|
|
56
|
+
root = ShellArgumentParser(prog="", add_help=False)
|
|
57
|
+
root.add_argument(
|
|
58
|
+
"-f", "--format",
|
|
59
|
+
dest="output_format",
|
|
60
|
+
default=None,
|
|
61
|
+
choices=sorted(FORMATTERS),
|
|
62
|
+
)
|
|
63
|
+
subparsers = root.add_subparsers(
|
|
64
|
+
dest="command",
|
|
65
|
+
parser_class=ShellArgumentParser,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
commands: dict[str, CommandInfo] = {}
|
|
69
|
+
|
|
70
|
+
for name, help_text, setup_fn, aliases in COMMAND_REGISTRY:
|
|
71
|
+
if name == "shell":
|
|
72
|
+
continue
|
|
73
|
+
description, epilog = get_command_docs(setup_fn)
|
|
74
|
+
sub_parser = subparsers.add_parser(
|
|
75
|
+
name,
|
|
76
|
+
help=help_text,
|
|
77
|
+
aliases=aliases,
|
|
78
|
+
description=description,
|
|
79
|
+
epilog=epilog,
|
|
80
|
+
formatter_class=ArgumentsFormatter,
|
|
81
|
+
)
|
|
82
|
+
handler, loader = setup_fn(sub_parser)
|
|
83
|
+
sub_parser.set_defaults(handler=handler, load_args=loader)
|
|
84
|
+
|
|
85
|
+
info = CommandInfo(
|
|
86
|
+
name=name,
|
|
87
|
+
handler=handler,
|
|
88
|
+
parser=sub_parser,
|
|
89
|
+
aliases=aliases,
|
|
90
|
+
)
|
|
91
|
+
commands[name] = info
|
|
92
|
+
for alias in aliases:
|
|
93
|
+
commands[alias] = info
|
|
94
|
+
|
|
95
|
+
return root, commands
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_command_names() -> list[str]:
|
|
99
|
+
"""Return all command names and aliases (for completion)."""
|
|
100
|
+
_ensure_registered()
|
|
101
|
+
names: list[str] = []
|
|
102
|
+
for name, _, _, aliases in COMMAND_REGISTRY:
|
|
103
|
+
if name == "shell":
|
|
104
|
+
continue
|
|
105
|
+
names.append(name)
|
|
106
|
+
names.extend(aliases)
|
|
107
|
+
return names
|