devstuff 2.0.0__py3-none-any.whl → 2.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.
dev_setup/cli.py CHANGED
@@ -1,12 +1,13 @@
1
1
  import click
2
2
 
3
- from dev_setup import __version__
3
+ from dev_setup import __version__, verbose
4
4
 
5
5
 
6
6
  @click.group(
7
7
  invoke_without_command=True,
8
8
  context_settings={"help_option_names": ["-h", "--help"]},
9
9
  )
10
+ @verbose.option
10
11
  @click.pass_context
11
12
  def cli(ctx: click.Context) -> None:
12
13
  if ctx.invoked_subcommand is None:
@@ -54,5 +55,31 @@ def _register_commands() -> None:
54
55
  cli.add_command(skills_cmd, "skills")
55
56
  cli.add_command(agent_cmd, "agent")
56
57
 
58
+ _add_verbose_option(cli)
59
+
60
+
61
+ def _add_verbose_option(group: click.Group) -> None:
62
+ """Give every command and subcommand `-v`/`-vv`, recursively.
63
+
64
+ Done centrally rather than by decorating each command so that the flag is accepted
65
+ everywhere it could plausibly be typed — `devstuff -v install x`, `devstuff install
66
+ -v x`, `devstuff functions -v enable k` all work, and no command can be forgotten.
67
+ Aliases (`remove`/`uninstall`) share one Command object, hence the seen-set.
68
+ """
69
+ seen: set[int] = set()
70
+
71
+ def walk(cmd: click.Command) -> None:
72
+ if id(cmd) in seen:
73
+ return
74
+ seen.add(id(cmd))
75
+ if not any("--verbose" in p.opts for p in cmd.params):
76
+ verbose.option(cmd)
77
+ if isinstance(cmd, click.Group):
78
+ for sub in cmd.commands.values():
79
+ walk(sub)
80
+
81
+ for command in group.commands.values():
82
+ walk(command)
83
+
57
84
 
58
85
  _register_commands()
@@ -11,13 +11,9 @@ from dev_setup.base import Tool
11
11
 
12
12
 
13
13
  @click.command("install")
14
- @click.option("--verbose", "-v", is_flag=True, help="Stream install output to the terminal.")
15
14
  @click.argument("packages", nargs=-1)
16
- def install_cmd(packages: tuple[str, ...], verbose: bool) -> None:
15
+ def install_cmd(packages: tuple[str, ...]) -> None:
17
16
  """Install packages. Interactive picker when called with no arguments."""
18
- from dev_setup import generic
19
- generic._verbose = verbose
20
-
21
17
  if not packages:
22
18
  _install_interactive()
23
19
  else:
@@ -9,13 +9,9 @@ from dev_setup.base import Tool
9
9
 
10
10
 
11
11
  @click.command("remove")
12
- @click.option("--verbose", "-v", is_flag=True, help="Stream removal output to the terminal.")
13
12
  @click.argument("packages", nargs=-1)
14
- def remove_cmd(packages: tuple[str, ...], verbose: bool) -> None:
13
+ def remove_cmd(packages: tuple[str, ...]) -> None:
15
14
  """Uninstall installed packages."""
16
- from dev_setup import generic
17
- generic._verbose = verbose
18
-
19
15
  if not packages:
20
16
  ui.error("Specify at least one package key. See: devstuff list --installed")
21
17
  sys.exit(1)
@@ -1,11 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import subprocess
3
4
  import sys
4
5
 
5
6
  import click
6
7
 
7
8
  from dev_setup import function_runner as runner
8
- from dev_setup import functions_registry, ui
9
+ from dev_setup import functions_registry, ui, verbose
9
10
  from dev_setup.function_runner import ParamResolutionError
10
11
  from dev_setup.functions_registry import FunctionDef, FunctionParam
11
12
 
@@ -20,6 +21,8 @@ def run_cmd(key: str, args: tuple[str, ...]) -> None:
20
21
  ui.error(f"Unknown function: '{key}'")
21
22
  sys.exit(1)
22
23
 
24
+ verbose.trace(f"function '{fn.key}': type={fn.type} register={fn.register} args={list(args)}")
25
+
23
26
  if fn.type == "script":
24
27
  _run_script(fn, args)
25
28
  elif fn.type == "shell-eval" and fn.register == "eval":
@@ -49,6 +52,12 @@ def _run_script(fn: FunctionDef, args: tuple[str, ...]) -> None:
49
52
  except ParamResolutionError as exc:
50
53
  ui.error(str(exc))
51
54
  sys.exit(1)
55
+ except subprocess.CalledProcessError as exc:
56
+ # devstuff's own exit code is always 1 here (a function can't signal a result
57
+ # through it), so the script's real exit code is only visible under -v.
58
+ ui.error(f"'{fn.name}' failed: {exc}")
59
+ verbose.log(f"'{fn.key}' exited {exc.returncode}")
60
+ sys.exit(1)
52
61
  except Exception as exc:
53
62
  ui.error(f"'{fn.name}' failed: {exc}")
54
63
  sys.exit(1)
@@ -12,17 +12,13 @@ from dev_setup.generic import UpdateStatus
12
12
 
13
13
 
14
14
  @click.command("update")
15
- @click.option("--verbose", "-v", is_flag=True, help="Stream update output to the terminal.")
16
15
  @click.option(
17
16
  "--version", "target_version", default=None,
18
17
  help="Update to a specific version instead of latest. Only valid with a single package.",
19
18
  )
20
19
  @click.argument("packages", nargs=-1)
21
- def update_cmd(packages: tuple[str, ...], verbose: bool, target_version: str | None) -> None:
20
+ def update_cmd(packages: tuple[str, ...], target_version: str | None) -> None:
22
21
  """Update packages. Interactive picker with recommended updates when called with no arguments."""
23
- from dev_setup import generic
24
- generic._verbose = verbose
25
-
26
22
  if not packages:
27
23
  if target_version:
28
24
  ui.error("--version requires a package key.")
@@ -6,6 +6,7 @@ import subprocess
6
6
  import tempfile
7
7
  from collections.abc import Callable
8
8
 
9
+ from dev_setup import verbose
9
10
  from dev_setup.base import patch_bashrc, remove_bashrc_block
10
11
  from dev_setup.functions_registry import FunctionDef, FunctionParam
11
12
 
