tailctl 0.1.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.
tailctl/tailscale.py ADDED
@@ -0,0 +1,365 @@
1
+ """Thin, side-effect-free wrapper around the Tailscale CLI.
2
+
3
+ All subprocess invocations use ``shell=False`` with an argv list so config
4
+ values flow through ``execve`` arguments, never shell strings. Profile names,
5
+ account IDs, and exit-node values are validated upstream (config-load time)
6
+ against ``^[A-Za-z0-9._-]+$``, so they can pass through without quoting.
7
+
8
+ This module knows nothing about the registry, holders, or instance lifecycle.
9
+ It just runs commands (optionally against a per-instance ``--socket``) and parses
10
+ JSON. Higher-level orchestration lives in ``instance_manager.py``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import subprocess
17
+ import time
18
+ from collections.abc import Callable
19
+ from dataclasses import dataclass
20
+ from typing import Any, Protocol
21
+
22
+ DEFAULT_BINARY = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
23
+
24
+ # Convergence + verification timeouts (seconds). Tuned conservative — the
25
+ # Round-2 review flagged 5s as unrealistic for account swaps that re-handshake
26
+ # WireGuard and fetch a fresh netmap.
27
+ DEFAULT_CONVERGENCE_TIMEOUT_S = 20.0
28
+ DEFAULT_CONVERGENCE_POLL_INTERVAL_S = 0.5
29
+ DEFAULT_SUBPROCESS_TIMEOUT_S = 30.0
30
+
31
+
32
+ # --- exceptions ---
33
+
34
+
35
+ class TailscaleError(RuntimeError):
36
+ """Generic Tailscale CLI failure (non-zero exit, parse error, timeout)."""
37
+
38
+
39
+ class TailscaleConvergenceTimeout(TailscaleError):
40
+ """Tailscale daemon did not converge to the expected state in time."""
41
+
42
+
43
+ # --- types ---
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class SwitchAccount:
48
+ """One row from ``tailscale switch --list --json``."""
49
+
50
+ id: str
51
+ nickname: str
52
+ tailnet: str
53
+ account: str
54
+ selected: bool
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class ExitNodeStatus:
59
+ """Minimal projection of ``ExitNodeStatus`` in status --json.
60
+
61
+ ``id`` is Tailscale's opaque short ID (e.g. ``nKKJQbqKax11CNTRL``).
62
+ ``hostname`` is the human name from the matching ``Peer`` entry — what
63
+ users put in ``profiles.yaml`` under ``exit_node:``. It's resolved by
64
+ ``parse_status`` joining ``ExitNodeStatus.ID`` against ``Peer[*].ID``;
65
+ ``None`` when the peer entry isn't present (e.g. during reconnect).
66
+ """
67
+
68
+ id: str | None
69
+ online: bool
70
+ hostname: str | None = None
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class TailscaleStatus:
75
+ """Minimal projection of ``tailscale status --json`` for our needs."""
76
+
77
+ backend_state: str
78
+ current_tailnet_name: str | None
79
+ exit_node: ExitNodeStatus | None
80
+ # The raw dict is preserved so the caller can pull arbitrary fields
81
+ # (e.g. for forensic logging) without re-parsing.
82
+ raw: dict[str, Any]
83
+
84
+
85
+ # --- subprocess seam ---
86
+
87
+
88
+ class CommandRunner(Protocol):
89
+ """Seam for unit tests: replace subprocess with a fake.
90
+
91
+ Returns ``(returncode, stdout, stderr)``. Implementations MUST NOT raise
92
+ on non-zero exit — the wrapper decides what's an error.
93
+ """
94
+
95
+ def __call__(
96
+ self, argv: list[str], *, timeout: float | None = None
97
+ ) -> tuple[int, str, str]: ...
98
+
99
+
100
+ def real_runner(argv: list[str], *, timeout: float | None = None) -> tuple[int, str, str]:
101
+ """Production runner: ``subprocess.run`` with shell=False, check=False."""
102
+ proc = subprocess.run(
103
+ argv,
104
+ shell=False,
105
+ check=False,
106
+ capture_output=True,
107
+ text=True,
108
+ timeout=timeout,
109
+ )
110
+ return proc.returncode, proc.stdout, proc.stderr
111
+
112
+
113
+ # --- client ---
114
+
115
+
116
+ class TailscaleClient:
117
+ """High-level interface to the Tailscale CLI."""
118
+
119
+ def __init__(
120
+ self,
121
+ *,
122
+ binary: str = DEFAULT_BINARY,
123
+ runner: CommandRunner | None = None,
124
+ sleep: Callable[[float], None] = time.sleep,
125
+ clock: Callable[[], float] = time.monotonic,
126
+ socket: str | None = None,
127
+ ) -> None:
128
+ self._binary = binary
129
+ self._runner = runner or real_runner
130
+ self._sleep = sleep
131
+ self._clock = clock
132
+ # When set, every CLI invocation targets a specific (userspace)
133
+ # tailscaled via its UDS, instead of the machine-global daemon. On
134
+ # macOS the CLI otherwise defaults to [::1]:64511, so this MUST be
135
+ # passed explicitly to drive a per-identity instance.
136
+ self._socket = socket
137
+
138
+ def _argv(self, *rest: str) -> list[str]:
139
+ """Build a CLI argv, prepending --socket when this client is bound to
140
+ a specific daemon socket."""
141
+ head = [self._binary]
142
+ if self._socket:
143
+ head.append(f"--socket={self._socket}")
144
+ return [*head, *rest]
145
+
146
+ # --- raw probes ---
147
+
148
+ def switch_list(self) -> list[SwitchAccount]:
149
+ """Return all signed-in accounts. Equivalent of ``switch --list --json``."""
150
+ rc, stdout, stderr = self._runner(
151
+ self._argv("switch", "--list", "--json"),
152
+ timeout=DEFAULT_SUBPROCESS_TIMEOUT_S,
153
+ )
154
+ if rc != 0:
155
+ raise TailscaleError(f"switch --list --json failed (rc={rc}): {stderr.strip()}")
156
+ return parse_switch_list(stdout)
157
+
158
+ def status(self) -> TailscaleStatus:
159
+ """Return ``tailscale status --json`` parsed into a minimal projection."""
160
+ rc, stdout, stderr = self._runner(
161
+ self._argv("status", "--json"),
162
+ timeout=DEFAULT_SUBPROCESS_TIMEOUT_S,
163
+ )
164
+ if rc != 0:
165
+ raise TailscaleError(f"status --json failed (rc={rc}): {stderr.strip()}")
166
+ return parse_status(stdout)
167
+
168
+ def active_account(self) -> SwitchAccount | None:
169
+ for acct in self.switch_list():
170
+ if acct.selected:
171
+ return acct
172
+ return None
173
+
174
+ # --- mutators ---
175
+
176
+ def switch_account(self, account_id: str) -> None:
177
+ """Issue ``tailscale switch <id>``. Does NOT wait for convergence."""
178
+ rc, _stdout, stderr = self._runner(
179
+ self._argv("switch", account_id),
180
+ timeout=DEFAULT_SUBPROCESS_TIMEOUT_S,
181
+ )
182
+ if rc != 0:
183
+ raise TailscaleError(
184
+ f"switch {account_id} failed (rc={rc}): {stderr.strip()}"
185
+ )
186
+
187
+ def set_exit_node(self, exit_node: str | None) -> None:
188
+ """Issue ``tailscale set --exit-node=<name>`` (empty = clear)."""
189
+ value = exit_node or ""
190
+ rc, _stdout, stderr = self._runner(
191
+ self._argv("set", f"--exit-node={value}"),
192
+ timeout=DEFAULT_SUBPROCESS_TIMEOUT_S,
193
+ )
194
+ if rc != 0:
195
+ raise TailscaleError(
196
+ f"set --exit-node={value!r} failed (rc={rc}): {stderr.strip()}"
197
+ )
198
+
199
+ def up(
200
+ self,
201
+ *,
202
+ accept_routes: bool = True,
203
+ accept_dns: bool = False,
204
+ exit_node: str | None = None,
205
+ hostname: str | None = None,
206
+ auth_key: str | None = None,
207
+ timeout_s: float = DEFAULT_SUBPROCESS_TIMEOUT_S,
208
+ ) -> tuple[int, str, str]:
209
+ """Issue ``tailscale up`` against this client's (userspace) daemon.
210
+
211
+ With ``auth_key`` the login is non-interactive and returns once the
212
+ node is registered. Without it, a first-time login blocks/prints an
213
+ auth URL on stdout for the caller to surface; an already-authed
214
+ statedir returns rc=0 immediately. Subprocess timeout still raises.
215
+
216
+ The auth key is passed via argv to the tailscale CLI; it is never
217
+ included in this wrapper's logs or error messages.
218
+ """
219
+ argv = self._argv("up")
220
+ argv.append(f"--accept-routes={'true' if accept_routes else 'false'}")
221
+ argv.append(f"--accept-dns={'true' if accept_dns else 'false'}")
222
+ if exit_node:
223
+ argv.append(f"--exit-node={exit_node}")
224
+ if hostname:
225
+ argv.append(f"--hostname={hostname}")
226
+ if auth_key:
227
+ argv.append(f"--auth-key={auth_key}")
228
+ return self._runner(argv, timeout=timeout_s)
229
+
230
+ def logout(self) -> None:
231
+ """Deregister this daemon's node (clears the statedir's login)."""
232
+ self._runner(self._argv("logout"), timeout=DEFAULT_SUBPROCESS_TIMEOUT_S)
233
+
234
+ # --- convergence ---
235
+
236
+ def wait_for_backend_running_and_tailnet(
237
+ self,
238
+ *,
239
+ expected_tailnet: str | None,
240
+ timeout_s: float = DEFAULT_CONVERGENCE_TIMEOUT_S,
241
+ poll_interval_s: float = DEFAULT_CONVERGENCE_POLL_INTERVAL_S,
242
+ ) -> TailscaleStatus:
243
+ """Poll status until ``BackendState == Running`` and (optionally) the
244
+ active tailnet matches ``expected_tailnet``.
245
+
246
+ ``expected_tailnet`` may be ``None`` or empty to skip the tailnet
247
+ check (used when the active account has no tailnet name, e.g. a
248
+ personal account).
249
+ """
250
+ deadline = self._clock() + timeout_s
251
+ last: TailscaleStatus | None = None
252
+ while True:
253
+ try:
254
+ last = self.status()
255
+ except TailscaleError:
256
+ last = None
257
+ if last is not None and last.backend_state == "Running":
258
+ if not expected_tailnet or last.current_tailnet_name == expected_tailnet:
259
+ return last
260
+ if self._clock() >= deadline:
261
+ raise TailscaleConvergenceTimeout(
262
+ f"backend did not reach Running/{expected_tailnet!r} within "
263
+ f"{timeout_s}s (last={last!r})"
264
+ )
265
+ self._sleep(poll_interval_s)
266
+
267
+
268
+ # --- parsers ---
269
+
270
+
271
+ def parse_switch_list(stdout: str) -> list[SwitchAccount]:
272
+ """Parse ``switch --list --json`` output into typed records."""
273
+ try:
274
+ data = json.loads(stdout)
275
+ except json.JSONDecodeError as exc:
276
+ raise TailscaleError(f"switch --list --json: invalid JSON: {exc}") from exc
277
+ if not isinstance(data, list):
278
+ raise TailscaleError("switch --list --json: expected a JSON array")
279
+ out: list[SwitchAccount] = []
280
+ for i, entry in enumerate(data):
281
+ if not isinstance(entry, dict):
282
+ raise TailscaleError(f"switch --list --json: entry {i} is not an object")
283
+ try:
284
+ out.append(
285
+ SwitchAccount(
286
+ id=str(entry["id"]),
287
+ nickname=str(entry.get("nickname", "")),
288
+ tailnet=str(entry.get("tailnet", "")),
289
+ account=str(entry.get("account", "")),
290
+ selected=bool(entry["selected"]),
291
+ )
292
+ )
293
+ except KeyError as exc:
294
+ raise TailscaleError(
295
+ f"switch --list --json: entry {i} missing key {exc.args[0]!r}"
296
+ ) from exc
297
+ return out
298
+
299
+
300
+ def parse_status(stdout: str) -> TailscaleStatus:
301
+ """Parse ``status --json`` into the minimal projection used internally."""
302
+ try:
303
+ data = json.loads(stdout)
304
+ except json.JSONDecodeError as exc:
305
+ raise TailscaleError(f"status --json: invalid JSON: {exc}") from exc
306
+ if not isinstance(data, dict):
307
+ raise TailscaleError("status --json: expected a JSON object")
308
+ backend_state = data.get("BackendState")
309
+ if not isinstance(backend_state, str):
310
+ raise TailscaleError("status --json: missing or non-string BackendState")
311
+ tailnet_name: str | None = None
312
+ current_tailnet = data.get("CurrentTailnet")
313
+ if isinstance(current_tailnet, dict):
314
+ name = current_tailnet.get("Name")
315
+ if isinstance(name, str) and name:
316
+ tailnet_name = name
317
+ exit_node: ExitNodeStatus | None = None
318
+ ens = data.get("ExitNodeStatus")
319
+ if isinstance(ens, dict):
320
+ en_id = str(ens["ID"]) if isinstance(ens.get("ID"), str) else None
321
+ exit_node = ExitNodeStatus(
322
+ id=en_id,
323
+ online=bool(ens.get("Online", False)),
324
+ hostname=_resolve_exit_node_hostname(data, en_id),
325
+ )
326
+ return TailscaleStatus(
327
+ backend_state=backend_state,
328
+ current_tailnet_name=tailnet_name,
329
+ exit_node=exit_node,
330
+ raw=data,
331
+ )
332
+
333
+
334
+ def _resolve_exit_node_hostname(
335
+ status_data: dict[str, Any], exit_node_id: str | None
336
+ ) -> str | None:
337
+ """Join ExitNodeStatus.ID against Peer[*].ID (and Self) to find the
338
+ human hostname.
339
+
340
+ Tailscale reports the active exit node by opaque internal ID; users
341
+ write profiles.yaml using the peer's HostName. Without this join, a
342
+ correctly-configured exit node looks like drift.
343
+
344
+ Self is checked alongside Peer because a node advertising itself as an
345
+ exit node appears in Self, not Peer — uncommon but legal.
346
+
347
+ Returns None when the ID is absent or no node matches (treat as
348
+ unverified — callers should not flag drift in that case).
349
+ """
350
+ if not exit_node_id:
351
+ return None
352
+ candidates: list[dict[str, Any]] = []
353
+ self_node = status_data.get("Self")
354
+ if isinstance(self_node, dict):
355
+ candidates.append(self_node)
356
+ peers = status_data.get("Peer")
357
+ if isinstance(peers, dict):
358
+ candidates.extend(p for p in peers.values() if isinstance(p, dict))
359
+ for node in candidates:
360
+ if node.get("ID") == exit_node_id:
361
+ host = node.get("HostName")
362
+ if isinstance(host, str) and host:
363
+ return host
364
+ return None
365
+ return None
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: tailctl
3
+ Version: 0.1.0
4
+ Summary: Per-identity Tailscale networking for parallel sessions on a single Mac
5
+ Author-email: DRYCodeWorks <dan@drycodeworks.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://drycodeworks.com
8
+ Project-URL: Repository, https://github.com/DRYCodeWorks/tailctl
9
+ Project-URL: Issues, https://github.com/DRYCodeWorks/tailctl/issues
10
+ Keywords: tailscale,vpn,wireguard,networking,socks5,proxy,cli,macos,multi-account,ai-agents,parallel-sessions
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS :: MacOS X
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: System :: Networking
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.11
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: PyYAML>=6.0
27
+ Requires-Dist: psutil>=5.9
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=7.4; extra == "dev"
30
+ Requires-Dist: pytest-timeout>=2.2; extra == "dev"
31
+ Requires-Dist: ruff>=0.4; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ <p align="center">
35
+ <img src="https://raw.githubusercontent.com/DRYCodeWorks/tailctl/main/docs/tailctl-banner.jpg" alt="tailctl — per-identity Tailscale networking for parallel sessions on a single Mac" width="100%">
36
+ </p>
37
+
38
+ # tailctl
39
+
40
+ Per-identity Tailscale networking for parallel sessions on a single Mac.
41
+
42
+ `tailctl` gives each process (e.g. a Claude Code session in its own Ghostty window)
43
+ its **own** Tailscale identity — a private, headless userspace `tailscaled` with a
44
+ per-identity SOCKS5/HTTP proxy and localhost port-forwards — instead of switching
45
+ the machine-global Tailscale account. Many identities run at once, and the GUI
46
+ Tailscale app (the browser's default tailnet) is never touched. No FIFO queue, no
47
+ shared network, no flapping.
48
+
49
+ Agent traffic is opted in per command via proxy env vars (`tailctl run`) or stable
50
+ localhost forwards for native-TCP clients (ClickHouse, Postgres, …).
51
+
52
+ No always-on daemon beyond the per-identity tailscaled instances. Single-laptop scope.
53
+
54
+ ## Status
55
+
56
+ Working prototype — macOS-only, single-laptop scope.
57
+
58
+ ## Install
59
+
60
+ Requires macOS, Python 3.11+, and the Homebrew Tailscale daemon (see
61
+ [Requirements](#requirements)).
62
+
63
+ ```
64
+ brew install tailscale
65
+ pipx install tailctl # or: pip install tailctl
66
+ ```
67
+
68
+ Or from source:
69
+
70
+ ```
71
+ brew install tailscale
72
+ git clone https://github.com/DRYCodeWorks/tailctl && cd tailctl
73
+ python3 -m venv .venv && .venv/bin/pip install -e .
74
+ ln -sf "$PWD/.venv/bin/tailctl" ~/.local/bin/tailctl # put it on PATH
75
+ ```
76
+
77
+ ## Quick view
78
+
79
+ ```
80
+ tailctl init # scaffold ~/.tailctl/profiles.yaml
81
+ tailctl doctor # validate config + probe daemon + verify accounts
82
+ tailctl bootstrap <name> --tailnet <t> [--exit-node <e>] [--port-forward svc=host:port]
83
+ # onboard a NEW tailnet: discovers account_id,
84
+ # appends a profile (preserving comments), prints
85
+ # the key steps (mint in console -> store in BWS)
86
+ tailctl up acme-dev # spawn a userspace tailscaled for the profile
87
+ # (first time: prints a one-time auth URL)
88
+ tailctl run acme-dev -- curl http://service.internal # route a command via that identity
89
+ tailctl run acme-dev -- clickhouse-client --host 127.0.0.1 --port $CLICKHOUSE_ADDR
90
+ tailctl ps # list running instances + their proxy ports/forwards
91
+ tailctl down acme-dev # release (refcount--); stops the daemon at zero
92
+ ```
93
+
94
+ `run` sets `ALL_PROXY`/`HTTPS_PROXY`/`HTTP_PROXY` (+ `NO_PROXY`) so proxy-aware
95
+ clients route through the identity, and exports `<SERVICE>_ADDR=127.0.0.1:<port>`
96
+ for each configured `port_forward` so native-TCP clients connect to localhost.
97
+
98
+ ## profiles.yaml
99
+
100
+ ```yaml
101
+ default: acme-dev
102
+ tailscaled_binary: /opt/homebrew/opt/tailscale/bin/tailscaled
103
+ profiles:
104
+ acme-dev:
105
+ account_id: a2c4
106
+ tailnet: acme.example.ts.net
107
+ exit_node: tailscale-subnet-router-development
108
+ accept_routes: true
109
+ auth_key_env: ACME_TS_AUTHKEY # optional: env var holding a Tailscale
110
+ # auth key (from BWS) for non-interactive
111
+ # first login — no browser URL
112
+ port_forwards:
113
+ - service: clickhouse # exported as CLICKHOUSE_ADDR
114
+ remote_host: 100.64.0.10
115
+ remote_port: 9000
116
+ ```
117
+
118
+ ## Authentication
119
+
120
+ Each profile is a distinct device on its tailnet, authenticated **once** — the
121
+ login persists in the profile's statedir and is reused on every later `up`/`run`
122
+ (teardown never logs out). Two ways to do that one-time login:
123
+
124
+ - **Auth key (recommended):** set `auth_key_env` to an env var holding a
125
+ Tailscale auth key (minted in the tailnet's admin console, stored in BWS).
126
+ First `up` logs in non-interactively — no browser.
127
+ - **Interactive:** with no auth key, the first `up` prints a login URL to open
128
+ once.
129
+
130
+ Profiles that share an account but differ by exit node (e.g. Acme
131
+ dev/staging/prod) are separate profiles → separate nodes → one auth each.
132
+
133
+ ## Requirements
134
+
135
+ - macOS
136
+ - Homebrew Tailscale for the userspace daemon: `brew install tailscale`
137
+ (coexists with the GUI Tailscale.app, which keeps the browser's default tailnet)
138
+ - Python 3.11+
139
+ - Each profile's Tailscale account authenticated once per profile on first `up`
140
+
141
+ ## License
142
+
143
+ MIT
@@ -0,0 +1,18 @@
1
+ tailctl/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ tailctl/cli.py,sha256=WCJEWvINo9bn6T_Je6PhIYzD7LuaA5ZWrOS22WgVeiQ,24055
3
+ tailctl/config.py,sha256=mdLf3f-FljubE2n31RL-LrzMgIhnpoOWsCzszHcYmwk,11233
4
+ tailctl/forwarder.py,sha256=puLpCMWRID8qUB1P3eLl94PQjuwcow8DWd3rU3IzEDI,4606
5
+ tailctl/instance_manager.py,sha256=qCpIc_Uc0LSl27WYiKbl_tZzwp3aP8IVidG2EsT90-w,37379
6
+ tailctl/liveness.py,sha256=-qeFpMlkL0a-nKoB7O22YVDjA-F5REAKZ8qnWUC65FU,4967
7
+ tailctl/log.py,sha256=6H0rRPqe5iMjoD9gBhMD1JgtSrmrnWW9TcITpErJPh4,1368
8
+ tailctl/paths.py,sha256=S0_XwxphmiRf6gWqLDTikqi59u7_UwakQt9y7wajn08,2026
9
+ tailctl/profiles.py,sha256=TSTwr-GSRpkZrnuG_-DHN6f-FRqixUixopiVvxBupOY,2270
10
+ tailctl/registry.py,sha256=380McJU1EAfVIyWukCPv2DfbHTY-mEFFvhI5wLe5N-Y,6300
11
+ tailctl/signals.py,sha256=ZvLbmPY6dMAzHko8GPS3kFRYeSh66WjspDB1wuu1hfM,1753
12
+ tailctl/tailscale.py,sha256=hCrojMxhRrmiyoJpVmATfLFZQt3kq4vnswP3LjpxvKw,13005
13
+ tailctl-0.1.0.dist-info/licenses/LICENSE,sha256=tCJ1mJyU60MYqdPDvF-E5lFzcloGJkHszr4qRp-MC8A,1069
14
+ tailctl-0.1.0.dist-info/METADATA,sha256=oTbMFjAlxHqbXNciWQ5K8X1JfFTyp2UuNbiFdSgwWzg,5736
15
+ tailctl-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
16
+ tailctl-0.1.0.dist-info/entry_points.txt,sha256=ZCJHEcit4mUTBAJ1kqQL8O3hfmO6olHo8rTBtt-e0ss,45
17
+ tailctl-0.1.0.dist-info/top_level.txt,sha256=_9CWOOPeY9wB1f_ch9A7jdpHpAEzQM_OiCEBpmztjzk,8
18
+ tailctl-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tailctl = tailctl.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DRYCodeWorks
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ tailctl