gcli-control 0.1.0__tar.gz → 0.2.0__tar.gz

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 (30) hide show
  1. {gcli_control-0.1.0 → gcli_control-0.2.0}/PKG-INFO +1 -1
  2. {gcli_control-0.1.0 → gcli_control-0.2.0}/gcli/__init__.py +1 -1
  3. gcli_control-0.2.0/gcli/aliases.py +512 -0
  4. gcli_control-0.2.0/gcli/client.py +598 -0
  5. gcli_control-0.2.0/gcli/clipboard.py +496 -0
  6. gcli_control-0.2.0/gcli/fileops.py +483 -0
  7. {gcli_control-0.1.0 → gcli_control-0.2.0}/gcli/host.py +94 -0
  8. gcli_control-0.2.0/gcli/monitoring.py +907 -0
  9. gcli_control-0.2.0/gcli/netutils.py +481 -0
  10. gcli_control-0.2.0/gcli/output.py +633 -0
  11. gcli_control-0.2.0/gcli/processes.py +632 -0
  12. gcli_control-0.2.0/gcli/security.py +245 -0
  13. gcli_control-0.2.0/gcli/session.py +404 -0
  14. {gcli_control-0.1.0 → gcli_control-0.2.0}/gcli_control.egg-info/PKG-INFO +1 -1
  15. {gcli_control-0.1.0 → gcli_control-0.2.0}/gcli_control.egg-info/SOURCES.txt +9 -0
  16. {gcli_control-0.1.0 → gcli_control-0.2.0}/pyproject.toml +1 -1
  17. gcli_control-0.1.0/gcli/client.py +0 -362
  18. {gcli_control-0.1.0 → gcli_control-0.2.0}/README.md +0 -0
  19. {gcli_control-0.1.0 → gcli_control-0.2.0}/gcli/__main__.py +0 -0
  20. {gcli_control-0.1.0 → gcli_control-0.2.0}/gcli/colors.py +0 -0
  21. {gcli_control-0.1.0 → gcli_control-0.2.0}/gcli/crypto.py +0 -0
  22. {gcli_control-0.1.0 → gcli_control-0.2.0}/gcli/npoint.py +0 -0
  23. {gcli_control-0.1.0 → gcli_control-0.2.0}/gcli/protocol.py +0 -0
  24. {gcli_control-0.1.0 → gcli_control-0.2.0}/gcli/utils.py +0 -0
  25. {gcli_control-0.1.0 → gcli_control-0.2.0}/gcli_control.egg-info/dependency_links.txt +0 -0
  26. {gcli_control-0.1.0 → gcli_control-0.2.0}/gcli_control.egg-info/entry_points.txt +0 -0
  27. {gcli_control-0.1.0 → gcli_control-0.2.0}/gcli_control.egg-info/requires.txt +0 -0
  28. {gcli_control-0.1.0 → gcli_control-0.2.0}/gcli_control.egg-info/top_level.txt +0 -0
  29. {gcli_control-0.1.0 → gcli_control-0.2.0}/setup.cfg +0 -0
  30. {gcli_control-0.1.0 → gcli_control-0.2.0}/tests/test_integration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gcli-control
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Remote access tool via npoint.io - encrypted command relay using AES-256-GCM
5
5
  Author: gcli contributors
6
6
  License-Expression: MIT
@@ -2,5 +2,5 @@
2
2
  gcli — remote access tool via npoint.io.
3
3
  Encrypted command relay using AES-256-GCM over npoint.io JSON bins.