@@ -70,7 +71,12 @@ def render_eval_script(fn: FunctionDef, args: tuple[str, ...]) -> str:
70
71
  clean for `eval "$(...)"` capture, so missing required params raise instead."""
71
72
  values = resolve_params(fn.params, args)
72
73
  prelude = _literal_prelude(fn.params, values)
73
- return f"{prelude}\n{fn.script}" if prelude else fn.script
74
+ script = f"{prelude}\n{fn.script}" if prelude else fn.script
75
+ # Logged, never traced with `set -x`: this text is evaluated by the *caller's*
76
+ # interactive shell, where enabling xtrace would persist after the function returns.
77
+ verbose.log(f"eval script for '{fn.key}':")
78
+ verbose.block(script, minimum=verbose.VERBOSE)
79
+ return script
74
80
 
75
81
 
76
82
  def _bashrc_prelude(fn: FunctionDef) -> list[str]:
@@ -106,7 +112,10 @@ def render_bashrc_function(fn: FunctionDef) -> str:
106
112
 
107
113
 
108
114
  def enable_bashrc_function(fn: FunctionDef) -> bool:
109
- return patch_bashrc(f"devstuff-fn:{fn.key}", render_bashrc_function(fn))
115
+ block = render_bashrc_function(fn)
116
+ verbose.log(f"patching ~/.bashrc with devstuff-fn:{fn.key}")
117
+ verbose.block(block)
118
+ return patch_bashrc(f"devstuff-fn:{fn.key}", block)
110
119
 
111
120
 
112
121
  def disable_bashrc_function(fn: FunctionDef) -> bool:
@@ -137,13 +146,25 @@ def run_script_function(
137
146
  with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False) as f:
138
147
  f.write(content)
139
148
  tmp = f.name
149
+
150
+ # `bash -x` at -vv traces every expanded command inside the function to stderr.
151
+ # A function is an opaque blob of someone else's shell until you can see that;
152
+ # `capture` mode gets it too, since the trace lands in the returned text.
153
+ cmd = ["bash", *(["-x"] if verbose.enabled(verbose.TRACE) else []), tmp, *values]
154
+ verbose.command(cmd)
155
+ verbose.trace(f"params: {_describe_params(fn.params, values)}")
156
+ verbose.block(content)
140
157
  try:
141
158
  if capture:
142
- proc = subprocess.run(
143
- ["bash", tmp, *values], check=True, capture_output=True, text=True
144
- )
159
+ proc = subprocess.run(cmd, check=True, capture_output=True, text=True)
145
160
  return ((proc.stdout or "") + (proc.stderr or "")).strip()
146
- subprocess.run(["bash", tmp, *values], check=True)
161
+ subprocess.run(cmd, check=True)
147
162
  return None
148
163
  finally:
149
164
  os.unlink(tmp)
165
+
166
+
167
+ def _describe_params(params: list[FunctionParam], values: list[str]) -> str:
168
+ if not params:
169
+ return "(none)"
170
+ return " ".join(f"{p.name}={v!r}" for p, v in zip(params, values, strict=True))
dev_setup/functions.yaml CHANGED
@@ -88,6 +88,135 @@ functions:
88
88
  pi -p "/dogfood $url $instruction"
89
89
  docs_url: https://pi.dev/docs/latest
90
90
 
91
+ whats-on-port:
92
+ name: What's On This Port
93
+ description: Find which process is listening on a port (and whether you can see it)
94
+ category: network
95
+ type: script
96
+ params:
97
+ - name: port
98
+ description: Port number to look up (1-65535)
99
+ required: true
100
+ - name: protocol
101
+ description: tcp, udp, or all (default all)
102
+ required: false
103
+ default: all
104
+ script: |
105
+ # Deliberately no `set -e`: this script's whole job is to tell "in use" from
106
+ # "free" from "cannot tell", which is exit-code work that -e would abort.
107
+ set -uo pipefail
108
+
109
+ case "$port" in
110
+ ''|*[!0-9]*)
111
+ echo "Port must be a number between 1 and 65535, got: ${port:-<empty>}" >&2
112
+ exit 2
113
+ ;;
114
+ esac
115
+ if [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then
116
+ echo "Port must be between 1 and 65535, got: $port" >&2
117
+ exit 2
118
+ fi
119
+
120
+ case "${protocol:-all}" in
121
+ tcp) flags="-tlpnH" ;;
122
+ udp) flags="-ulpnH" ;;
123
+ all|"") flags="-tulpnH"; protocol="all" ;;
124
+ *) echo "Protocol must be tcp, udp or all, got: $protocol" >&2; exit 2 ;;
125
+ esac
126
+
127
+ # ss ships in iproute2, which is not in the devstuff catalog and is not on every
128
+ # minimal image (this repo's own CI container has lsof but no ss), so say what to
129
+ # install rather than letting "command not found" surface.
130
+ if ! command -v ss >/dev/null 2>&1; then
131
+ echo "ss is required to look up sockets (it is part of iproute2)." >&2
132
+ echo "Install it with: sudo apt-get install -y iproute2" >&2
133
+ exit 1
134
+ fi
135
+
136
+ # `ss` exits 0 whether or not the filter matched anything, so an empty result is
137
+ # the only signal that the port is free.
138
+ sockets="$(ss $flags "sport = :$port" 2>/dev/null || true)"
139
+
140
+ # Unprivileged `ss` still lists a socket owned by another user but silently drops
141
+ # the users:((...)) field, so the port reads as taken with nothing named. Retry
142
+ # under sudo only when it needs no password — a diagnostic should not stop to ask
143
+ # for one. (lsof and fuser are worse here: both report *nothing* and exit as if
144
+ # the port were free, which is why this uses ss.)
145
+ escalated=""
146
+ if [ -n "$sockets" ] && ! printf '%s' "$sockets" | grep -q 'pid='; then
147
+ if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then
148
+ sockets="$(sudo -n ss $flags "sport = :$port" 2>/dev/null || printf '%s' "$sockets")"
149
+ escalated="yes"
150
+ fi
151
+ fi
152
+
153
+ # "Nothing found" is an answer, not a failure: `devstuff run` flattens every
154
+ # non-zero code to 1, so exiting non-zero here would only buy a scary "command
155
+ # failed" banner under a correct result. Non-zero is kept for the cases where
156
+ # the lookup could not be performed at all (bad input, no ss).
157
+ if [ -z "$sockets" ]; then
158
+ echo "Nothing is listening on port $port ($protocol)."
159
+ echo
160
+ echo "A bind can still fail after this: a container port published without the"
161
+ echo "userland proxy is forwarded by NAT with no host socket to find, and sockets"
162
+ echo "in another network namespace are invisible from here."
163
+ if [ "$(id -u)" -ne 0 ]; then
164
+ echo "Sockets you have no permission to see are not listed either — to rule"
165
+ echo "that out: sudo devstuff run whats-on-port $port"
166
+ fi
167
+ exit 0
168
+ fi
169
+
170
+ echo "Port $port ($protocol) is in use:"
171
+ echo
172
+ printf '%s\n' "$sockets" | sed 's/^/ /'
173
+
174
+ # One port can have several holders (SO_REUSEPORT), so this reports every PID
175
+ # rather than assuming a single answer.
176
+ pids="$(printf '%s\n' "$sockets" | grep -oE 'pid=[0-9]+' | cut -d= -f2 | sort -un)"
177
+ if [ -n "$pids" ]; then
178
+ echo
179
+ echo "Holding process(es):"
180
+ printf ' %-8s %-10s %-10s %s\n' PID USER ELAPSED COMMAND
181
+ for pid in $pids; do
182
+ # The full command line is never truncated: it is usually the thing that
183
+ # identifies which of five node processes this actually is.
184
+ ps -o pid=,user=,etime=,args= -p "$pid" 2>/dev/null \
185
+ | awk '{printf " %-8s %-10s %-10s ", $1, $2, $3; $1=$2=$3=""; sub(/^ +/, ""); print}'
186
+ done
187
+ if [ -n "$escalated" ]; then
188
+ echo
189
+ echo " (the process details above needed root — re-read via sudo)"
190
+ fi
191
+ else
192
+ echo
193
+ echo "The socket exists, but its owning process belongs to another user and is"
194
+ echo "not visible from here. Re-run as root to name it:"
195
+ echo " sudo devstuff run whats-on-port $port"
196
+ fi
197
+
198
+ # Counted on the listener's side only: matching both sport and dport counts each
199
+ # connection twice, once per end of a loopback pair.
200
+ conns="$(ss -tnpH "sport = :$port" 2>/dev/null | grep -c ESTAB || true)"
201
+ if [ "${conns:-0}" -gt 0 ]; then
202
+ echo
203
+ echo "$conns established connection(s) to this port — list them with:"
204
+ echo " ss -tnp 'sport = :$port'"
205
+ fi
206
+
207
+ # Best-effort: a published container port is held by docker-proxy, whose command
208
+ # line does not say which container it belongs to. Silent on any failure, so no
209
+ # daemon (or no docker at all) costs nothing.
210
+ if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
211
+ containers="$(docker ps --format '{{.Names}}\t{{.Ports}}' 2>/dev/null | grep -E ":$port->" || true)"
212
+ if [ -n "$containers" ]; then
213
+ echo
214
+ echo "Published by container(s):"
215
+ printf '%s\n' "$containers" | sed 's/^/ /'
216
+ fi
217
+ fi
218
+ docs_url: https://man7.org/linux/man-pages/man8/ss.8.html
219
+
91
220
  aws-saml-reauth:
92
221
  name: AWS SAML Reauth
93
222
  description: Reauthorize the AWS CLI via saml2aws
dev_setup/generic.py CHANGED
@@ -10,10 +10,9 @@ from collections.abc import Callable
10
10
  from dataclasses import dataclass, fields
11
11
  from pathlib import Path
12
12
 
13
+ from dev_setup import verbose
13
14
  from dev_setup.base import Tool
14
15
 
15
- _verbose: bool = False
16
-
17
16
  # Auto-inferred requires per install type (re-derived on load, not persisted)
18
17
  AUTO_REQUIRES = {
19
18
  "npm": ["nvm"],
@@ -43,9 +42,20 @@ class UpdateStatus:
43
42
 
44
43
 
45
44
  def _run(cmd: list, *, cwd: Path | None = None) -> None:
46
- """Run a command. Streams output when verbose, captures when not."""
47
- if _verbose:
48
- subprocess.run(cmd, check=True, cwd=cwd)
45
+ """Run a state-changing command (install/remove/update). Raises RuntimeError on failure.
46
+
47
+ Streams output when verbose, captures when not — captured output is discarded on
48
+ success and surfaced as the exception message on failure, which is the only reason
49
+ quiet mode can say anything useful about what went wrong.
50
+ """
51
+ verbose.command(cmd, cwd=cwd)
52
+ if verbose.enabled():
53
+ try:
54
+ subprocess.run(cmd, check=True, cwd=cwd)
55
+ except subprocess.CalledProcessError as e:
56
+ # The output already streamed past; repeating the whole argv in the error
57
+ # adds nothing the user can't see directly above it.
58
+ raise RuntimeError(f"exit code {e.returncode}") from e
49
59
  else:
50
60
  try:
51
61
  subprocess.run(cmd, check=True, capture_output=True, text=True, cwd=cwd)
@@ -54,6 +64,26 @@ def _run(cmd: list, *, cwd: Path | None = None) -> None:
54
64
  raise RuntimeError(msg) from e
55
65
 
56
66
 
67
+ def _probe(cmd: list, *, log_at: int = verbose.TRACE, **kwargs) -> subprocess.CompletedProcess:
68
+ """Run a command whose output we capture rather than stream, and don't raise on.
69
+
70
+ Used for read-only probes (version checks, dpkg queries, …) and for the few
71
+ best-effort actions whose failure is deliberately ignored. Verbosity only adds
72
+ logging here, and by default only at -vv: probes fire constantly — one or more per
73
+ tool on every `list` — and would bury the actual work at -v. `log_at=verbose.VERBOSE`
74
+ marks the ones that are real actions the user should see at -v.
75
+ """
76
+ kwargs.setdefault("capture_output", True)
77
+ verbose.command(cmd, cwd=kwargs.get("cwd"), minimum=log_at)
78
+ proc = subprocess.run(cmd, **kwargs)
79
+ if verbose.enabled(verbose.TRACE):
80
+ out = "".join(
81
+ part for part in (proc.stdout, proc.stderr) if isinstance(part, str)
82
+ )
83
+ verbose.result(proc.returncode, out)
84
+ return proc
85
+
86
+
57
87
  @dataclass
58
88
  class GenericTool(Tool):
59
89
  key: str = ""
@@ -160,7 +190,7 @@ class GenericTool(Tool):
160
190
  if cmd and shutil.which(cmd):
161
191
  for flag in ["--version", "-v", "version"]:
162
192
  try:
163
- r = subprocess.run([cmd, flag], capture_output=True, text=True, timeout=5)
193
+ r = _probe([cmd, flag], capture_output=True, text=True, timeout=5)
164
194
  if r.returncode == 0 and r.stdout.strip():
165
195
  return r.stdout.strip().splitlines()[0]
166
196
  except Exception:
@@ -178,18 +208,13 @@ class GenericTool(Tool):
178
208
 
179
209
 
180
210
  def _install_npm(tool: GenericTool) -> None:
181
- from dev_setup import ui
182
211
  if not tool.npm_name:
183
212
  raise RuntimeError("npm_name not set")
184
- with ui.spinner(f"Installing {tool.name} via npm..."):
185
- subprocess.run(
186
- ["bash", "-lc", f"{_npm_init()} && npm install -g {shlex.quote(tool.npm_name)}"],
187
- check=True, capture_output=True,
188
- )
213
+ with verbose.step(f"Installing {tool.name} via npm..."):
214
+ _run(["bash", "-lc", f"{_npm_init()} && npm install -g {shlex.quote(tool.npm_name)}"])
189
215
 
190
216
 
191
217
  def _install_uvx(tool: GenericTool) -> None:
192
- from dev_setup import ui
193
218
  if not tool.pip_name:
194
219
  raise RuntimeError("pip_name not set")
195
220
  uv = shutil.which("uv")
@@ -198,26 +223,19 @@ def _install_uvx(tool: GenericTool) -> None:
198
223
  "uv is required to install uvx packages. "
199
224
  "Install it first: devstuff install uv"
200
225
  )
201
- with ui.spinner(f"Installing {tool.name} via uvx..."):
202
- subprocess.run([uv, "tool", "install", tool.pip_name], check=True, capture_output=True)
226
+ with verbose.step(f"Installing {tool.name} via uvx..."):
227
+ _run([uv, "tool", "install", tool.pip_name])
203
228
 
204
229
 
205
230
  def _install_git(tool: GenericTool) -> None:
206
- from dev_setup import ui
207
231
  if not tool.git_url:
208
232
  raise RuntimeError("git_url not set")
209
233
  dest = _git_clone_dest(tool.git_url)
210
- with ui.spinner(f"Cloning {tool.name}..."):
211
- subprocess.run(
212
- ["git", "clone", "--depth=1", tool.git_url, str(dest)],
213
- check=True, capture_output=True,
214
- )
234
+ with verbose.step(f"Cloning {tool.name}..."):
235
+ _run(["git", "clone", "--depth=1", tool.git_url, str(dest)])
215
236
  if tool.git_install_cmd:
216
- with ui.spinner("Running install command..."):
217
- subprocess.run(
218
- ["bash", "-c", tool.git_install_cmd],
219
- cwd=dest, check=True, capture_output=True,
220
- )
237
+ with verbose.step("Running install command..."):
238
+ _run(["bash", "-c", tool.git_install_cmd], cwd=dest)
221
239
 
222
240
 
223
241
  def _install_apt(tool: GenericTool) -> None:
@@ -225,7 +243,7 @@ def _install_apt(tool: GenericTool) -> None:
225
243
  if not tool.apt_packages:
226
244
  raise RuntimeError("apt_packages not set")
227
245
  ui.info(f"Installing {tool.name} via apt...")
228
- subprocess.run(["sudo", "apt-get", "update", "-q"], capture_output=True)
246
+ _run_apt_update()
229
247
  _run(["sudo", "apt-get", "install", "-y"] + tool.apt_packages.split())
230
248
 
231
249
 
@@ -250,19 +268,14 @@ def _install_bash(tool: GenericTool) -> None:
250
268
 
251
269
 
252
270
  def _update_npm(tool: GenericTool, version: str | None) -> None:
253
- from dev_setup import ui
254
271
  if not tool.npm_name:
255
272
  raise RuntimeError("npm_name not set")
256
273
  target = f"{tool.npm_name}@{version or 'latest'}"
257
- with ui.spinner(f"Updating {tool.name} via npm..."):
258
- subprocess.run(
259
- ["bash", "-lc", f"{_npm_init()} && npm install -g {shlex.quote(target)}"],
260
- check=True, capture_output=True,
261
- )
274
+ with verbose.step(f"Updating {tool.name} via npm..."):
275
+ _run(["bash", "-lc", f"{_npm_init()} && npm install -g {shlex.quote(target)}"])
262
276
 
263
277
 
264
278
  def _update_uvx(tool: GenericTool, version: str | None) -> None:
265
- from dev_setup import ui
266
279
  if not tool.pip_name:
267
280
  raise RuntimeError("pip_name not set")
268
281
  uv = shutil.which("uv")
@@ -272,8 +285,8 @@ def _update_uvx(tool: GenericTool, version: str | None) -> None:
272
285
  "Install it first: devstuff install uv"
273
286
  )
274
287
  target = f"{tool.pip_name}=={version}" if version else tool.pip_name
275
- with ui.spinner(f"Updating {tool.name} via uv tool upgrade..."):
276
- subprocess.run([uv, "tool", "upgrade", target], check=True, capture_output=True)
288
+ with verbose.step(f"Updating {tool.name} via uv tool upgrade..."):
289
+ _run([uv, "tool", "upgrade", target])
277
290
 
278
291
 
279
292
  def _update_apt(tool: GenericTool, version: str | None) -> None:
@@ -281,7 +294,7 @@ def _update_apt(tool: GenericTool, version: str | None) -> None:
281
294
  if not tool.apt_packages:
282
295
  raise RuntimeError("apt_packages not set")
283
296
  packages = tool.apt_packages.split()
284
- subprocess.run(["sudo", "apt-get", "update", "-q"], capture_output=True)
297
+ _run_apt_update()
285
298
  if version:
286
299
  if len(packages) != 1:
287
300
  raise RuntimeError(
@@ -295,7 +308,6 @@ def _update_apt(tool: GenericTool, version: str | None) -> None:
295
308
 
296
309
 
297
310
  def _update_git(tool: GenericTool, version: str | None) -> None:
298
- from dev_setup import ui
299
311
  if version:
300
312
  raise RuntimeError(
301
313
  "Version pinning is not supported for git tools (shallow clone). "
@@ -306,13 +318,10 @@ def _update_git(tool: GenericTool, version: str | None) -> None:
306
318
  dest = _git_clone_dest(tool.git_url)
307
319
  if not dest.exists():
308
320
  raise RuntimeError(f"{tool.name} clone not found at {dest}")
309
- with ui.spinner(f"Pulling latest {tool.name}..."):
321
+ with verbose.step(f"Pulling latest {tool.name}..."):
310
322
  _run(["git", "-C", str(dest), "pull"])
311
323
  if tool.git_install_cmd:
312
- subprocess.run(
313
- ["bash", "-c", tool.git_install_cmd],
314
- cwd=dest, check=True, capture_output=True,
315
- )
324
+ _run(["bash", "-c", tool.git_install_cmd], cwd=dest)
316
325
 
317
326
 
318
327
  def _update_script_url(tool: GenericTool, version: str | None) -> None:
@@ -335,7 +344,7 @@ def _update_bash(tool: GenericTool, version: str | None) -> None:
335
344
  def _npm_installed_version(pkg: str) -> str:
336
345
  import json
337
346
  try:
338
- r = subprocess.run(
347
+ r = _probe(
339
348
  ["bash", "-lc", f"{_npm_init()} && npm list -g --depth=0 --json {shlex.quote(pkg)}"],
340
349
  capture_output=True, text=True, timeout=10,
341
350
  )
@@ -347,7 +356,7 @@ def _npm_installed_version(pkg: str) -> str:
347
356
 
348
357
  def _npm_latest_version(pkg: str) -> str:
349
358
  try:
350
- r = subprocess.run(
359
+ r = _probe(
351
360
  ["bash", "-lc", f"{_npm_init()} && npm view {shlex.quote(pkg)} version"],
352
361
  capture_output=True, text=True, timeout=10,
353
362
  )
@@ -371,7 +380,7 @@ def _uv_tool_current_version(pkg: str) -> str:
371
380
  if not uv:
372
381
  return ""
373
382
  try:
374
- r = subprocess.run(
383
+ r = _probe(
375
384
  [uv, "tool", "list", "--color", "never"], capture_output=True, text=True, timeout=15,
376
385
  )
377
386
  except Exception:
@@ -392,7 +401,7 @@ def _uv_outdated_map() -> dict[str, str]:
392
401
  if not uv:
393
402
  return {}
394
403
  try:
395
- r = subprocess.run(
404
+ r = _probe(
396
405
  [uv, "tool", "list", "--outdated", "--color", "never"],
397
406
  capture_output=True, text=True, timeout=20,
398
407
  )
@@ -423,14 +432,14 @@ def _check_update_apt(tool: GenericTool) -> UpdateStatus:
423
432
  return UpdateStatus()
424
433
  pkg = tool.apt_packages.split()[0]
425
434
  try:
426
- r = subprocess.run(
435
+ r = _probe(
427
436
  ["dpkg-query", "-W", "-f=${Version}", pkg], capture_output=True, text=True, timeout=10,
428
437
  )
429
438
  current = r.stdout.strip() if r.returncode == 0 else ""
430
439
  except Exception:
431
440
  current = ""
432
441
  try:
433
- r = subprocess.run(["apt-cache", "policy", pkg], capture_output=True, text=True, timeout=10)
442
+ r = _probe(["apt-cache", "policy", pkg], capture_output=True, text=True, timeout=10)
434
443
  except Exception:
435
444
  return UpdateStatus(current=current)
436
445
  candidate = ""
@@ -451,7 +460,7 @@ def _check_update_git(tool: GenericTool) -> UpdateStatus:
451
460
  if not dest.exists():
452
461
  return UpdateStatus()
453
462
  try:
454
- r = subprocess.run(
463
+ r = _probe(
455
464
  ["git", "-C", str(dest), "rev-parse", "--short", "HEAD"],
456
465
  capture_output=True, text=True, timeout=10,
457
466
  )
@@ -459,7 +468,7 @@ def _check_update_git(tool: GenericTool) -> UpdateStatus:
459
468
  except Exception:
460
469
  current = ""
461
470
  try:
462
- r = subprocess.run(
471
+ r = _probe(
463
472
  ["git", "ls-remote", tool.git_url, "HEAD"], capture_output=True, text=True, timeout=10,
464
473
  )
465
474
  remote_full = r.stdout.split()[0] if r.returncode == 0 and r.stdout.strip() else ""
@@ -486,32 +495,28 @@ _UPDATE_CHECKERS: dict[str, Callable[[GenericTool], UpdateStatus]] = {
486
495
 
487
496
 
488
497
  def _remove_npm(tool: GenericTool) -> None:
489
- from dev_setup import ui
490
- with ui.spinner(f"Removing {tool.name}..."):
491
- subprocess.run(
492
- ["bash", "-lc", f"{_npm_init()} && npm uninstall -g {shlex.quote(tool.npm_name)}"],
493
- check=True, capture_output=True,
494
- )
498
+ with verbose.step(f"Removing {tool.name}..."):
499
+ _run(["bash", "-lc", f"{_npm_init()} && npm uninstall -g {shlex.quote(tool.npm_name)}"])
495
500
 
496
501
 
497
502
  def _remove_uvx(tool: GenericTool) -> None:
498
- from dev_setup import ui
499
503
  uv = shutil.which("uv")
500
504
  if not uv:
501
505
  raise RuntimeError(
502
506
  "uv is required to remove uvx packages. "
503
507
  "Install it first: devstuff install uv"
504
508
  )
505
- with ui.spinner(f"Removing {tool.name}..."):
506
- subprocess.run([uv, "tool", "uninstall", tool.pip_name], check=True, capture_output=True)
509
+ with verbose.step(f"Removing {tool.name}..."):
510
+ _run([uv, "tool", "uninstall", tool.pip_name])
507
511
 
508
512
 
509
513
  def _remove_git(tool: GenericTool) -> None:
510
- from dev_setup import ui
511
514
  dest = _git_clone_dest(tool.git_url)
512
515
  if tool.git_remove_cmd:
513
- with ui.spinner("Running remove command..."):
514
- subprocess.run(["bash", "-c", tool.git_remove_cmd], cwd=dest, capture_output=True)
516
+ # Best-effort: the clone directory is removed below either way, so a remove
517
+ # command that fails must not abort the removal.
518
+ with verbose.step("Running remove command..."):
519
+ _probe(["bash", "-c", tool.git_remove_cmd], cwd=dest, log_at=verbose.VERBOSE)
515
520
  if dest.exists():
516
521
  shutil.rmtree(dest)
517
522
 
@@ -610,7 +615,7 @@ _UPDATERS: dict[str, Callable[[GenericTool, str | None], None]] = {
610
615
 
611
616
  def _npm_global_installed(pkg: str) -> bool:
612
617
  try:
613
- r = subprocess.run(
618
+ r = _probe(
614
619
  ["bash", "-lc", f"{_npm_init()} && npm list -g --depth=0 {shlex.quote(pkg)}"],
615
620
  capture_output=True,
616
621
  text=True,
@@ -620,13 +625,19 @@ def _npm_global_installed(pkg: str) -> bool:
620
625
  return False
621
626
 
622
627
 
628
+ def _run_apt_update() -> None:
629
+ """Refresh apt's package lists. Deliberately non-fatal: a failing mirror shouldn't
630
+ stop an install of a package that may already be cached."""
631
+ _probe(["sudo", "apt-get", "update", "-q"], log_at=verbose.VERBOSE)
632
+
633
+
623
634
  def _npm_init() -> str:
624
635
  return '. "$HOME/.nvm/nvm.sh" 2>/dev/null || true'
625
636
 
626
637
 
627
638
  def _apt_installed(pkg: str) -> bool:
628
639
  try:
629
- r = subprocess.run(["dpkg", "-s", pkg], capture_output=True, text=True)
640
+ r = _probe(["dpkg", "-s", pkg], capture_output=True, text=True)
630
641
  return "Status: install ok installed" in r.stdout
631
642
  except Exception:
632
643
  return False
@@ -641,8 +652,10 @@ def _download_script(url: str, *, expected_sha256: str = "") -> str:
641
652
  """Download a script over HTTPS and optionally verify its sha256."""
642
653
  import urllib.request
643
654
 
655
+ verbose.log(f"downloading {url}")
644
656
  with urllib.request.urlopen(url, timeout=30) as resp:
645
657
  data = resp.read()
658
+ verbose.trace(f"downloaded {len(data)} bytes, sha256 {hashlib.sha256(data).hexdigest()}")
646
659
 
647
660
  if expected_sha256:
648
661
  actual = hashlib.sha256(data).hexdigest()
@@ -664,8 +677,12 @@ def _run_bash_script(script: str) -> None:
664
677
  with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False) as f:
665
678
  f.write(script)
666
679
  tmp = f.name
680
+ verbose.trace(f"script → {tmp}")
681
+ verbose.block(script)
667
682
  try:
668
- _run(["bash", tmp])
683
+ # -x at -vv traces each expanded command to stderr as the script runs, which is
684
+ # the only way to see where a downloaded installer actually failed.
685
+ _run(["bash", *(["-x"] if verbose.enabled(verbose.TRACE) else []), tmp])
669
686
  finally:
670
687
  os.unlink(tmp)
671
688
 
@@ -686,7 +703,7 @@ def _is_simple_command(cmd: str) -> bool:
686
703
  def _bash_version(key: str) -> str:
687
704
  for flag in ["--version", "-v", "version"]:
688
705
  try:
689
- r = subprocess.run(
706
+ r = _probe(
690
707
  [
691
708
  "bash", "-lc",
692
709
  f'. "$HOME/.nvm/nvm.sh" 2>/dev/null; {shlex.quote(key)} {flag} 2>/dev/null',
@@ -707,13 +724,13 @@ def _check_cmd_installed(cmd: str, *, install_type: str = "") -> bool:
707
724
  # Only source nvm for npm-type tools — avoids unnecessary shell overhead
708
725
  prefix = f"{_npm_init()} && " if install_type == "npm" else ""
709
726
  try:
710
- return subprocess.run(
727
+ return _probe(
711
728
  ["bash", "-lc", f"{prefix}command -v {cmd} >/dev/null 2>&1"],
712
729
  capture_output=True,
713
730
  ).returncode == 0
714
731
  except Exception:
715
732
  return False
716
733
  try:
717
- return subprocess.run(["bash", "-lc", cmd], capture_output=True).returncode == 0
734
+ return _probe(["bash", "-lc", cmd], capture_output=True).returncode == 0
718
735
  except Exception:
719
736
  return False
dev_setup/verbose.py ADDED
@@ -0,0 +1,148 @@
1
+ """Process-wide verbosity level and the logging helpers built on it.
2
+
3
+ Three levels, set once per process by the `-v` CLI option (`verbose.option`) or the
4
+ `DEVSTUFF_VERBOSE` environment variable:
5
+
6
+ 0 quiet — normal output only; subprocess output is captured and shown only on failure
7
+ 1 -v — every command devstuff runs is logged, and its output streams live
8
+ 2 -vv — plus read-only probes, exit codes, captured output, and `bash -x` tracing
9
+ of script bodies (functions, install/remove scripts)
10
+
11
+ **Everything here writes to stderr, never stdout.** `devstuff run <key>` in
12
+ `register: eval` mode prints shell code to stdout for `eval "$(...)"` to consume, so a
13
+ verbose line on stdout would be executed in the user's shell. Routing the whole logger
14
+ to stderr makes that impossible by construction rather than by remembering to check the
15
+ level at each call site.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import shlex
22
+ from collections.abc import Generator
23
+ from contextlib import contextmanager
24
+
25
+ from rich.console import Console
26
+
27
+ # stderr, and no highlighting/markup: these lines carry raw command lines and script
28
+ # bodies, which are full of characters rich would otherwise style or eat.
29
+ _console = Console(stderr=True, highlight=False)
30
+
31
+ # soft_wrap keeps a logged command line or script line on one terminal line. Rich's
32
+ # default word-wrap breaks a long command mid-word, and what the user most wants to do
33
+ # with a verbose line — copy it back into a shell — stops working when it does.
34
+ _PRINT = {"style": "dim", "markup": False, "soft_wrap": True}
35
+
36
+ QUIET = 0
37
+ VERBOSE = 1
38
+ TRACE = 2
39
+
40
+ _level: int = 0
41
+
42
+
43
+ def _from_env() -> int:
44
+ """DEVSTUFF_VERBOSE=1|2 (or a bare word like "true") as a starting level.
45
+
46
+ Lets a function invoked from a script or another tool be verbose without the caller
47
+ threading a flag through; an explicit -v still wins because it can only raise it.
48
+ """
49
+ raw = os.environ.get("DEVSTUFF_VERBOSE", "").strip().lower()
50
+ if not raw:
51
+ return QUIET
52
+ if raw.isdigit():
53
+ return min(int(raw), TRACE)
54
+ return VERBOSE if raw in ("true", "yes", "on", "y") else QUIET
55
+
56
+
57
+ _level = _from_env()
58
+
59
+
60
+ def set_level(value: int) -> None:
61
+ global _level
62
+ _level = max(QUIET, min(TRACE, value))
63
+
64
+
65
+ def level() -> int:
66
+ return _level
67
+
68
+
69
+ def enabled(minimum: int = VERBOSE) -> bool:
70
+ return _level >= minimum
71
+
72
+
73
+ def log(msg: str, *, minimum: int = VERBOSE) -> None:
74
+ """A verbose note. Dim, stderr, one line."""
75
+ if _level >= minimum:
76
+ _console.print(f" {msg}", **_PRINT)
77
+
78
+
79
+ def trace(msg: str) -> None:
80
+ """A note only worth showing at -vv."""
81
+ log(msg, minimum=TRACE)
82
+
83
+
84
+ def command(cmd: list[str] | str, *, cwd: object = None, minimum: int = VERBOSE) -> None:
85
+ """Log a command about to run, in a form the user could paste back into a shell."""
86
+ if _level < minimum:
87
+ return
88
+ text = cmd if isinstance(cmd, str) else shlex.join(str(c) for c in cmd)
89
+ log(f"$ {text}" + (f" (in {cwd})" if cwd else ""), minimum=minimum)
90
+
91
+
92
+ def result(returncode: int, output: str = "", *, minimum: int = TRACE) -> None:
93
+ """Log a captured command's exit code and output."""
94
+ if _level < minimum:
95
+ return
96
+ log(f"→ exit {returncode}", minimum=minimum)
97
+ block(output, minimum=minimum)
98
+
99
+
100
+ def block(text: str, *, minimum: int = TRACE) -> None:
101
+ """Log a multi-line body (captured output, a script) indented under the last line."""
102
+ if _level < minimum or not text.strip():
103
+ return
104
+ for line in text.rstrip("\n").splitlines():
105
+ _console.print(f" │ {line}", **_PRINT)
106
+
107
+
108
+ @contextmanager
109
+ def step(label: str) -> Generator[None, None, None]:
110
+ """A unit of work: a spinner normally, a logged line when verbose.
111
+
112
+ The spinner has to go when verbose — it repaints its own line, so it would fight
113
+ with the subprocess output now streaming to the same terminal.
114
+ """
115
+ if enabled():
116
+ log(label)
117
+ yield
118
+ else:
119
+ from dev_setup import ui
120
+ with ui.spinner(label):
121
+ yield
122
+
123
+
124
+ def option(f):
125
+ """`-v`/`-vv` for a Click command or group, setting the process-wide level.
126
+
127
+ `expose_value=False` — the level is process state read by the subprocess helpers
128
+ deep in `generic.py`/`function_runner.py`, not something each command body threads
129
+ through. Attached to both the `cli` group and individual commands so that
130
+ `devstuff -v install x` and `devstuff install -v x` both work; the callback takes
131
+ the max so the two can't cancel each other out.
132
+ """
133
+ import click
134
+
135
+ def _callback(ctx: object, param: object, value: int) -> int:
136
+ if value:
137
+ set_level(max(level(), value))
138
+ return value
139
+
140
+ return click.option(
141
+ "-v",
142
+ "--verbose",
143
+ count=True,
144
+ expose_value=False,
145
+ is_eager=True,
146
+ callback=_callback,
147
+ help="Verbose output: -v logs and streams every command, -vv adds probes and tracing.",
148
+ )(f)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devstuff
3
- Version: 2.0.0
3
+ Version: 2.2.0
4
4
  Summary: Development environment setup CLI for Linux
