conning 0.2.0__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.
conning/cli.py ADDED
@@ -0,0 +1,263 @@
1
+ """The `conn` command line. Humans manage targets here; agents with only a shell may use it too.
2
+
3
+ Every agent-facing command prints exactly what the matching MCP tool returns.
4
+ """
5
+
6
+ import argparse
7
+ import os
8
+ import sys
9
+
10
+ from conning import __version__, admin, doctor, install, watch
11
+ from conning.output import DEFAULT_MAX_BYTES, MAX_BYTES_CEILING
12
+ from conning.secrets import SecretError
13
+ from conning.ssh import SSHError
14
+ from conning.targets import AUTHS, MODES, TargetFileError
15
+
16
+
17
+ def _core():
18
+ from conning.core import Core
19
+
20
+ return Core()
21
+
22
+
23
+ def _io():
24
+ return admin.ConsoleIO()
25
+
26
+
27
+ def _emit(text: str) -> int:
28
+ print(text)
29
+ return 1 if text.startswith("error:") else 0
30
+
31
+
32
+ def _run(args: argparse.Namespace) -> int:
33
+ if not args.argv:
34
+ print(f"error: give the command after the target, e.g. conn run {args.target} uname -a")
35
+ return 1
36
+ common = dict(cwd=args.cwd, wait=args.wait, timeout=args.timeout, max_bytes=args.max_bytes, grep=args.grep)
37
+ core, io = _core(), _io()
38
+ core.notify = not io.tty # a human at this terminal is asked right here, no pop-up needed
39
+ command = {"cmd": " ".join(args.argv)} if args.shell else {"argv": args.argv}
40
+ result = core.run(args.target, **command, **common)
41
+ return admin.run_here(core, io, result, wait=args.wait, max_bytes=args.max_bytes, grep=args.grep)
42
+
43
+
44
+ def _add(a: argparse.Namespace) -> int:
45
+ return admin.add(
46
+ _core(), _io(), a.name, host=a.host, user=a.user, port=a.port, auth=a.auth, secret=a.secret, key=a.key,
47
+ os_name=a.os, mode=a.mode, tags=a.tag, retry=a.retry, like=a.like,
48
+ ) # fmt: skip
49
+
50
+
51
+ def _watch(args: argparse.Namespace) -> int:
52
+ try:
53
+ return watch.watch(_core(), _io(), history=args.history, prompt=not args.no_prompt)
54
+ except KeyboardInterrupt:
55
+ return 0
56
+
57
+
58
+ def _mcp(args: argparse.Namespace) -> int:
59
+ if args.mcp_command in (None, "serve"):
60
+ from conning.server import build_server
61
+
62
+ build_server(approve_in_client=args.approve_in_client).run("stdio")
63
+ return 0
64
+ return install.install(_io(), args.client, dry_run=args.dry_run)
65
+
66
+
67
+ HANDLERS = {
68
+ "targets": lambda a: _emit(_core().targets(a.filter, a.check)),
69
+ "run": _run,
70
+ "read": lambda a: _emit(
71
+ _core().read(a.id, wait=a.wait, range=a.range, grep=a.grep, signal=a.signal, max_bytes=a.max_bytes)
72
+ ),
73
+ "transfer": lambda a: _emit(_core().transfer(a.source, a.dest)),
74
+ "add": _add,
75
+ "approve": lambda a: admin.approve(_core(), _io(), a.what, always=a.always),
76
+ "import": lambda a: admin.import_ssh_config(_io(), a.match),
77
+ "rm": lambda a: admin.remove(_io(), a.name),
78
+ "test": lambda a: admin.test(_core(), _io(), a.name),
79
+ "lease": lambda a: admin.lease(_io(), a.name, a.minutes),
80
+ "allow": lambda a: admin.allow(_io(), a.target, a.words),
81
+ "deny": lambda a: admin.deny(_io(), a.target, a.words),
82
+ "watch": _watch,
83
+ "doctor": lambda a: doctor.doctor(_io()),
84
+ "mcp": _mcp,
85
+ "help": lambda a: print(help_markdown() if a.markdown else build_parser().format_help()) or 0,
86
+ }
87
+
88
+
89
+ def build_parser() -> argparse.ArgumentParser:
90
+ p = argparse.ArgumentParser(prog="conn", description="Conning: a control room for your agent's test machines.")
91
+ p.add_argument("--version", action="version", version=f"conning {__version__}")
92
+ sub = p.add_subparsers(dest="command", metavar="<command>")
93
+
94
+ def cmd(name: str, help: str, aliases=()) -> argparse.ArgumentParser:
95
+ sp = sub.add_parser(name, help=help, description=help, aliases=list(aliases))
96
+ sp.set_defaults(func=HANDLERS[name])
97
+ return sp
98
+
99
+ # Agent-facing: same output as the MCP tools.
100
+ sp = cmd("targets", "List targets: name, OS, shell, user, tags, mode. Never hosts or secrets.", ["ls"])
101
+ sp.add_argument("filter", nargs="?", help="'tag:<tag>' or comma-separated names")
102
+ sp.add_argument("--check", action="store_true", help="probe reachability in parallel")
103
+
104
+ sp = cmd("run", "Run a command on one target, a list, or tag:<tag>. Exit code first.")
105
+ sp.add_argument("target")
106
+ sp.add_argument("--wait", type=int, default=30, help="seconds before returning a job id (0 = async)")
107
+ sp.add_argument("--timeout", type=int, default=3600, help="kill the command after this many seconds")
108
+ sp.add_argument("--cwd")
109
+ sp.add_argument("--shell", action="store_true", help="send the words as one shell string (pipes etc. always ask)")
110
+ sp.add_argument(
111
+ "--max-bytes",
112
+ type=int,
113
+ default=DEFAULT_MAX_BYTES,
114
+ help=f"output cap (default {DEFAULT_MAX_BYTES}, ceiling {MAX_BYTES_CEILING})",
115
+ )
116
+ sp.add_argument("--grep", help="keep only matching lines before capping")
117
+ sp.add_argument("argv", nargs="*", help="the command and its arguments (conn's own options go before the target)")
118
+
119
+ sp = cmd("read", "Page a job (new lines only), a saved output, an approval, or a remote file.")
120
+ sp.add_argument("id", help="j:<job>, o:<output>, a:<approval>, or <target>:<path>")
121
+ sp.add_argument("--wait", type=int, default=0, help="long-poll up to this many seconds")
122
+ sp.add_argument("--range", help="line range, e.g. 200-260 or -50 for the last 50")
123
+ sp.add_argument("--grep")
124
+ sp.add_argument("--signal", choices=["INT", "TERM", "KILL"])
125
+ sp.add_argument("--max-bytes", type=int, default=DEFAULT_MAX_BYTES)
126
+
127
+ sp = cmd("transfer", "Copy files: local:<path> to <target>:<path>, or back. sha256 checked.")
128
+ sp.add_argument("source")
129
+ sp.add_argument("dest")
130
+
131
+ # Target management. Without a terminal (an agent), a new target is saved as pending.
132
+ sp = cmd("add", "Add a target. Without a terminal it is saved as pending until a human approves it.")
133
+ sp.add_argument("name")
134
+ sp.add_argument("--host")
135
+ sp.add_argument("--user")
136
+ sp.add_argument("--like", metavar="TARGET", help="same machine as TARGET (host, port, OS, tags); pick --user")
137
+ sp.add_argument("--port", type=int, help="default 22")
138
+ sp.add_argument("--auth", choices=AUTHS)
139
+ sp.add_argument("--secret", help="reference only: keychain:<service>/<account>, op://..., env:<NAME>")
140
+ sp.add_argument("--key", help="private key file, for --auth key")
141
+ sp.add_argument("--os", choices=["linux", "windows", "darwin"])
142
+ sp.add_argument("--mode", choices=MODES, default="ask")
143
+ sp.add_argument("--tag", action="append", default=[])
144
+ sp.add_argument("--retry", action="store_true", help="probe an existing target again")
145
+ sp = cmd("approve", "Human only: make a pending target live, or allow a pending command a:<id>.")
146
+ sp.add_argument("what", help="<target> or a:<id>")
147
+ sp.add_argument("--always", action="store_true", help="for a:<id>: remember the command's first two words")
148
+ sp = cmd("import", "Import hosts from ~/.ssh/config as pending targets (unsafe options dropped).")
149
+ sp.add_argument("--match", default="*")
150
+ cmd("rm", "Remove a target and its pinned host key.").add_argument("name")
151
+ cmd("test", "Probe a target: reachability, OS, shell, pinned host key.").add_argument("name")
152
+ sp = cmd("lease", "Human only: give a target a full shell for a time box (1-60 minutes).")
153
+ sp.add_argument("name")
154
+ sp.add_argument("--minutes", type=int, default=15)
155
+
156
+ sp = cmd("allow", "Human only: let a command run without asking (no words: list the rules).")
157
+ sp.add_argument("target", help="a name, a comma list, or tag:<tag>")
158
+ sp.add_argument("words", nargs="*", help="the command's leading words, e.g. systemctl status")
159
+ sp = cmd("deny", "Remove an allow rule, so the command asks again.")
160
+ sp.add_argument("target", help="a name, a comma list, or tag:<tag>")
161
+ sp.add_argument("words", nargs="+")
162
+
163
+ # The human's live view.
164
+ sp = cmd("watch", "Live control room: every command on every target, job output, approval prompts.")
165
+ sp.add_argument("--history", type=int, default=10, help="show this many past events first")
166
+ sp.add_argument("--no-prompt", action="store_true", help="show activity only; never ask")
167
+ cmd("doctor", "Check this machine's setup: config and state dirs, ssh, keychain, targets.")
168
+ sp = cmd("mcp", "Run the MCP server on stdio, or install it into an agent client.")
169
+ sp.add_argument(
170
+ "--approve-in-client",
171
+ action=argparse.BooleanOptionalAction,
172
+ default=True,
173
+ help="ask the human in the agent client (MCP elicitation) when a command needs approval",
174
+ )
175
+ mcp_sub = sp.add_subparsers(dest="mcp_command", metavar="<serve|install>")
176
+ mcp_sub.add_parser("serve", help="run the MCP server on stdio (the default)")
177
+ sp = mcp_sub.add_parser("install", help="add Conning to an agent client, and deny it raw ssh/scp")
178
+ sp.add_argument("client", choices=list(install.PLANNERS))
179
+ sp.add_argument("--dry-run", action="store_true", help="show what would change")
180
+
181
+ sp = cmd("help", "Show this help; --markdown prints the block cached in README.md.")
182
+ sp.add_argument("--markdown", action="store_true")
183
+ return p
184
+
185
+
186
+ def help_markdown() -> str:
187
+ """The CLI reference agents read from the README instead of calling --help (decision D10)."""
188
+ columns = os.environ.get("COLUMNS")
189
+ os.environ["COLUMNS"] = "100" # stable wrapping, so the README copy can be checked in tests
190
+ try:
191
+ parser = build_parser()
192
+ return _help_markdown(parser)
193
+ finally:
194
+ if columns is None:
195
+ del os.environ["COLUMNS"]
196
+ else:
197
+ os.environ["COLUMNS"] = columns
198
+
199
+
200
+ def _help_markdown(parser: argparse.ArgumentParser) -> str:
201
+ sub = next(a for a in parser._actions if isinstance(a, argparse._SubParsersAction))
202
+ out = ["```"]
203
+ for action in sub._choices_actions:
204
+ sp = sub.choices[action.dest]
205
+ usage = sp.format_usage().strip().removeprefix("usage: ").replace(" [-h]", "")
206
+ out += [usage, f" {action.help}", ""]
207
+ out[-1] = "```"
208
+ return "\n".join(out)
209
+
210
+
211
+ RUN_VALUE_OPTIONS = ("--wait", "--timeout", "--cwd", "--max-bytes", "--grep")
212
+ RUN_FLAGS = ("--shell",)
213
+
214
+
215
+ def _run_options(argv: list[str], i: int) -> int:
216
+ """Skip conn's own `run` options starting at argv[i]; return the index of the first other word."""
217
+ while i < len(argv):
218
+ word = argv[i]
219
+ if word in RUN_VALUE_OPTIONS:
220
+ i += 2
221
+ elif word in RUN_FLAGS or word.split("=", 1)[0] in RUN_VALUE_OPTIONS:
222
+ i += 1
223
+ else:
224
+ break
225
+ return i
226
+
227
+
228
+ def _split_remote_command(argv: list[str]) -> tuple[list[str], list[str] | None]:
229
+ """Cut off the remote command so argparse never sees its flags.
230
+
231
+ `conn run [options] <target> [options] <command...>`: conn's own options may sit on either side of
232
+ the target; the first other word starts the remote command, and everything from there on is passed
233
+ through, flags included, so `conn run lab uname -a` works without `--`. An explicit `--` is honoured
234
+ too (and needed on Python 3.11, whose argparse mishandles `--` before a subcommand's positionals).
235
+ """
236
+ if "--" in argv:
237
+ cut = argv.index("--")
238
+ return argv[:cut], argv[cut + 1 :]
239
+ if not argv or argv[0] != "run":
240
+ return argv, None
241
+ target = _run_options(argv, 1)
242
+ if target >= len(argv) or argv[target].startswith("-"):
243
+ return argv, None # no target (or -h): let argparse report it
244
+ command = _run_options(argv, target + 1)
245
+ return argv[:command], argv[command:]
246
+
247
+
248
+ def main(argv: list[str] | None = None) -> int:
249
+ parser = build_parser()
250
+ argv, rest = _split_remote_command(list(sys.argv[1:] if argv is None else argv))
251
+ args = parser.parse_args(argv)
252
+ if rest is not None:
253
+ if getattr(args, "command", None) != "run":
254
+ parser.error("'--' is only used with conn run")
255
+ args.argv = [*args.argv, *rest]
256
+ if not getattr(args, "func", None):
257
+ parser.print_help()
258
+ return 0
259
+ try:
260
+ return args.func(args)
261
+ except (OSError, TargetFileError, SecretError, SSHError) as e: # one clear line, never a traceback
262
+ print(f"error: {e}")
263
+ return 1