4
4
  """
5
- __version__ = "0.1.0"
5
+ __version__ = "0.2.0"
6
6
  __author__ = "gcli"
@@ -0,0 +1,512 @@
1
+ """
2
+ Command aliases and scripting for the gcli remote access tool.
3
+
4
+ Provides named command aliases (create, remove, list, expand), batch
5
+ script execution, and persistent alias storage via JSON files.
6
+
7
+ Usage::
8
+
9
+ from gcli.aliases import dispatch, CommandAliases
10
+
11
+ # Remote command dispatch
12
+ result = dispatch({"type": "alias", "name": "ll", "command": "ls -la"})
13
+
14
+ # Local expansion
15
+ ca = CommandAliases()
16
+ ca.alias("ll", "ls -la")
17
+ expanded = ca.expand("ll -h") # -> "ls -la -h"
18
+ """
19
+ import json
20
+ import os
21
+ import sys
22
+ import platform
23
+ import logging
24
+ import tempfile
25
+ from pathlib import Path
26
+ from typing import Any, Callable, Dict, List, Optional
27
+
28
+ logger = logging.getLogger("gcli.aliases")
29
+
30
+ MAX_EXPANSION_DEPTH = 5
31
+
32
+ _WINDOWS = platform.system() == "Windows"
33
+
34
+
35
+ def _default_aliases() -> Dict[str, str]:
36
+ """Return the built-in alias set appropriate for the current OS."""
37
+ if _WINDOWS:
38
+ return {
39
+ "ll": "dir",
40
+ "la": "dir /a",
41
+ "cls": "cls",
42
+ "h": "doskey /history",
43
+ "..": "cd ..",
44
+ }
45
+ return {
46
+ "ll": "ls -la",
47
+ "la": "ls -a",
48
+ "cls": "clear",
49
+ "h": "history 20",
50
+ "..": "cd ..",
51
+ }
52
+
53
+
54
+ class CommandAliases:
55
+ """Manages command aliases, expansion, persistence, and batch execution."""
56
+
57
+ def __init__(self) -> None:
58
+ self._aliases: Dict[str, str] = _default_aliases()
59
+
60
+ # ------------------------------------------------------------------
61
+ # Core alias management
62
+ # ------------------------------------------------------------------
63
+
64
+ def alias(self, name: str, command: str) -> dict:
65
+ """Create or overwrite a named alias.
66
+
67
+ Returns ``{"ok": True, "name": ..., "command": ...}`` on success.
68
+ """
69
+ if not name or not isinstance(name, str):
70
+ return {"ok": False, "error": "Alias name must be a non-empty string"}
71
+ if not command or not isinstance(command, str):
72
+ return {"ok": False, "error": "Alias command must be a non-empty string"}
73
+ self._aliases[name] = command
74
+ logger.debug("Alias set: %s -> %s", name, command)
75
+ return {"ok": True, "name": name, "command": command}
76
+
77
+ def unalias(self, name: str) -> dict:
78
+ """Remove a named alias.
79
+
80
+ Returns ``{"ok": True, "removed": name}`` on success.
81
+ """
82
+ if name not in self._aliases:
83
+ return {"ok": False, "error": f"Alias not found: {name!r}"}
84
+ del self._aliases[name]
85
+ logger.debug("Alias removed: %s", name)
86
+ return {"ok": True, "removed": name}
87
+
88
+ def list_aliases(self) -> dict:
89
+ """Return all current aliases.
90
+
91
+ Returns ``{"ok": True, "aliases": {...}, "count": n}``.
92
+ """
93
+ snapshot = dict(self._aliases)
94
+ return {"ok": True, "aliases": snapshot, "count": len(snapshot)}
95
+
96
+ # ------------------------------------------------------------------
97
+ # Expansion
98
+ # ------------------------------------------------------------------
99
+
100
+ def expand(self, command: str) -> str:
101
+ """Expand the first word of *command* if it matches an alias.
102
+
103
+ Follows alias references up to ``MAX_EXPANSION_DEPTH`` levels to
104
+ prevent infinite recursion. Non-alias leading words are returned
105
+ unchanged.
106
+
107
+ >>> ca = CommandAliases()
108
+ >>> ca.alias("ll", "ls -la")
109
+ >>> ca.expand("ll -h")
110
+ 'ls -la -h'
111
+ """
112
+ if not command:
113
+ return command
114
+
115
+ parts = command.split(None, 1)
116
+ if not parts:
117
+ return command
118
+
119
+ word = parts[0]
120
+ rest = (" " + parts[1]) if len(parts) > 1 else ""
121
+
122
+ visited: set = set()
123
+ for _ in range(MAX_EXPANSION_DEPTH):
124
+ if word not in self._aliases:
125
+ break
126
+ if word in visited:
127
+ logger.warning("Alias recursion detected for %r, stopping", word)
128
+ break
129
+ visited.add(word)
130
+ expansion = self._aliases[word]
131
+ # Re-split: the expansion may itself have arguments baked in,
132
+ # but we only track the leading word for the next hop.
133
+ exp_parts = expansion.split(None, 1)
134
+ word = exp_parts[0]
135
+ tail = (" " + exp_parts[1]) if len(exp_parts) > 1 else ""
136
+ # The trailing arguments from the original call always come last
137
+ rest = tail + rest
138
+
139
+ return word + rest
140
+
141
+ # ------------------------------------------------------------------
142
+ # Batch script execution
143
+ # ------------------------------------------------------------------
144
+
145
+ def run_script(self, commands: List[str],
146
+ stop_on_error: bool = False) -> dict:
147
+ """Execute a list of shell commands sequentially.
148
+
149
+ Each command is expanded for aliases before execution. Results are
150
+ returned in order. When *stop_on_error* is ``True`` the script
151
+ halts on the first command whose ``ok`` field is ``False``.
152
+
153
+ Returns::
154
+
155
+ {"ok": True, "results": [...], "total": n, "succeeded": m, "failed": f}
156
+ """
157
+ if not isinstance(commands, list):
158
+ return {"ok": False, "error": "commands must be a list"}
159
+
160
+ from .utils import run_command # local import to avoid circular deps at module level
161
+
162
+ results: List[dict] = []
163
+ succeeded = 0
164
+ failed = 0
165
+
166
+ for raw_cmd in commands:
167
+ if not isinstance(raw_cmd, str) or not raw_cmd.strip():
168
+ entry = {"cmd": str(raw_cmd), "ok": False, "output": "", "error": "empty command"}
169
+ results.append(entry)
170
+ failed += 1
171
+ if stop_on_error:
172
+ break
173
+ continue
174
+
175
+ expanded = self.expand(raw_cmd.strip())
176
+ rc = run_command(expanded)
177
+ ok = rc.get("ok", False)
178
+ output = rc.get("stdout", "")
179
+ stderr = rc.get("stderr", "")
180
+ if stderr:
181
+ output = (output + "\n" + stderr).strip() if output else stderr
182
+
183
+ entry = {"cmd": raw_cmd, "ok": ok, "output": output}
184
+ results.append(entry)
185
+
186
+ if ok:
187
+ succeeded += 1
188
+ else:
189
+ failed += 1
190
+ if stop_on_error:
191
+ break
192
+
193
+ total = succeeded + failed
194
+ return {
195
+ "ok": True,
196
+ "results": results,
197
+ "total": total,
198
+ "succeeded": succeeded,
199
+ "failed": failed,
200
+ }
201
+
202
+ # ------------------------------------------------------------------
203
+ # Persistence
204
+ # ------------------------------------------------------------------
205
+
206
+ def save_aliases(self, path: str) -> dict:
207
+ """Persist the current alias dictionary to a JSON file.
208
+
209
+ Returns ``{"ok": True, "path": ..., "count": n}``.
210
+ """
211
+ try:
212
+ p = Path(path)
213
+ p.parent.mkdir(parents=True, exist_ok=True)
214
+ p.write_text(json.dumps(self._aliases, indent=2, sort_keys=True),
215
+ encoding="utf-8")
216
+ logger.debug("Saved %d aliases to %s", len(self._aliases), path)
217
+ return {"ok": True, "path": str(p.resolve()), "count": len(self._aliases)}
218
+ except Exception as exc:
219
+ return {"ok": False, "error": str(exc)}
220
+
221
+ def load_aliases(self, path: str) -> dict:
222
+ """Load aliases from a JSON file and merge with current set.
223
+
224
+ Existing aliases are overwritten by any conflicting keys in the file.
225
+
226
+ Returns ``{"ok": True, "loaded": n, "total": m}``.
227
+ """
228
+ try:
229
+ p = Path(path)
230
+ if not p.exists():
231
+ return {"ok": False, "error": f"File not found: {path}"}
232
+ data = json.loads(p.read_text(encoding="utf-8"))
233
+ if not isinstance(data, dict):
234
+ return {"ok": False, "error": "Alias file must contain a JSON object"}
235
+ loaded = 0
236
+ for k, v in data.items():
237
+ if isinstance(k, str) and isinstance(v, str) and k and v:
238
+ self._aliases[k] = v
239
+ loaded += 1
240
+ logger.debug("Loaded %d aliases from %s", loaded, path)
241
+ return {"ok": True, "loaded": loaded, "total": len(self._aliases)}
242
+ except json.JSONDecodeError as exc:
243
+ return {"ok": False, "error": f"Invalid JSON: {exc}"}
244
+ except Exception as exc:
245
+ return {"ok": False, "error": str(exc)}
246
+
247
+ # ------------------------------------------------------------------
248
+ # Direct accessors (useful for integration)
249
+ # ------------------------------------------------------------------
250
+
251
+ @property
252
+ def aliases(self) -> Dict[str, str]:
253
+ """Read-only snapshot of the alias dictionary."""
254
+ return dict(self._aliases)
255
+
256
+ def clear(self) -> None:
257
+ """Remove all aliases (including built-ins)."""
258
+ self._aliases.clear()
259
+
260
+
261
+ # ---------------------------------------------------------------------------
262
+ # Singleton for dispatch
263
+ # ---------------------------------------------------------------------------
264
+
265
+ _instance: Optional[CommandAliases] = None
266
+
267
+
268
+ def _get_instance() -> CommandAliases:
269
+ global _instance
270
+ if _instance is None:
271
+ _instance = CommandAliases()
272
+ return _instance
273
+
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # Remote command dispatcher
277
+ # ---------------------------------------------------------------------------
278
+
279
+ _HANDLERS: Dict[str, Callable[..., dict]] = {}
280
+
281
+
282
+ def _register(name: str):
283
+ """Decorator that registers a handler function for a command type."""
284
+ def decorator(fn: Callable[..., dict]) -> Callable[..., dict]:
285
+ _HANDLERS[name] = fn
286
+ return fn
287
+ return decorator
288
+
289
+
290
+ @_register("alias")
291
+ def _handle_alias(payload: dict) -> dict:
292
+ name = payload.get("name", "")
293
+ command = payload.get("command", "")
294
+ return _get_instance().alias(name, command)
295
+
296
+
297
+ @_register("unalias")
298
+ def _handle_unalias(payload: dict) -> dict:
299
+ name = payload.get("name", "")
300
+ return _get_instance().unalias(name)
301
+
302
+
303
+ @_register("list_aliases")
304
+ def _handle_list(payload: dict) -> dict:
305
+ return _get_instance().list_aliases()
306
+
307
+
308
+ @_register("run_script")
309
+ def _handle_run_script(payload: dict) -> dict:
310
+ commands = payload.get("commands", [])
311
+ stop_on_error = payload.get("stop_on_error", False)
312
+ return _get_instance().run_script(commands, stop_on_error)
313
+
314
+
315
+ @_register("save_aliases")
316
+ def _handle_save(payload: dict) -> dict:
317
+ path = payload.get("path", "")
318
+ if not path:
319
+ return {"ok": False, "error": "No path specified"}
320
+ return _get_instance().save_aliases(path)
321
+
322
+
323
+ @_register("load_aliases")
324
+ def _handle_load(payload: dict) -> dict:
325
+ path = payload.get("path", "")
326
+ if not path:
327
+ return {"ok": False, "error": "No path specified"}
328
+ return _get_instance().load_aliases(path)
329
+
330
+
331
+ def dispatch(payload: dict) -> dict:
332
+ """Route an alias-related command payload to the correct handler.
333
+
334
+ Parameters
335
+ ----------
336
+ payload:
337
+ A dict with at least a ``"type"`` key matching one of the supported
338
+ command names (alias, unalias, list_aliases, run_script,
339
+ save_aliases, load_aliases).
340
+
341
+ Returns
342
+ -------
343
+ dict
344
+ ``{"ok": True, ...}`` on success, or ``{"ok": False, "error": ...}``
345
+ on failure.
346
+ """
347
+ cmd_type = payload.get("type", "")
348
+ handler = _HANDLERS.get(cmd_type)
349
+ if handler is None:
350
+ return {"ok": False, "error": f"Unknown alias command: {cmd_type!r}"}
351
+ return handler(payload)
352
+
353
+
354
+ # ---------------------------------------------------------------------------
355
+ # Self-test
356
+ # ---------------------------------------------------------------------------
357
+
358
+ def test_aliases() -> None:
359
+ """Exercise every capability of the aliases module."""
360
+ passed = 0
361
+ failed = 0
362
+
363
+ def check(desc: str, condition: bool) -> None:
364
+ nonlocal passed, failed
365
+ if condition:
366
+ passed += 1
367
+ else:
368
+ failed += 1
369
+ print(f" FAIL: {desc}")
370
+
371
+ try:
372
+ # -- built-in aliases loaded --
373
+ ca = CommandAliases()
374
+ r = ca.list_aliases()
375
+ check("list_aliases ok", r["ok"])
376
+ check("ll built-in present", "ll" in r["aliases"])
377
+ check("la built-in present", "la" in r["aliases"])
378
+ check("cls built-in present", "cls" in r["aliases"])
379
+ check("h built-in present", "h" in r["aliases"])
380
+ check(".. built-in present", ".." in r["aliases"])
381
+
382
+ # -- create alias --
383
+ r = ca.alias("greet", "echo hello")
384
+ check("alias create ok", r["ok"])
385
+ check("alias name returned", r["name"] == "greet")
386
+ check("alias command returned", r["command"] == "echo hello")
387
+
388
+ # -- create alias validation --
389
+ r = ca.alias("", "echo hi")
390
+ check("alias empty name fails", not r["ok"])
391
+ r = ca.alias("x", "")
392
+ check("alias empty command fails", not r["ok"])
393
+
394
+ # -- list includes new alias --
395
+ r = ca.list_aliases()
396
+ check("new alias in list", "greet" in r["aliases"])
397
+ check("list count correct", r["count"] == len(r["aliases"]))
398
+
399
+ # -- expand --
400
+ result = ca.expand("greet world")
401
+ check("expand greet", result == "echo hello world")
402
+
403
+ result = ca.expand("ll -la")
404
+ check("expand ll", result.endswith("-la"))
405
+ check("expand ll has ls", result.startswith("ls") or result.startswith("dir"))
406
+
407
+ result = ca.expand("unknown_cmd arg")
408
+ check("expand unknown unchanged", result == "unknown_cmd arg")
409
+
410
+ result = ca.expand("")
411
+ check("expand empty unchanged", result == "")
412
+
413
+ # -- alias chaining (one level) --
414
+ ca.alias("g1", "greet there")
415
+ result = ca.expand("g1")
416
+ check("chain g1 -> greet", result == "echo hello there")
417
+
418
+ # -- alias recursion prevention --
419
+ ca.alias("a", "b extra")
420
+ ca.alias("b", "a more")
421
+ result = ca.expand("a")
422
+ check("recursion stops", "a" in result or "b" in result)
423
+
424
+ # -- unalias --
425
+ r = ca.unalias("greet")
426
+ check("unalias ok", r["ok"])
427
+ check("unalias removed name", r["removed"] == "greet")
428
+
429
+ r = ca.unalias("greet")
430
+ check("unalias missing fails", not r["ok"])
431
+
432
+ # -- run_script --
433
+ r = ca.run_script(["echo first", "echo second", "echo third"])
434
+ check("run_script ok", r["ok"])
435
+ check("run_script total", r["total"] == 3)
436
+ check("run_script succeeded", r["succeeded"] == 3)
437
+ check("run_script failed", r["failed"] == 0)
438
+ check("run_script results len", len(r["results"]) == 3)
439
+
440
+ # -- run_script with alias expansion --
441
+ ca.alias("greet", "echo hello")
442
+ r = ca.run_script(["greet"])
443
+ check("run_script alias expand", r["ok"])
444
+ check("run_script alias output", "hello" in r["results"][0].get("output", ""))
445
+
446
+ # -- run_script with bad command and stop_on_error --
447
+ r = ca.run_script(["echo ok", "false_command_xyz_999", "echo never"],
448
+ stop_on_error=True)
449
+ check("run_script stop_on_error ok", r["ok"])
450
+ check("run_script stopped early", r["total"] < 3)
451
+
452
+ # -- run_script invalid input --
453
+ r = ca.run_script("not a list")
454
+ check("run_script non-list fails", not r["ok"])
455
+
456
+ # -- save / load round-trip --
457
+ tmp = Path(tempfile.mkdtemp(prefix="gcli_alias_test_"))
458
+ save_path = str(tmp / "aliases.json")
459
+ r = ca.save_aliases(save_path)
460
+ check("save_aliases ok", r["ok"])
461
+ check("save_aliases count", r["count"] > 0)
462
+
463
+ # Create a fresh instance and load
464
+ ca2 = CommandAliases()
465
+ ca2.clear()
466
+ r = ca2.load_aliases(save_path)
467
+ check("load_aliases ok", r["ok"])
468
+ check("load_aliases loaded", r["loaded"] > 0)
469
+ check("load_aliases preserves greet", "greet" in ca2.aliases)
470
+
471
+ # -- load missing file --
472
+ r = ca2.load_aliases("/nonexistent/path/xyz.json")
473
+ check("load missing file fails", not r["ok"])
474
+
475
+ # -- load invalid JSON --
476
+ bad_path = str(tmp / "bad.json")
477
+ Path(bad_path).write_text("{invalid json", encoding="utf-8")
478
+ r = ca2.load_aliases(bad_path)
479
+ check("load bad json fails", not r["ok"])
480
+
481
+ # -- save to nonexistent parent --
482
+ r = ca.save_aliases(str(tmp / "deep" / "nest" / "aliases.json"))
483
+ check("save creates parents", r["ok"])
484
+
485
+ # -- dispatch routing --
486
+ r = dispatch({"type": "alias", "name": "test1", "command": "echo test"})
487
+ check("dispatch alias", r["ok"])
488
+ r = dispatch({"type": "unalias", "name": "test1"})
489
+ check("dispatch unalias", r["ok"])
490
+ r = dispatch({"type": "list_aliases"})
491
+ check("dispatch list_aliases", r["ok"])
492
+ r = dispatch({"type": "run_script", "commands": ["echo x"]})
493
+ check("dispatch run_script", r["ok"])
494
+ r = dispatch({"type": "bogus"})
495
+ check("dispatch unknown fails", not r["ok"])
496
+
497
+ except Exception as exc:
498
+ failed += 1
499
+ print(f" EXCEPTION: {exc}")
500
+ finally:
501
+ # Cleanup temp dir
502
+ try:
503
+ import shutil
504
+ shutil.rmtree(tmp, ignore_errors=True)
505
+ except Exception:
506
+ pass
507
+
508
+ print(f"aliases tests: {passed} passed, {failed} failed")
509
+
510
+
511
+ if __name__ == "__main__":
512
+ test_aliases()