5
5
  Project-URL: Repository, https://github.com/thesawdawg/dev-setup-py
6
6
  Author-email: Sawyer <sawyerksu@gmail.com>
@@ -193,6 +193,30 @@ The bash `./devstuff` script in the repo is a convenience runner for the git-clo
193
193
 
194
194
  ---
195
195
 
196
+ ## Verbose output (`-v` / `-vv`)
197
+
198
+ Every command accepts `-v`, before or after the subcommand. devstuff is a thin layer over other
199
+ people's commands; this is how you see them.
200
+
201
+ ```bash
202
+ devstuff install lazygit -v # log each command and stream its output live
203
+ devstuff -vv run whats-on-port 8080 # + probes, exit codes, and bash -x line tracing
204
+ DEVSTUFF_VERBOSE=1 devstuff update # same as -v, for scripts and CI
205
+ ```
206
+
207
+ | Level | What you get |
208
+ |-------|--------------|
209
+ | *(default)* | Normal output. Command output is captured and shown only if the command fails. |
210
+ | `-v` | Every command devstuff runs, printed as a line you can paste back into a shell, with its output streaming live. Spinners are replaced by plain lines so they don't fight with that output. |
211
+ | `-vv` | Plus read-only probes (version checks, install-state checks) with their exit codes and output, the body of any script before it runs, resolved function parameters, and `bash -x` tracing of every line inside function and installer scripts. |
212
+
213
+ `-vvv` and above are the same as `-vv`.
214
+
215
+ All of it goes to **stderr**, so it never contaminates anything you pipe or capture — including
216
+ `eval "$(devstuff run <shell-eval function>)"`, whose stdout has to be shell code and nothing else.
217
+
218
+ ---
219
+
196
220
  ## Commands
