agent-lock-cli 0.1.1__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.
agent_lock/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Lock agent commands to the local HTTP proxy by running them as agentnet."""
2
+
3
+ __version__ = "0.1.1"
agent_lock/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
agent_lock/allow.py ADDED
@@ -0,0 +1,40 @@
1
+ """--allow-tcp values. Only 127.0.0.1:PORT and ::1:PORT."""
2
+
3
+ import argparse
4
+ import re
5
+
6
+ _VALUE = re.compile(r"^(127\.0\.0\.1|::1):([0-9]+)$")
7
+
8
+
9
+ def parse_allow_tcp_value(value):
10
+ """Return (host, port) or raise ValueError. Used by firewall renderers."""
11
+ match = _VALUE.match(value or "")
12
+ if not match:
13
+ raise ValueError(value)
14
+ port = int(match.group(2))
15
+ if match.group(2) != str(port):
16
+ # Reject leading zeros so the stored form is canonical.
17
+ raise ValueError(value)
18
+ if port < 1 or port > 65535:
19
+ raise ValueError(value)
20
+ return match.group(1), port
21
+
22
+
23
+ def allow_tcp_arg(value):
24
+ """argparse type. Illegal values are exit 2."""
25
+ try:
26
+ host, port = parse_allow_tcp_value(value)
27
+ except ValueError:
28
+ raise argparse.ArgumentTypeError(
29
+ "must be 127.0.0.1:PORT or ::1:PORT (port 1-65535), not {!r}".format(value))
30
+ return "{}:{}".format(host, port)
31
+
32
+
33
+ def port_arg(value):
34
+ try:
35
+ port = int(value)
36
+ except ValueError:
37
+ raise argparse.ArgumentTypeError("port must be an integer")
38
+ if str(port) != str(value).lstrip("+") or port < 1 or port > 65535:
39
+ raise argparse.ArgumentTypeError("port must be 1-65535")
40
+ return port
agent_lock/cli.py ADDED
@@ -0,0 +1,181 @@
1
+ """agent-lock command line. Help text and examples come from this module."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from . import __version__
7
+ from .allow import allow_tcp_arg, port_arg
8
+ from .completion import SHELLS, script
9
+ from .errors import AgentLockError
10
+ from .install import cmd_bind_bin, cmd_doctor, cmd_exec, cmd_install, cmd_uninstall
11
+
12
+ COMMAND_GROUPS = (
13
+ ("Setup", (
14
+ ("install", "install the account, firewall, and wrappers"),
15
+ ("uninstall", "remove the lock and keep linked directories"),
16
+ ("bind-bin", "mark a directory setgid for new launchers"),
17
+ ("completion", "print a shell completion script"),
18
+ )),
19
+ ("Everyday", (
20
+ ("doctor", "check the lock and repair cleared ACLs"),
21
+ ("exec", "run one command as agentnet"),
22
+ )),
23
+ )
24
+ COMMAND_SUMMARY = {name: summary for _title, commands in COMMAND_GROUPS for name, summary in commands}
25
+
26
+ TOP_EXAMPLES = (
27
+ ("agent-lock install", "install the lock"),
28
+ ("agent-lock doctor", "check the lock"),
29
+ ("agent-lock exec -- true", "run one command as agentnet"),
30
+ ("agent-lock completion bash", "print a bash script"),
31
+ )
32
+
33
+ COMMAND_EXAMPLES = {
34
+ "install": (
35
+ ("agent-lock install", "install lock"),
36
+ ("agent-lock install --port 7897 --allow-tcp 127.0.0.1:11434", "pin the port"),
37
+ ("agent-lock install --controller 127.0.0.1:9097 --secret-stdin", "read secret"),
38
+ ("agent-lock install --share-dir ~/.cx --trust-exec ~/bin", "share paths"),
39
+ ),
40
+ "uninstall": (
41
+ ("agent-lock uninstall", "remove the lock"),
42
+ ("agent-lock uninstall", "safe to repeat"),
43
+ ),
44
+ "doctor": (
45
+ ("agent-lock doctor", "run every check"),
46
+ ("agent-lock doctor --json", "print version 1 JSON"),
47
+ ("agent-lock doctor --repair", "restore cleared ACLs"),
48
+ ),
49
+ "exec": (
50
+ ("agent-lock exec -- true", "run true as agentnet"),
51
+ ("agent-lock exec -- curl -I https://example.com", "only via the proxy"),
52
+ ("agent-lock exec -- git status", "run git as agentnet"),
53
+ ),
54
+ "bind-bin": (
55
+ ("sudo agent-lock bind-bin ~/.local/bin", "setgid this directory"),
56
+ ("sudo agent-lock bind-bin /tmp/bin", "clear group write again"),
57
+ ),
58
+ "completion": (
59
+ ("eval \"$(agent-lock completion bash)\"", "add this to ~/.bashrc"),
60
+ ("eval \"$(agent-lock completion zsh)\"", "run this after compinit"),
61
+ ("agent-lock completion fish | source", "load the fish script"),
62
+ ("agent-lock completion powershell", "print the PowerShell script"),
63
+ ),
64
+ }
65
+
66
+ ALLOW_TCP_HELP = (
67
+ "loopback TCP port to allow (127.0.0.1:PORT or ::1:PORT). "
68
+ "A loopback service that then dials the network is a second exit beside the proxy."
69
+ )
70
+
71
+
72
+ def _examples_text(examples):
73
+ """examples: epilog. Command lines are padded to the longest plus two."""
74
+ width = max(len(command) for command, _summary in examples) + 2
75
+ lines = ["examples:"]
76
+ lines.extend(" {}{}".format(command.ljust(width), summary) for command, summary in examples)
77
+ return "\n".join(lines) + "\n"
78
+
79
+
80
+ def _grouped_commands_text():
81
+ width = max(len(name) for name in COMMAND_SUMMARY) + 2
82
+ lines = []
83
+ for title, commands in COMMAND_GROUPS:
84
+ lines.append("{}:".format(title))
85
+ lines.extend(" {}{}".format(name.ljust(width), summary) for name, summary in commands)
86
+ lines.append("")
87
+ return "\n".join(lines).rstrip("\n")
88
+
89
+
90
+ HELP_EXAMPLES = _examples_text(TOP_EXAMPLES) + "\nRun agent-lock COMMAND -h for more examples.\n"
91
+
92
+
93
+ def build_parser():
94
+ parser = argparse.ArgumentParser(
95
+ prog="agent-lock",
96
+ allow_abbrev=False,
97
+ formatter_class=argparse.RawDescriptionHelpFormatter,
98
+ description="Lock agent commands to the local HTTP proxy by running them as the user agentnet.\n\n"
99
+ + _grouped_commands_text(),
100
+ epilog=HELP_EXAMPLES,
101
+ )
102
+ parser.add_argument("--version", action="version", version="%(prog)s " + __version__)
103
+ sub = parser.add_subparsers(dest="command", metavar="COMMAND")
104
+ sub.required = True
105
+
106
+ install = sub.add_parser("install", description=COMMAND_SUMMARY["install"], allow_abbrev=False)
107
+ install.add_argument("--port", type=port_arg, help="mixed port; required when discovery fails")
108
+ install.add_argument("--controller", help="loopback controller (host:port or unix:PATH)")
109
+ install.add_argument("--secret-stdin", action="store_true", help="read one secret line from stdin")
110
+ install.add_argument("--claude-dir", action="append", default=None, help="extra Claude settings directory")
111
+ install.add_argument("--share-dir", action="append", default=None, help="extra directory to share with agentnet")
112
+ install.add_argument("--trust-exec", action="append", default=None, help="program or directory agentnet may execute")
113
+ install.add_argument("--allow-tcp", action="append", default=None, type=allow_tcp_arg, help=ALLOW_TCP_HELP)
114
+
115
+ sub.add_parser("uninstall", description=COMMAND_SUMMARY["uninstall"], allow_abbrev=False)
116
+
117
+ doctor = sub.add_parser("doctor", description=COMMAND_SUMMARY["doctor"], allow_abbrev=False)
118
+ doctor.add_argument("--json", action="store_true", help="print version 1 JSON")
119
+ doctor.add_argument("--repair", action="store_true", help="reapply credential ACLs; does not change the port")
120
+
121
+ sub.add_parser("exec", description=COMMAND_SUMMARY["exec"], allow_abbrev=False)
122
+
123
+ bind = sub.add_parser("bind-bin", description=COMMAND_SUMMARY["bind-bin"], allow_abbrev=False)
124
+ bind.add_argument("path", metavar="PATH", help="directory to mark setgid for the agentnet group")
125
+
126
+ completion = sub.add_parser("completion", description=COMMAND_SUMMARY["completion"], allow_abbrev=False)
127
+ completion.add_argument("shell", choices=SHELLS, help="bash, zsh, fish, or powershell")
128
+
129
+ for name, subparser in sub.choices.items():
130
+ subparser.formatter_class = argparse.RawDescriptionHelpFormatter
131
+ subparser.epilog = _examples_text(COMMAND_EXAMPLES[name])
132
+ subparser.allow_abbrev = False
133
+ return parser
134
+
135
+
136
+ def _split_exec(argv):
137
+ """Drop the command tail at the first --. Flags such as -h stay on the parser side."""
138
+ if not argv or argv[0] != "exec":
139
+ return argv, None
140
+ if "--" not in argv:
141
+ return argv, None
142
+ index = argv.index("--")
143
+ return argv[:index] or ["exec"], argv[index + 1:]
144
+
145
+
146
+ def main(argv=None):
147
+ if argv is None:
148
+ argv = sys.argv[1:]
149
+ argv = list(argv)
150
+ argv, tail = _split_exec(argv)
151
+ try:
152
+ parser = build_parser()
153
+ if argv and argv[0] == "completion":
154
+ # Print the script even when agent-lock is not installed.
155
+ args = parser.parse_args(argv)
156
+ sys.stdout.write(script(args.shell, parser))
157
+ return 0
158
+ args = parser.parse_args(argv)
159
+ except SystemExit as exc:
160
+ code = exc.code
161
+ if code is None:
162
+ return 0
163
+ return int(code)
164
+ try:
165
+ if args.command == "install":
166
+ return cmd_install(args)
167
+ if args.command == "uninstall":
168
+ return cmd_uninstall(args)
169
+ if args.command == "doctor":
170
+ return cmd_doctor(args)
171
+ if args.command == "exec":
172
+ return cmd_exec(tail)
173
+ if args.command == "bind-bin":
174
+ return cmd_bind_bin(args)
175
+ if args.command == "completion":
176
+ sys.stdout.write(script(args.shell, parser))
177
+ return 0
178
+ except AgentLockError as exc:
179
+ sys.stderr.write("agent-lock: {}\n".format(exc))
180
+ return int(exc.code)
181
+ return 2
@@ -0,0 +1,414 @@
1
+ """Generate bash, zsh, fish, and PowerShell completion from the argparse parser.
2
+
3
+ Subcommand names, options, and choices are read when the script is generated.
4
+ There is no second word list and no account lookup. Filename completion is only
5
+ for --claude-dir, --share-dir, --trust-exec, and the bind-bin positional.
6
+ """
7
+
8
+ import argparse
9
+
10
+ SHELLS = ("bash", "zsh", "fish", "powershell")
11
+
12
+ # These options take a path. Every other valued option completes nothing.
13
+ PATH_OPTIONS = ("--claude-dir", "--share-dir", "--trust-exec")
14
+ # Printed as separate `return $null` statements, in this order, then exec --.
15
+ NO_FILE_OPTIONS = ("--port", "--allow-tcp", "--controller")
16
+
17
+
18
+ def command_spec(parser):
19
+ """Return {subcommand: (options, valued options)}. Help actions are skipped."""
20
+ spec = {}
21
+ for action in parser._actions: # noqa: SLF001 — argparse has no public walker
22
+ if not isinstance(action, argparse._SubParsersAction): # noqa: SLF001
23
+ continue
24
+ for name, sub in action.choices.items():
25
+ options = []
26
+ valued = []
27
+ for sub_action in sub._actions: # noqa: SLF001
28
+ if not sub_action.option_strings or sub_action.help == argparse.SUPPRESS:
29
+ continue
30
+ if isinstance(sub_action, argparse._HelpAction): # noqa: SLF001
31
+ continue
32
+ options.extend(sub_action.option_strings)
33
+ if sub_action.nargs != 0:
34
+ valued.extend(sub_action.option_strings)
35
+ spec[name] = (options, valued)
36
+ return spec
37
+
38
+
39
+ def _positional_choices(parser):
40
+ """Choice words, and bind-bin's path positional, from one subparser walk."""
41
+ found = {}
42
+ files = []
43
+ for action in parser._actions: # noqa: SLF001
44
+ if not isinstance(action, argparse._SubParsersAction): # noqa: SLF001
45
+ continue
46
+ for name, sub in action.choices.items():
47
+ index = 0
48
+ for sub_action in sub._actions: # noqa: SLF001
49
+ if sub_action.option_strings or sub_action.help == argparse.SUPPRESS:
50
+ continue
51
+ if isinstance(sub_action, argparse._HelpAction): # noqa: SLF001
52
+ continue
53
+ if sub_action.choices and sub_action.nargs in (None, "?"):
54
+ found[(name, index)] = [str(choice) for choice in sub_action.choices]
55
+ elif not sub_action.choices and name == "bind-bin" and index == 0:
56
+ files.append((name, index))
57
+ if sub_action.nargs != "*":
58
+ index += 1
59
+ return found, files
60
+
61
+
62
+ def _path_options(spec):
63
+ present = []
64
+ valued = set()
65
+ for _name, pair in spec.items():
66
+ valued.update(pair[1])
67
+ for option in PATH_OPTIONS:
68
+ if option in valued:
69
+ present.append(option)
70
+ return present
71
+
72
+
73
+ def _no_file_options(spec):
74
+ valued = set()
75
+ for _name, pair in spec.items():
76
+ valued.update(pair[1])
77
+ return [option for option in NO_FILE_OPTIONS if option in valued]
78
+
79
+
80
+ def script(shell, parser):
81
+ spec = command_spec(parser)
82
+ choices, files = _positional_choices(parser)
83
+ if shell == "bash":
84
+ return _bash(spec, choices, files)
85
+ if shell == "zsh":
86
+ return _zsh(spec, choices, files)
87
+ if shell == "fish":
88
+ return _fish(spec, choices, files)
89
+ if shell == "powershell":
90
+ return _powershell(spec, choices, files)
91
+ raise ValueError(shell)
92
+
93
+
94
+ def _words(items):
95
+ return " ".join(items)
96
+
97
+
98
+ def _option_cases_bash(spec):
99
+ lines = []
100
+ for name, pair in sorted(spec.items()):
101
+ lines.append(" {name}) opts={opts}; vopts={vopts} ;;".format(
102
+ name=name,
103
+ opts=_sq(_words(pair[0])),
104
+ vopts=_sq(_words(pair[1])),
105
+ ))
106
+ return "\n".join(lines)
107
+
108
+
109
+ def _sq(text):
110
+ return "'" + text.replace("'", "'\\''") + "'"
111
+
112
+
113
+ def _bash(spec, choices, files):
114
+ commands = sorted(spec)
115
+ path_pattern = "|".join(_path_options(spec)) or "__no_path_option__"
116
+ no_file_pattern = "|".join(_no_file_options(spec)) or "__no_value_option__"
117
+ positional = []
118
+ for key, words in sorted(choices.items()):
119
+ positional.append(" {cmd}:{index}) candidates={words} ;;".format(
120
+ cmd=key[0], index=key[1], words=_sq(_words(words))))
121
+ for command, index in files:
122
+ positional.append(
123
+ " {cmd}:{index}) COMPREPLY=($(compgen -f -- \"$cur\")); return 0 ;;".format(
124
+ cmd=command, index=index))
125
+ return """# bash completion for agent-lock. Generated from the parser. No bash-completion package.
126
+ _agent_lock() {{
127
+ # Split the raw line on whitespace. The default word breaks include a colon,
128
+ # which would cut 127.0.0.1:PORT and ::1:PORT into extra words.
129
+ local line="${{COMP_LINE:0:COMP_POINT}}"
130
+ local cur="${{line##* }}"
131
+ local before="${{line%%"$cur"}}"
132
+ local -a words
133
+ read -r -a words <<< "$before"
134
+ local cmd="" i w
135
+ for ((i = 1; i < ${{#words[@]}}; i++)); do
136
+ if [[ "${{words[i]}}" != -* ]]; then cmd="${{words[i]}}"; break; fi
137
+ done
138
+ local opts="" vopts=""
139
+ case "$cmd" in
140
+ {option_cases}
141
+ esac
142
+ local npos=0 skip=0 prev=""
143
+ for ((i = i + 1; i < ${{#words[@]}}; i++)); do
144
+ w="${{words[i]}}"
145
+ if ((skip)); then skip=0; prev=""; continue; fi
146
+ if [[ "$w" == "--" ]]; then npos=-1; break; fi
147
+ if [[ "$w" == -* ]]; then
148
+ if [[ " $vopts " == *" $w "* ]]; then skip=1; prev="$w"; fi
149
+ continue
150
+ fi
151
+ ((npos++))
152
+ done
153
+ local candidates=""
154
+ if ((skip)); then
155
+ case "$prev" in
156
+ {path_pattern}) COMPREPLY=($(compgen -f -- "$cur")); return 0 ;;
157
+ {no_file_pattern}) COMPREPLY=(); return 0 ;;
158
+ *) COMPREPLY=(); return 0 ;;
159
+ esac
160
+ elif [[ -z "$cmd" ]]; then
161
+ candidates="{commands} --help --version"
162
+ elif ((npos < 0)); then
163
+ COMPREPLY=(); return 0
164
+ elif [[ "$cur" == -* ]]; then
165
+ candidates="$opts"
166
+ else
167
+ case "$cmd:$npos" in
168
+ {positional}
169
+ esac
170
+ fi
171
+ COMPREPLY=($(compgen -W "$candidates" -- "$cur"))
172
+ local j prefix=""
173
+ for ((j = ${{#cur}} - 1; j >= 0; j--)); do
174
+ case "$COMP_WORDBREAKS" in
175
+ *"${{cur:j:1}}"*) prefix="${{cur:0:j+1}}"; break ;;
176
+ esac
177
+ done
178
+ if [[ -n "$prefix" ]]; then
179
+ for i in "${{!COMPREPLY[@]}}"; do COMPREPLY[i]="${{COMPREPLY[i]#"$prefix"}}"; done
180
+ fi
181
+ }}
182
+ complete -F _agent_lock agent-lock
183
+ """.format(
184
+ option_cases=_option_cases_bash(spec),
185
+ commands=_words(commands),
186
+ path_pattern=path_pattern,
187
+ no_file_pattern=no_file_pattern,
188
+ positional="\n".join(positional),
189
+ )
190
+
191
+
192
+ def _zsh(spec, choices, files):
193
+ commands = sorted(spec)
194
+ option_cases = []
195
+ for name, pair in sorted(spec.items()):
196
+ option_cases.append(" {name}) opts=({opts}); vopts=({vopts}) ;;".format(
197
+ name=name,
198
+ opts=_words([_sq(item) for item in pair[0]]),
199
+ vopts=_words([_sq(item) for item in pair[1]]),
200
+ ))
201
+ path_pattern = "|".join(_path_options(spec)) or "__no_path_option__"
202
+ positional = []
203
+ for key, words in sorted(choices.items()):
204
+ positional.append(" {cmd}:{index}) candidates=({words}) ;;".format(
205
+ cmd=key[0], index=key[1], words=_words([_sq(word) for word in words])))
206
+ for command, index in files:
207
+ positional.append(" {cmd}:{index}) _files; return ;;".format(cmd=command, index=index))
208
+ return """#compdef agent-lock
209
+ # zsh completion for agent-lock. Generated from the parser.
210
+ _agent_lock() {{
211
+ local cmd="" i w
212
+ local -a opts vopts candidates
213
+ for ((i = 2; i < CURRENT; i++)); do
214
+ if [[ "${{words[i]}}" != -* ]]; then cmd="${{words[i]}}"; break; fi
215
+ done
216
+ case "$cmd" in
217
+ {option_cases}
218
+ esac
219
+ local npos=0 skip=0 prev=""
220
+ for ((i = i + 1; i < CURRENT; i++)); do
221
+ w="${{words[i]}}"
222
+ if ((skip)); then skip=0; prev=""; continue; fi
223
+ if [[ "$w" == "--" ]]; then npos=-1; break; fi
224
+ if [[ "$w" == -* ]]; then
225
+ if (( ${{vopts[(Ie)$w]}} )); then skip=1; prev="$w"; fi
226
+ continue
227
+ fi
228
+ ((npos++))
229
+ done
230
+ local cur="${{words[CURRENT]}}"
231
+ if ((skip)); then
232
+ case "$prev" in
233
+ {path_pattern}) _files; return ;;
234
+ *) return ;;
235
+ esac
236
+ elif [[ -z "$cmd" ]]; then
237
+ candidates=({commands} --help --version)
238
+ elif ((npos < 0)); then
239
+ return
240
+ elif [[ "$cur" == -* ]]; then
241
+ candidates=($opts)
242
+ else
243
+ case "$cmd:$npos" in
244
+ {positional}
245
+ esac
246
+ fi
247
+ compadd -- $candidates
248
+ }}
249
+ compdef _agent_lock agent-lock
250
+ """.format(
251
+ option_cases="\n".join(option_cases),
252
+ commands=_words(commands),
253
+ path_pattern=path_pattern,
254
+ positional="\n".join(positional),
255
+ )
256
+
257
+
258
+ def _fish(spec, choices, files):
259
+ commands = sorted(spec)
260
+ lines = [
261
+ "# fish completion for agent-lock. Generated from the parser.",
262
+ "complete -c agent-lock -f",
263
+ "function __agent_lock_positionals",
264
+ " set -l tokens (commandline -opc)",
265
+ " set -l value_options $argv",
266
+ " set -l seen 0",
267
+ " set -l skip 0",
268
+ " set -l count 0",
269
+ " for token in $tokens[2..-1]",
270
+ " if test $seen -eq 0",
271
+ " if not string match -q -- '-*' $token",
272
+ " set seen 1",
273
+ " end",
274
+ " continue",
275
+ " end",
276
+ " if test $skip -eq 1",
277
+ " set skip 0",
278
+ " continue",
279
+ " end",
280
+ " if test \"$token\" = '--'",
281
+ " echo -1",
282
+ " return",
283
+ " end",
284
+ " if string match -q -- '-*' $token",
285
+ " if contains -- $token $value_options",
286
+ " set skip 1",
287
+ " end",
288
+ " continue",
289
+ " end",
290
+ " set count (math $count + 1)",
291
+ " end",
292
+ " echo $count",
293
+ "end",
294
+ "complete -c agent-lock -n __fish_use_subcommand -a {}".format(_sq(_words(commands))),
295
+ "complete -c agent-lock -n __fish_use_subcommand -l help -l version",
296
+ ]
297
+ paths = set(_path_options(spec))
298
+ for name, pair in sorted(spec.items()):
299
+ condition = "__fish_seen_subcommand_from {}".format(name)
300
+ for option in pair[0]:
301
+ flag = "-l {}".format(option[2:]) if option.startswith("--") else "-s {}".format(option[1:])
302
+ requires = " -r" if option in pair[1] else ""
303
+ files_flag = " -F" if option in paths else ""
304
+ lines.append("complete -c agent-lock -n {} {}{}{}".format(
305
+ _sq(condition), flag, requires, files_flag))
306
+ for key, words in sorted(choices.items()):
307
+ if key[0] != name:
308
+ continue
309
+ guard = "{}; and test (__agent_lock_positionals {}) -eq {}".format(
310
+ condition, _words(pair[1]), key[1])
311
+ lines.append("complete -c agent-lock -n {} -a {}".format(
312
+ _sq(guard), _sq(_words(words))))
313
+ for command, index in files:
314
+ pair = spec.get(command, ([], []))
315
+ guard = "__fish_seen_subcommand_from {}; and test (__agent_lock_positionals {}) -eq {}".format(
316
+ command, _words(pair[1]), index)
317
+ lines.append("complete -c agent-lock -n {} -a '(__fish_complete_path)'".format(_sq(guard)))
318
+ return "\n".join(lines) + "\n"
319
+
320
+
321
+ def _ps_quote(text):
322
+ return "'" + text.replace("'", "''") + "'"
323
+
324
+
325
+ def _ps_array(items):
326
+ return "@(" + ", ".join(_ps_quote(item) for item in items) + ")"
327
+
328
+
329
+ def _powershell(spec, choices, files):
330
+ """ASCII only. No-file positions return $null. Path positions bare-return."""
331
+ commands = sorted(spec)
332
+ option_lines = []
333
+ value_lines = []
334
+ for name in commands:
335
+ if spec[name][0]:
336
+ option_lines.append(" {} = {}".format(_ps_quote(name), _ps_array(spec[name][0])))
337
+ if spec[name][1]:
338
+ value_lines.append(" {} = {}".format(_ps_quote(name), _ps_array(spec[name][1])))
339
+ choice_lines = []
340
+ for key, words in sorted(choices.items()):
341
+ choice_lines.append(" {} = {}".format(
342
+ _ps_quote("{}:{}".format(key[0], key[1])), _ps_array(words)))
343
+ path_checks = []
344
+ for option in _path_options(spec):
345
+ path_checks.append(" if ($skip -and $lastOpt -eq {}) {{ return }}".format(_ps_quote(option)))
346
+ for command, index in files:
347
+ path_checks.append(
348
+ " if ($cmd -eq {cmd} -and -not $skip -and -not $afterDd -and"
349
+ " -not $wordToComplete.StartsWith('-') -and $npos -eq {index}) {{ return }}".format(
350
+ cmd=_ps_quote(command), index=index))
351
+ null_checks = []
352
+ for option in _no_file_options(spec):
353
+ null_checks.append(
354
+ " if ($skip -and $lastOpt -eq {}) {{ return $null }}".format(_ps_quote(option)))
355
+ null_checks.append(" if ($cmd -eq 'exec' -and $afterDd) { return $null }")
356
+ lines = [
357
+ "# agent-lock PowerShell completion. ASCII only.",
358
+ "Register-ArgumentCompleter -Native -CommandName agent-lock, agent-lock.exe -ScriptBlock {",
359
+ " param($wordToComplete, $commandAst, $cursorPosition)",
360
+ " $commands = {}".format(_ps_array(commands)),
361
+ " $options = @{",
362
+ ]
363
+ lines.extend(option_lines)
364
+ lines.extend([
365
+ " }",
366
+ " $valueOptions = @{",
367
+ ])
368
+ lines.extend(value_lines)
369
+ lines.extend([
370
+ " }",
371
+ " $positional = @{",
372
+ ])
373
+ lines.extend(choice_lines)
374
+ lines.extend([
375
+ " }",
376
+ " $before = @($commandAst.CommandElements | Where-Object { $_.Extent.EndOffset -lt $cursorPosition } |",
377
+ " ForEach-Object { $_.Extent.Text })",
378
+ " $cmd = ''",
379
+ " $npos = 0",
380
+ " $skip = $false",
381
+ " $afterDd = $false",
382
+ " $lastOpt = ''",
383
+ " foreach ($w in ($before | Select-Object -Skip 1)) {",
384
+ " if ($cmd -eq '') {",
385
+ " if (-not $w.StartsWith('-')) { $cmd = $w }",
386
+ " continue",
387
+ " }",
388
+ " if ($w -eq '--') { $afterDd = $true; break }",
389
+ " if ($skip) { $skip = $false; continue }",
390
+ " if ($w.StartsWith('-')) {",
391
+ " if ($valueOptions[$cmd] -contains $w) { $skip = $true; $lastOpt = $w }",
392
+ " } else {",
393
+ " $npos++",
394
+ " }",
395
+ " }",
396
+ ])
397
+ lines.extend(path_checks)
398
+ lines.extend(null_checks)
399
+ lines.extend([
400
+ " $candidates = @()",
401
+ " if ($cmd -eq '') {",
402
+ " $candidates = $commands",
403
+ " } elseif ($wordToComplete.StartsWith('-')) {",
404
+ " $candidates = $options[$cmd]",
405
+ " } else {",
406
+ " $candidates = $positional[($cmd + ':' + $npos)]",
407
+ " }",
408
+ " $candidates | Where-Object { $_ -and $_.StartsWith($wordToComplete, "
409
+ "[System.StringComparison]::OrdinalIgnoreCase) } |",
410
+ " ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }",
411
+ "}",
412
+ "",
413
+ ])
414
+ return "\n".join(lines)