ppkit 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.
pp/lib/session.py ADDED
@@ -0,0 +1,31 @@
1
+ """Who is calling pp: a person's shell, or an agent session.
2
+
3
+ Claude Code puts its session's identity in the environment of every command it runs (CLAUDE_CODE_SESSION_ID,
4
+ CLAUDE_PID, and TMUX_PANE when it runs inside tmux). pp records that identity as a queued job's owner, so
5
+ `pp queue wait --mine` finds the jobs a session queued. It strips it from anything pp starts on its own (workers,
6
+ jobs, hooks): a process that inherits a session's identity looks, to itself and to every tool it runs, as if it
7
+ were inside that session.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+
13
+ # a session's identity, never configuration: CLAUDE_CODE_USE_BEDROCK and friends are auth settings and stay
14
+ SESSION_VARS = ("CLAUDECODE", "AI_AGENT", "CLAUDE_PID", "CLAUDE_EFFORT", "CLAUDE_CODE_SESSION_ID",
15
+ "CLAUDE_CODE_CHILD_SESSION", "CLAUDE_CODE_SESSION_ATTENDED", "CLAUDE_CODE_MESSAGING_SOCKET",
16
+ "CLAUDE_CODE_MESSAGING_TOKEN", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_EXECPATH", "TMUX_PANE")
17
+
18
+
19
+ def caller(env: dict | None = None) -> dict | None:
20
+ """The calling agent session, {session, cwd}, or None when a person (or a script) calls."""
21
+ env = os.environ if env is None else env
22
+ sid = env.get("CLAUDE_CODE_SESSION_ID")
23
+ return {"session": sid, "cwd": os.getcwd()} if sid else None
24
+
25
+
26
+ def scrubbed_env(env: dict | None = None) -> dict:
27
+ """A copy of env without any session's identity."""
28
+ env = dict(os.environ if env is None else env)
29
+ for k in SESSION_VARS:
30
+ env.pop(k, None)
31
+ return env
pp/lib/ui.py ADDED
@@ -0,0 +1,189 @@
1
+ """Terminal helpers shared by commands: argument parsers, confirmation, tables, colour, human units."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import re
6
+ import json
7
+ import os
8
+ import sys
9
+ import time
10
+ import unicodedata
11
+
12
+
13
+ def parser(cmd: str, doc: str | None, **kw) -> argparse.ArgumentParser:
14
+ """An ArgumentParser for `pp <cmd>`: the module docstring is the help text, kept verbatim."""
15
+ return argparse.ArgumentParser(prog=f"pp {cmd}", description=doc,
16
+ formatter_class=argparse.RawDescriptionHelpFormatter, **kw)
17
+
18
+
19
+ def parse_intermixed(p: argparse.ArgumentParser, argv: list[str]) -> argparse.Namespace:
20
+ """p.parse_intermixed_args(argv), the same on every Python pp supports: options may sit between positionals
21
+ (`pp batch rename ' ' _ -r .`, which plain parse_args gets wrong on 3.11 and early 3.12), and after `--` every
22
+ argument is a positional. Those versions' intermixed parse also lost `--` and read a later '-x' as an
23
+ option, so such arguments pass through argparse as placeholders that cannot look like options."""
24
+ if "--" not in argv:
25
+ return p.parse_intermixed_args(argv)
26
+ i = argv.index("--")
27
+ held = {f"\0{n}": a for n, a in enumerate(argv[i + 1:]) if a.startswith("-")}
28
+ tail = [f"\0{n}" if a.startswith("-") else a for n, a in enumerate(argv[i + 1:])]
29
+ ns = p.parse_intermixed_args(argv[:i + 1] + tail)
30
+ for k, v in vars(ns).items():
31
+ if isinstance(v, str):
32
+ setattr(ns, k, held.get(v, v))
33
+ elif isinstance(v, list):
34
+ setattr(ns, k, [held.get(x, x) if isinstance(x, str) else x for x in v])
35
+ return ns
36
+
37
+
38
+ def die(msg: str, code: int = 1):
39
+ """Print `pp: msg` to stderr and exit with code (1 failure, 2 usage/refusal)."""
40
+ print(f"pp: {msg}", file=sys.stderr)
41
+ raise SystemExit(code)
42
+
43
+
44
+ def warn(msg: str):
45
+ print(f"pp: {msg}", file=sys.stderr)
46
+
47
+
48
+ def confirm(prompt: str, yes: bool = False) -> bool:
49
+ """True to proceed. -y always proceeds; without a terminal to ask on we refuse rather than guess."""
50
+ sys.stdout.flush() # the plan printed so far comes before the question or the refusal, even into a pipe
51
+ if yes:
52
+ return True
53
+ if not sys.stdin.isatty():
54
+ print(f"{prompt} There is no terminal to ask at; pass -y to proceed.", file=sys.stderr)
55
+ return False
56
+ try:
57
+ return input(f"{prompt} [y/N] ").strip().lower() in ("y", "yes")
58
+ except EOFError:
59
+ return False
60
+
61
+
62
+ # ---- colour ---------------------------------------------------------------------------------------
63
+
64
+ _STYLES = {"bold": "1", "dim": "2", "red": "31", "green": "32", "yellow": "33", "blue": "34", "magenta": "35", "cyan": "36"}
65
+
66
+
67
+ def color_on(stream=None) -> bool:
68
+ stream = stream or sys.stdout
69
+ return stream.isatty() and not os.environ.get("NO_COLOR") and os.environ.get("TERM") != "dumb"
70
+
71
+
72
+ def c(text: str, *styles: str, stream=None) -> str:
73
+ if not styles or not color_on(stream):
74
+ return text
75
+ return "\033[" + ";".join(_STYLES[s] for s in styles) + "m" + text + "\033[0m"
76
+
77
+
78
+ # ---- output ---------------------------------------------------------------------------------------
79
+
80
+ # every control character but tab, the C1 range (\x80-\x9f) among them: a terminal reads \x9b as a
81
+ # control sequence introducer, the way it reads \x1b[
82
+ _CTRL = re.compile(r"[\x00-\x08\x0a-\x1f\x7f-\x9f]")
83
+ _CTRL_TAB = re.compile(r"[\x00-\x1f\x7f-\x9f]")
84
+ _SGR = re.compile(r"\x1b\[[0-9;]*m") # what c() wraps a cell in: bytes, no columns
85
+
86
+
87
+ def printable(text: str, tabs: bool = True) -> str:
88
+ """Text from elsewhere (another program's argv, a plugin's file, git's output, a requested URL) made safe
89
+ to print: control characters shown as \\n, \\x1b and so on, so an escape sequence cannot redraw the
90
+ terminal around what the user is reading. A tab stays a tab unless tabs is False (in a table, where it
91
+ would break the columns). The one escaper in pp: CONTRIBUTING.md, "Adding a command"."""
92
+ return (_CTRL if tabs else _CTRL_TAB).sub(lambda m: repr(m.group())[1:-1], text)
93
+
94
+
95
+ def _char_width(ch: str) -> int:
96
+ """How many columns one character takes, which width() and cut() both count in, so a cut and the measure
97
+ that decided on it can never disagree.
98
+
99
+ A combining mark is drawn on the character before it, so it takes no column of its own: an accent in an
100
+ NFD name (a copy from a Mac, an unzipped archive, some input methods) is such a mark, and so are the
101
+ other zero-width characters, a joiner and a variation selector among them. A character East Asian width
102
+ calls wide or fullwidth (a CJK name, a fullwidth form) takes two columns. Anything else takes one."""
103
+ if unicodedata.category(ch) in ("Mn", "Me", "Cf"):
104
+ return 0
105
+ return 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
106
+
107
+
108
+ def width(text: str) -> int:
109
+ """How many columns text takes on a terminal, which is what anything lining text up has to count.
110
+
111
+ A colour from c() is escape bytes the terminal prints nothing for, so they count for nothing; every other
112
+ character counts what _char_width says. Counting characters instead pads a coloured cell nine columns too
113
+ far, a CJK name half as far as it needs, and an NFD name one column per accent too far."""
114
+ return sum(_char_width(ch) for ch in _SGR.sub("", text))
115
+
116
+
117
+ def pad(text: str, to: int) -> str:
118
+ """text with spaces after it up to `to` printed columns, as str.ljust does with characters."""
119
+ return text + " " * max(to - width(text), 0)
120
+
121
+
122
+ def cut(text: str, to: int) -> str:
123
+ """text, no wider than `to` printed columns, with an ellipsis where it was cut. Cut by columns, so a wide
124
+ character is kept whole or left out, never split across the edge, and a combining mark stays with the
125
+ character it is drawn on. For text without colours: a colour cut in half would run on past the cell."""
126
+ if width(text) <= to:
127
+ return text
128
+ out, used = [], 0
129
+ for ch in text:
130
+ w = _char_width(ch)
131
+ if used + w > to - 1:
132
+ break
133
+ out.append(ch)
134
+ used += w
135
+ return "".join(out) + "…"
136
+
137
+
138
+ def table(rows: list[list], headers: list[str] | None = None, indent: str = "") -> str:
139
+ """Left-aligned columns; the last column is never padded. Cells are str()'d, with a newline or carriage return
140
+ shown as \\n or \\r, so no cell starts a row of its own. Cells may carry c()'s colours; text from elsewhere
141
+ goes through printable() first.
142
+
143
+ Columns are counted and padded in printed columns (width()), so a coloured cell and a CJK name line up
144
+ like any other and no call site has to know what its cells hold."""
145
+ rows = [[("" if v is None else str(v)).replace("\n", "\\n").replace("\r", "\\r") for v in r] for r in rows]
146
+ allr = ([headers] if headers else []) + rows
147
+ if not allr:
148
+ return ""
149
+ n = max(len(r) for r in allr)
150
+ widths = [max((width(r[i]) for r in allr if i < len(r)), default=0) for i in range(n)]
151
+ out = []
152
+ for k, r in enumerate(allr):
153
+ cells = [pad(cell, widths[i]) if i < len(r) - 1 else cell for i, cell in enumerate(r)]
154
+ line = indent + " ".join(cells)
155
+ out.append(c(line, "bold") if headers and k == 0 else line)
156
+ return "\n".join(out)
157
+
158
+
159
+ def print_json(obj) -> None:
160
+ json.dump(obj, sys.stdout, indent=2, default=str)
161
+ sys.stdout.write("\n")
162
+
163
+
164
+ def human_size(n: float) -> str:
165
+ for unit in ("B", "K", "M", "G", "T"):
166
+ if abs(n) < 1024 or unit == "T":
167
+ return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
168
+ n /= 1024
169
+ return f"{n:.1f}T"
170
+
171
+
172
+ def human_duration(s: float | None) -> str:
173
+ if s is None:
174
+ return "-"
175
+ s = int(s)
176
+ if s < 60:
177
+ return f"{s}s"
178
+ if s < 3600:
179
+ return f"{s // 60}m{s % 60:02d}s"
180
+ if s < 86400:
181
+ return f"{s // 3600}h{(s % 3600) // 60:02d}m"
182
+ return f"{s // 86400}d{(s % 86400) // 3600:02d}h"
183
+
184
+
185
+ def human_age(ts: float | None, now: float | None = None) -> str:
186
+ """'3m ago' style age of a unix timestamp."""
187
+ if not ts:
188
+ return "-"
189
+ return human_duration((now or time.time()) - ts) + " ago"
@@ -0,0 +1,330 @@
1
+ Metadata-Version: 2.4
2
+ Name: ppkit
3
+ Version: 0.1.0
4
+ Summary: A CLI kit for user & agents
5
+ Keywords: cli,job queue,gpu,agents,toolkit
6
+ Author: The pp Authors
7
+ License-Expression: Apache-2.0
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: System :: Shells
16
+ Classifier: Topic :: Utilities
17
+ Requires-Python: >=3.11
18
+ Project-URL: Changelog, https://github.com/xflash96/pp/blob/main/CHANGELOG.md
19
+ Project-URL: Homepage, https://github.com/xflash96/pp
20
+ Project-URL: Issues, https://github.com/xflash96/pp/issues
21
+ Description-Content-Type: text/markdown
22
+
23
+ # pp
24
+
25
+ [![ci](https://github.com/xflash96/pp/actions/workflows/ci.yml/badge.svg)](https://github.com/xflash96/pp/actions/workflows/ci.yml)
26
+ [![pypi](https://img.shields.io/pypi/v/ppkit)](https://pypi.org/project/ppkit/)
27
+ [![license](https://img.shields.io/github/license/xflash96/pp)](https://github.com/xflash96/pp/blob/main/LICENSE)
28
+
29
+ A CLI kit for user & agents.
30
+
31
+ pp gathers the shell chores that come up when you work next to coding
32
+ agents such as Claude Code on one Linux machine. Long jobs wait their turn
33
+ for the GPU. An agent finds out how its job ended without anyone watching a
34
+ terminal. The copy of a tool you use every day is one that passed its tests,
35
+ not the one being edited.
36
+
37
+ Every command shows what it will do before it does it, and works the same
38
+ when a person or an agent runs it. [Try it](#try-it) queues a first job.
39
+
40
+ ## Features
41
+
42
+ - [`pp queue`](#queues) runs commands later, in named queues. A job starts
43
+ when the GPU memory, CPUs, RAM or counters it asks for are free, and it
44
+ can have a timeout, retries and a hook for when it ends.
45
+ - [`pp queue wait`](#working-with-agents) blocks until jobs end, then says
46
+ how each one ended. An agent runs it in the background instead of polling.
47
+ - [`pp promote`](#releases) releases a project from a git commit, tests the
48
+ copy, and switches to it only if the tests pass.
49
+ - [`pp kill`](#everyday-tools) stops processes by pid, port or pattern, and
50
+ never the shell that asked.
51
+ - [`pp batch`](#everyday-tools) renames files, or runs a command on each one,
52
+ with a preview and undo.
53
+ - [`pp http`](#everyday-tools) shows project folders in a browser, with
54
+ markdown and code rendered.
55
+ - [`pp cmd`](#your-own-commands) writes your own commands, and adds other
56
+ people's from git.
57
+
58
+ pp needs no packages beyond Python itself, and it completes in zsh and bash.
59
+
60
+ ## How it works
61
+
62
+ Each command is one Python file. pp looks for them when it runs: its own in
63
+ `pp/commands/`, yours in `~/.config/pp/commands/`, the plugins you added
64
+ with `pp cmd add`, the folders listed in `PP_PATH`, and any program named
65
+ `pp-NAME` on your `PATH`. The first one found wins, so a plugin cannot hide
66
+ a built-in or one of yours. `pp` lists commands without loading them, so
67
+ one broken command cannot break the others.
68
+ `pp help NAME` prints a command's manual, with examples.
69
+
70
+ Everything pp keeps is in plain files:
71
+
72
+ ```
73
+ ~/.config/pp/config.toml settings, a table per command
74
+ ~/.config/pp/commands/ your own commands
75
+ .pp.toml a project's settings, at its root
76
+ ~/.local/state/pp/ queues, job logs, the rename journal
77
+ ~/.local/share/pp/releases/ what pp promote released
78
+ ~/.local/share/pp/plugins/ plugins, one git clone each
79
+ ```
80
+
81
+ A queue has a worker, a background process that `pp queue add` starts when
82
+ it is not running. It runs each job in its own process group, in the
83
+ directory the job was added from, with the `PATH` and virtualenv of the
84
+ shell that added it.
85
+
86
+ ## Install
87
+
88
+ pp runs on Linux, with Python 3.11 or later. Install it as a tool, in an
89
+ environment of its own:
90
+
91
+ ```
92
+ uv tool install ppkit # or: pipx install ppkit
93
+ pp --version
94
+ ```
95
+
96
+ For completion, add `source <(pp completion zsh)` to `~/.zshrc` after
97
+ `compinit`, or `source <(pp completion bash)` to `~/.bashrc`. To change pp
98
+ itself, run it from a clone instead ([CONTRIBUTING.md](https://github.com/xflash96/pp/blob/main/CONTRIBUTING.md)).
99
+
100
+ ## Try it
101
+
102
+ ```
103
+ pp queue add -- sh -c 'echo working; sleep 3; echo hello' # job 1, in the default queue; its worker starts
104
+ pp queue # job 1 running, with its last line of output: working
105
+ pp queue wait 1 # waits, then: done 1 rc=0 3s
106
+ pp queue log 1 # working, then hello
107
+ pp queue add --retries 1 --retry-delay 1s -- sh -c 'echo trying; exit 3'
108
+ pp queue wait 2 # FAILED 2 rc=3, after two tries, with the end of its log
109
+ ```
110
+
111
+ `wait` exits with 0 when every job succeeded and 1 when one failed, so it
112
+ fits in a script: `pp queue wait 2 && ./next-step.sh`.
113
+
114
+ A job that needs a GPU says how much memory it takes:
115
+
116
+ ```
117
+ pp queue add --gpu-gb 20 -- python train.py
118
+ ```
119
+
120
+ It starts when one card has 20 GB free, counting what nvidia-smi reports
121
+ and what running jobs have reserved. The job sees only that card, through
122
+ `CUDA_VISIBLE_DEVICES`.
123
+
124
+ ## Queues
125
+
126
+ Each queue has its own worker, limits and order. Job ids are unique across
127
+ queues. A job with a lower `--prio` runs first, and the default is 10. A big
128
+ job at the head of a queue is not overtaken by smaller ones.
129
+
130
+ ```
131
+ pp queue add -q dl --need net=1 -- ./fetch.sh the dl queue, taking one of a counter named net
132
+ pp queue add --timeout 2h -- python x.py stopped after two hours
133
+ pp queue add --retries 2 --retry-if 'Connection reset' -- ./sync.sh
134
+ pp queue add --on-fail 'notify-send "job $PP_QUEUE_JOB failed"' -- ./nightly.sh
135
+ pp queue add --tag sweep -- python x.py --lr 0.1 tagged, to act on the group later
136
+ pp queue wait sweep every job tagged sweep
137
+ pp queue watch 12 a line for each start, retry and outcome
138
+ pp queue drop 20-29 a range: it shows the jobs and asks first
139
+ pp queue kill 12 waits while the worker stops it, then:
140
+ killed 12 (stopped on SIGTERM)
141
+ pp queue retry 11 pp queue hold 15 pp queue release 15
142
+ ```
143
+
144
+ Limits go in `~/.config/pp/config.toml`. Every queue draws on the shared
145
+ GPU memory, CPUs and RAM; a queue's own pool caps what it may take of them.
146
+ A counter is a number, or a command that prints how much is free:
147
+
148
+ ```toml
149
+ [resources] # shared by every queue
150
+ license = 4 # --need license=1 takes one
151
+ nodes = { probe = "sinfo -h -t idle -o %D", every = "1m" }
152
+
153
+ [queue.default]
154
+ retries = 1
155
+ retry_if = "Connection reset|503"
156
+
157
+ [queue.dl.resources] # only the dl queue has these
158
+ net = 2
159
+ ```
160
+
161
+ A project's `.pp.toml` can wrap every job queued from it, for example in a
162
+ container, and add to the jobs' environment:
163
+
164
+ ```toml
165
+ [queue]
166
+ runner = "docker/run.sh" # the job runs as docker/run.sh CMD...
167
+ env = { HF_HUB_OFFLINE = "1" }
168
+ ```
169
+
170
+ `pp queue add --no-runner` skips the runner for one job. The runner gets the
171
+ job's environment, including `CUDA_VISIBLE_DEVICES`, and a container runner
172
+ has to pass it in. Give a job a token or a secret with `--env` or the
173
+ project's `env`: a variable exported in your shell reaches it only through
174
+ the worker. `pp help queue` has the examples, and `pp help queue add` every
175
+ option of `add`.
176
+
177
+ ## Working with agents
178
+
179
+ An agent that starts a long job should not sit in a loop checking on it.
180
+ With pp it runs `pp queue wait ID` in the background. Claude Code tells the
181
+ agent when a background command exits, and the output says how the job
182
+ ended, with the end of the log if it failed. `pp queue watch ID` prints a
183
+ line per event instead, for a monitor. `pp queue add` prints this hint when
184
+ an agent runs it.
185
+
186
+ pp notes which session queued each job. `pp queue wait --mine` waits for
187
+ the jobs this session queued, and also reports the ones that ended before it
188
+ started waiting, so an outcome is not missed:
189
+
190
+ ```
191
+ pp queue add -- ./train.sh # prints the job's id, and the hint to wait in the background
192
+ pp queue wait --mine # every job of this session: how it ended, with the log's end on failure
193
+ ```
194
+
195
+ Nothing is typed into the agent's terminal. pp leaves the session's identity
196
+ out of the jobs it runs, so a job does not think it is inside that session.
197
+
198
+ The commands follow the same rules, so an agent can use them without a
199
+ terminal. A command that changes or deletes things shows what it will do
200
+ and asks, unless you named each thing exactly; `-n` only shows, `-y` goes
201
+ ahead, and without a terminal it refuses instead of asking. Lists and
202
+ status reports have `--json`. The exit status is 0 when it worked, 1 when
203
+ it failed and 2 when it refused; [CONTRIBUTING.md](https://github.com/xflash96/pp/blob/main/CONTRIBUTING.md#exit-status) has
204
+ all of them.
205
+
206
+ ## Releases
207
+
208
+ `pp promote` keeps the copy of a tool or service you use apart from the
209
+ tree you edit. It copies a git commit into
210
+ `~/.local/share/pp/releases/NAME/STAMP/`, runs the project's check inside
211
+ that copy, and points the `current` link at it only if the check passes.
212
+ The links you register go through `current`, so they never change.
213
+
214
+ ```toml
215
+ # .pp.toml, at the project root
216
+ [promote]
217
+ check = "./test.sh" # must pass, or nothing changes
218
+ links = { "~/.local/bin/notes" = "bin/notes" }
219
+ after = "systemctl --user restart notes" # makes the running service pick up the release
220
+ verify = "curl -fsS http://127.0.0.1:8100/ >/dev/null" # if this fails, pp goes back
221
+ ```
222
+
223
+ ```
224
+ pp promote -n the plan and every problem, without changing anything
225
+ pp promote release HEAD
226
+ pp promote status each project, its release, and how far its source is ahead
227
+ pp promote rollback notes back to the release before
228
+ ```
229
+
230
+ `after` runs again on a rollback, so it has to work for any release. A
231
+ database migration that cannot be undone does not belong in it. pp keeps the
232
+ last five releases, and always keeps the current one and the one before.
233
+
234
+ A systemd service runs the release by pointing at `current`:
235
+
236
+ ```
237
+ ExecStart=%h/.local/share/pp/releases/notes/current/bin/serve --port 8100
238
+ ```
239
+
240
+ ## Everyday tools
241
+
242
+ ```
243
+ pp kill :8080 whatever listens on port 8080
244
+ pp kill 'vite --port' processes whose command line matches; it shows them and asks
245
+ pp kill -n :8080 only show what it would stop
246
+ pp kill --ports every listening port and what owns it
247
+ ```
248
+
249
+ `pkill -f PATTERN` can kill the shell that runs it, because the pattern is
250
+ in that shell's command line. `pp kill` leaves out itself and the processes
251
+ that started it.
252
+
253
+ ```
254
+ pp batch rename ' ' '_' '*' spaces to underscores, after a preview
255
+ pp batch rename -t '{parent}_{i:03}{ext}' 'photos/**/*.jpg' trip_001.jpg, trip_002.jpg, ... in each folder
256
+ pp batch run 'ffmpeg -i {} {out}' '*.wav' --out 'mp3/{stem}.mp3' -j 4
257
+ pp batch undo put the last rename back
258
+ ```
259
+
260
+ ```
261
+ pp http ~/notes . a tab for each folder, at http://127.0.0.1:8000
262
+ ```
263
+
264
+ `pp http` listens on 127.0.0.1 unless you pass `--bind`, and does not serve
265
+ files such as `.env` or private keys.
266
+
267
+ ## Your own commands
268
+
269
+ ```
270
+ pp cmd new hello --user -s "Say hello" writes ~/.config/pp/commands/hello.py, a working start
271
+ ```
272
+
273
+ A command is a Python file with a docstring and a `main` function. The
274
+ first line of the docstring is what `pp` lists, and the rest is its help.
275
+ Edit it into what you need. After this edit, `pp hello world` prints
276
+ `hello, world`:
277
+
278
+ ```python
279
+ """Say hello
280
+
281
+ pp hello NAME say hello to NAME
282
+ """
283
+ from pp.lib import ui
284
+
285
+
286
+ def main(argv: list[str]) -> int:
287
+ ap = ui.parser("hello", __doc__)
288
+ ap.add_argument("name")
289
+ a = ap.parse_args(argv)
290
+ print(f"hello, {a.name}")
291
+ return 0
292
+ ```
293
+
294
+ Commands that someone else wrote come as a plugin: a git repository with
295
+ command files in its `commands/` folder, or at its top.
296
+
297
+ ```
298
+ pp cmd add github.com/OWNER/REPO shows the commands it adds, then asks
299
+ pp cmd add github.com/OWNER/REPO -n --show only shows, with their code
300
+ pp cmd update fetches, shows the diff, then asks
301
+ pp cmd rm REPO
302
+ pp cmd ls every command, and where it comes from
303
+ ```
304
+
305
+ pp clones the plugin into `~/.local/share/pp/plugins/` and checks out the
306
+ commit you said yes to. Only `pp cmd update` moves it, after showing you
307
+ the diff. A plugin's code runs as you, so read it before you say yes.
308
+
309
+ A program named `pp-NAME` on your `PATH`, in any language, is a command
310
+ too. To add a command to pp itself, see [CONTRIBUTING.md](https://github.com/xflash96/pp/blob/main/CONTRIBUTING.md).
311
+
312
+ ## Status
313
+
314
+ pp is new, and its version is 0.x. A command or a config key may still
315
+ change, and so may a field of `--json` output; the
316
+ [changelog](https://github.com/xflash96/pp/blob/main/CHANGELOG.md) says when one does. CI runs the tests on
317
+ Python 3.11 and 3.14, on Ubuntu 24.04.
318
+
319
+ ## Contributing
320
+
321
+ [CONTRIBUTING.md](https://github.com/xflash96/pp/blob/main/CONTRIBUTING.md) says how to test and send a change, and
322
+ what a command needs to fit in. Bugs go to the
323
+ [issue tracker](https://github.com/xflash96/pp/issues).
324
+
325
+ Developed with agent assistance.
326
+
327
+ ## License
328
+
329
+ Copyright 2026 The pp Authors. Licensed under the Apache License 2.0; see
330
+ [LICENSE](https://github.com/xflash96/pp/blob/main/LICENSE).
@@ -0,0 +1,24 @@
1
+ pp/__init__.py,sha256=XX4APe7_X_VM5uMHXoo1OT70895_u1NJwH8vn6xdcDA,225
2
+ pp/__main__.py,sha256=L-ldKT90Z-_Ex094d3i--sLiTs8-bLTUx2_Z0pT3acA,54
3
+ pp/cli.py,sha256=IqLHaoxu0e1jpuJ6KXKghRCoatCRss6pnJPnRGcz_Ho,15504
4
+ pp/commands/__init__.py,sha256=TvcAOaDdkKAefBhdC2Twgb5Uvfo578lMzMRBQHxxqq8,113
5
+ pp/commands/batch.py,sha256=85Bg3TwkR0jH01pUWTSmn15ZaYHQATYUu9m0ViyPcIY,40754
6
+ pp/commands/cmd.py,sha256=M8wevaPnRcO1SpBmRZ98QX2WamJ_B6Aokq8Uc86PjCo,25779
7
+ pp/commands/completion.py,sha256=RkOUF2v4rWrzsr7TFVNHW16jRbgNEgEvWswpL1p0ocg,1637
8
+ pp/commands/http.py,sha256=qrjfBIdGoiKuThoDE82-TnaGb36e4M3faW03v3WCYw0,77377
9
+ pp/commands/kill.py,sha256=5yRqo6UGvxQ2yGgeNChXz45x3DlRzWCY7YYNng11hSQ,14519
10
+ pp/commands/promote.py,sha256=UGwcV0xEaiXUCg1-V6a52QXKUg0QZLSIRw0wk-TGI7U,46507
11
+ pp/commands/queue.py,sha256=mYtPV6xw4_fuRlCBCXTZsayBkSoyysCW1xKYFTVpcpA,15999
12
+ pp/lib/__init__.py,sha256=MMRvvcZ3s7MSFceL_Wg8K6LirBV4HZBNLQks6khIYEQ,88
13
+ pp/lib/config.py,sha256=PBS0rs-1hzt8gS3RE5RF62XoOzdh9-yXxGwKijbHceU,4468
14
+ pp/lib/jobq.py,sha256=S4CNiryGlT5dCP8ckrlcQTU_2pk7jknx_au46eUlAho,162475
15
+ pp/lib/lock.py,sha256=ZnMlDmW437V5CXKHfaZD2kXoUXgNXqa74Y2oICSDfL4,17276
16
+ pp/lib/paths.py,sha256=kPcwlC1lCcFAlIKnw9LScjsEhKN8Hik9BOABjBid7ak,6927
17
+ pp/lib/proc.py,sha256=ss675Vl2EXoNld-mlpLdZM2-FWvnJSE9C-cQCINe3zs,31770
18
+ pp/lib/session.py,sha256=YY8TlbszqdDksb9wKzvuhNtgnFrgGF7d1CD9cTEpEh4,1537
19
+ pp/lib/ui.py,sha256=hMHmFKP_ty53WG81FcFrdyOPbceANAIUkpKKazIWfeE,8154
20
+ ppkit-0.1.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
21
+ ppkit-0.1.0.dist-info/WHEEL,sha256=RRVLqVugUmFOqBedBFAmA4bsgFcROUBiSUKlERi0Hcg,79
22
+ ppkit-0.1.0.dist-info/entry_points.txt,sha256=kCQ8mdFRBFFi3n1i8zzGzItQFX2gtRNFL8M_UqS04Ok,36
23
+ ppkit-0.1.0.dist-info/METADATA,sha256=Wu0kUzfhzPMudCm-lJRTQWq1IEMKdTgEfUj1FmcNnX4,13634
24
+ ppkit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.21
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ pp = pp.cli:main
3
+