197
221
 
198
222
  ### `list`
@@ -1105,9 +1129,38 @@ ones tools already have — for now, custom functions are hand-edited YAML at
1105
1129
  | `ssh-agent-key` | auth | shell-eval (bashrc) | Start ssh-agent in the current shell and add a key to it | `key_path` |
1106
1130
  | `validate-docker-compose` | validation | script | Validate a docker-compose.yml file in the current directory | — |
1107
1131
  | `validate-yaml` | validation | script | Validate a YAML file's syntax using `yq` | `file` |
1132
+ | `whats-on-port` | network | script | Find which process is listening on a port | `port`, `protocol` (optional: `tcp`/`udp`/`all`) |
1108
1133
  | `acc-check` | web-dev | script | Run the pi coding agent's `/dogfood` skill against a web URL | `url`, `instruction` (optional) |
1109
1134
  | `aws-saml-reauth` | web-dev | script | Reauthorize the AWS CLI via `saml2aws login --force` | `profile` (optional) |
1110
1135
 
1136
+ #### `whats-on-port`
1137
+
1138
+ ```bash
1139
+ devstuff run whats-on-port 8080 # tcp and udp
1140
+ devstuff run whats-on-port 5432 tcp # one protocol
1141
+ ```
1142
+
1143
+ Prints the matching sockets, then the PID, user, elapsed time and **untruncated** command
1144
+ line of every process holding them — plural because `SO_REUSEPORT` lets several processes
1145
+ share one port, and "which of these five node processes" is usually the actual question.
1146
+
1147
+ It uses `ss` rather than `lsof` or `fuser` for one measured reason: **run unprivileged
1148
+ against another user's socket, `lsof` and `fuser` print nothing and exit as though the port
1149
+ were free.** `ss` still lists the socket, just without the `users:((...))` field — so the
1150
+ function can tell "nothing there" apart from "something there I'm not allowed to name", and
1151
+ says which. When the process is hidden it retries under `sudo -n`, and only if that needs no
1152
+ password; a diagnostic shouldn't stop to prompt for one. If sudo isn't available it says
1153
+ what to re-run.
1154
+
1155
+ "Nothing is listening" is reported as a success, not a failure, along with the two reasons a
1156
+ bind can still fail afterwards: a container port published with `userland-proxy: false` is
1157
+ NAT-forwarded with no host socket to find at all, and sockets in another network namespace
1158
+ are invisible. If Docker is running, published container ports are matched and named too —
1159
+ `docker-proxy`'s own command line doesn't say which container it belongs to.
1160
+
1161
+ `ss` comes from `iproute2`, which isn't a devstuff package and isn't on every minimal image,
1162
+ so a missing `ss` names the apt package instead of surfacing "command not found".
1163
+
1111
1164
  ---
1112
1165
 
1113
1166
  ## Built-in packages
@@ -4,17 +4,18 @@ dev_setup/agent_tools.schema.json,sha256=veXnih00xW4w9yK3ruWz-npUrCnJmcQpQdFvfcu
4
4
  dev_setup/agent_tools.yaml,sha256=56E70B1W7hBqjGhYhhCAGzFQvi1nUZ04cnNiqZlMjyY,4506
5
5
  dev_setup/base.py,sha256=j2EQ5gzeGgtveWQN_BgbLcZianMENJ7PQy8EQ9J9MPE,1946
6
6
  dev_setup/catalog.py,sha256=YST_dpTpIh08aLz1FBziywtKX_zFfhlU5fr56hgf0mA,5514
7
- dev_setup/cli.py,sha256=DIvADRhXIqQ0UcVN8GY-uLDWUwgjdOcc7nWHP4Ie0Gc,2028
7
+ dev_setup/cli.py,sha256=Y_RbHkexTAIQN4neDQ3buo6vmtTMNdXrsYsmY0X_Om8,2976
8
8
  dev_setup/doctor.py,sha256=JliUAgmiW2HVCWHhT76PIXTBekNXXrc0VfDMaOeLBWY,15918
9
- dev_setup/function_runner.py,sha256=ZnGGk_Av524vddsrQvO9O-dcNZS0UnLpfnqNxCSDytM,5937
9
+ dev_setup/function_runner.py,sha256=08F6jvB9-oOyE5ivYtRjnL16JTGsfRnkpvkjSVrJhsM,6957
10
10
  dev_setup/functions.schema.json,sha256=VVdpH3xWsdbtqw47S1QqpBcs4O9wd3XZ_s0Q3sGwv0Q,6220
11
- dev_setup/functions.yaml,sha256=q3JE8g-iG64tbJ4dgvEq7o5SbNbJ8Fh__mVVR3GJIWc,3425
11
+ dev_setup/functions.yaml,sha256=IZbdIsfRkQEvgYOjEtBMYxD-TRdqKCsoF1siWTHEYtE,9173
12
12
  dev_setup/functions_catalog.py,sha256=YgR2hY9HM1iol1AZcF-_j02EXCP9U4P3e_5eWes9KH0,7116
13
13
  dev_setup/functions_registry.py,sha256=YSl-VgNXPf0hj4WgReg-pZUj4fAjFlwvC165dgon_BE,3682
14
- dev_setup/generic.py,sha256=FJFOkDF1sc4ZZn2f3iiGOl9gC_8ySHh9R__yzuCGL0A,24020
14
+ dev_setup/generic.py,sha256=6-sUq7NtPlabs9T-2qyNqAMa4E6IKfL93IRTzzMsLp8,25313
15
15
  dev_setup/registry.py,sha256=8lrk6EXCCWk1WNeNnCGLTJ-v8rpCs7-TLYtVXgiuxnA,2243
16
16
  dev_setup/tools.yaml,sha256=4NQIsMV_x_sUjgafLbuXzHoPpuSd8-QS9XOoQiz9DEk,26730
17
17
  dev_setup/ui.py,sha256=Z5nNZV8MN8tAjo1ZWq7-QPZYkPKQKuM7JbmGtiuKTqM,3806
18
+ dev_setup/verbose.py,sha256=N3U9JAYOvFwdd8DT6W7Pqq6RCIUfbcQnPsm0O1TnvQY,5003
18
19
  dev_setup/agent/__init__.py,sha256=znrgxUCr_y0Qp-f41S6EaTv-hoTYwoZ9mOMLVge-IJg,226
19
20
  dev_setup/agent/approval.py,sha256=R2YnbR1fauGtV7-NW5dH1QvWvNHjR3l23DKRTCiqpEE,3625
20
21
  dev_setup/agent/bridges.py,sha256=RX5coPXJUSkyZApxgUZghz_57ibcPrWHVz3D44S6z2Q,4814
@@ -40,12 +41,12 @@ dev_setup/commands/docs_cmd.py,sha256=1PtpwdFO3LrAxG3HyCLFn5SXVnnwqcJ8K_c9cVd8Ph
40
41
  dev_setup/commands/doctor_cmd.py,sha256=8HqQ9sB3aVDKer6jbqN_Eq0XKZDXM2o48L6_vL8JAcE,3161
41
42
  dev_setup/commands/functions_cmd.py,sha256=D9JRYMRavXbROz0XRGjHRHeFvZY-GKyvRCctJODQDBE,2856
42
43
  dev_setup/commands/help_cmd.py,sha256=kHbzMggYVj3hS7JKUiCrBY4VtZ5p71YPoPKQ4Y-OYNU,3301
43
- dev_setup/commands/install_cmd.py,sha256=DMKq2_bn6Y7ZV-VPR85H2A73fMkVKqH2srfeY4cIl0g,4785
44
+ dev_setup/commands/install_cmd.py,sha256=AGrB0CZPT6hZ0Cnn9AgmlyFkW8F1wZufuXVvwYft_Xo,4610
44
45
  dev_setup/commands/list_cmd.py,sha256=-BAywXxpSm4-VAlysLzlDwIqvmk_zBKhmzEObM8jigo,2847
45
- dev_setup/commands/remove_cmd.py,sha256=5xCCN6zlaP5KCfOl6GKbSbWC08QD9zkQ1MN1X4AKaLw,1414
46
- dev_setup/commands/run_cmd.py,sha256=xZEw6a12TBd3P4WB-39D3PVAKLCyCtXtVrEVwXItZXc,2465
46
+ dev_setup/commands/remove_cmd.py,sha256=_KqCa2S9fQFngLUs_OpBzjkIkFKgDtKup4gQ5TqLrVI,1239
47
+ dev_setup/commands/run_cmd.py,sha256=e1xfWt09UALiAe03AxzdvZyYpj0V7WKk2-E_nbk9hIw,2933
47
48
  dev_setup/commands/skills_cmd.py,sha256=j4rleYEW4lt-O2ssDwGyhwmk2Wiip64C4Z7iPT6c3y0,7482
48
- dev_setup/commands/update_cmd.py,sha256=EA2I0kyEnIg30R7XSjJKT6WcWLVjsVRoxvmQuzBRFAU,5005
49
+ dev_setup/commands/update_cmd.py,sha256=9Bdgu9wMdliawonDOsfDsjP2am-okuONStICKn9pkkg,4831
49
50
  dev_setup/configure/__init__.py,sha256=YXkDx21lDzjEQvypoAyoEAy8srskEzc5t6PR69evPHA,3266
50
51
  dev_setup/configure/ansible/__init__.py,sha256=huQL_tfiU1l0WI6nzjTnGZlxCQX2aH7E9n9TrfwmIpU,407
51
52
  dev_setup/configure/ansible/detect.py,sha256=5LkdbJg5BU2lrIU4CZm_Lhr6Sm4TJMCh1sMrsRK6rgc,9531
@@ -89,7 +90,7 @@ dev_setup/configure/starship/model.py,sha256=HNkIj69DqBQFAq0VTHqPLNZT2DuBZSfF-5y
89
90
  dev_setup/configure/starship/preview.py,sha256=ZaNhNA3AeHHtPnMqrJbB9XVVXDFbH1R36jm8xyYdt2U,6145
90
91
  dev_setup/configure/starship/render.py,sha256=gDE0hbGZ9oDCR1VAZQRDm5EFYviMjj0-eSmr1EnZfn8,10262
91
92
  dev_setup/configure/starship/wizard.py,sha256=BmWl-U6ttF79gepDhCv8UxT8ZDIMe1SWnhSkA1iIrGQ,15498
92
- devstuff-2.0.0.dist-info/METADATA,sha256=JnPSEMPGjO7Mu0yf55BV1CaQHqSgiqecrd0qc2XJrPM,65404
93
- devstuff-2.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
94
- devstuff-2.0.0.dist-info/entry_points.txt,sha256=48ITy9ENdXDFrTHwAyVPUpq1MDmgFqcOisOo5mAR6OQ,53
95
- devstuff-2.0.0.dist-info/RECORD,,
93
+ devstuff-2.2.0.dist-info/METADATA,sha256=Jq_Nx1lYKtqm-nRmPEdjg7R3HOte4oGhj9nL4ARUTMk,68408
94
+ devstuff-2.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
95
+ devstuff-2.2.0.dist-info/entry_points.txt,sha256=48ITy9ENdXDFrTHwAyVPUpq1MDmgFqcOisOo5mAR6OQ,53
96
+ devstuff-2.2.0.dist-info/RECORD,,