call-dibs 0.3.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.
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: call-dibs
3
+ Version: 0.3.0
4
+ Summary: Call dibs on shared stuff — a one-file lock ledger for humans and AI agents.
5
+ License: MIT
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Dynamic: license-file
10
+
11
+ <div align="center">
12
+
13
+ # ✋ call-dibs
14
+
15
+ **Call dibs on shared stuff.**
16
+
17
+ A tiny lock ledger so humans and AI agents stop fighting over the same phone, GPU, or test bench.
18
+
19
+ [![CI](https://github.com/NeoMarcoPolo/call-dibs/actions/workflows/ci.yml/badge.svg)](https://github.com/NeoMarcoPolo/call-dibs/actions/workflows/ci.yml)
20
+ [![PyPI](https://img.shields.io/pypi/v/call-dibs)](https://pypi.org/project/call-dibs/)
21
+ [![Python 3.8+](https://img.shields.io/badge/python-3.8%2B-blue)](https://www.python.org/)
22
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
23
+
24
+ </div>
25
+
26
+ ```console
27
+ $ dibs claim phone-a --note "regression run"
28
+ claimed phone-a as alice
29
+
30
+ $ dibs claim phone-a # …from another session
31
+ BUSY phone-a: held by alice since 2026-09-02T21:14:03Z (3m ago) — "regression run"
32
+
33
+ $ dibs release phone-a
34
+ released phone-a (was alice)
35
+ ```
36
+
37
+ ## Why
38
+
39
+ You have one test phone, one GPU, one hardware bench — and several
40
+ terminals (or several coding agents) that can all reach it. `dibs` is the
41
+ smallest thing that makes them take turns.
42
+
43
+ - **One file, no dependencies.** A single Python script, stdlib only. No
44
+ daemon, no server, no database.
45
+ - **Cross-platform.** macOS, Linux, Windows.
46
+ - **Legible.** The ledger is one human-readable JSON file per resource, and
47
+ every "busy" says *who*, *since when*, and *why*.
48
+ - **Agent-friendly.** Stable exit codes, `--json`, a blocking `--wait`, and a
49
+ `run` wrapper that always releases — even when the command fails.
50
+
51
+ ## Install
52
+
53
+ ```sh
54
+ pipx install call-dibs # or: pip install call-dibs · uv tool install call-dibs
55
+ ```
56
+
57
+ Works the same on macOS, Linux, and Windows. Prefer no package manager? It's
58
+ one file — [`dibs.py`](dibs.py) — drop it on your `PATH`.
59
+
60
+ ## Set up
61
+
62
+ Tell `dibs` what you share. Write `~/.dibs/resources.json` once, by hand:
63
+
64
+ ```json
65
+ {
66
+ "phone-a": "Android test phone on the bench",
67
+ "gpu-0": "the shared training GPU",
68
+ "printer": "label printer, room 2"
69
+ }
70
+ ```
71
+
72
+ Only these names can be claimed, so a typo can't silently create a new lock.
73
+ `dibs` never edits this file.
74
+
75
+ ## Use
76
+
77
+ | command | what it does |
78
+ |---|---|
79
+ | `dibs claim <r...> [--note ...] [--wait [--timeout N]]` | claim one or more resources. Exit `2` + holder info if busy |
80
+ | `dibs release <r...> [--force]` | release |
81
+ | `dibs status [r] [--json]` | who has what |
82
+ | `dibs wait <r...> [--timeout N]` | block until free, without claiming |
83
+ | `dibs run <r> -- <cmd...>` | claim → run → always release |
84
+ | `dibs watch` | live terminal view |
85
+
86
+ Exit codes: `0` ok · `2` busy · `3` not yours · `4` wait timeout · `1` error.
87
+
88
+ ### Who's asking
89
+
90
+ Every claim records an owner, and only that owner can release it. Name
91
+ yourself once per session with `DIBS_OWNER`:
92
+
93
+ ```sh
94
+ export DIBS_OWNER=alice # people
95
+ DIBS_OWNER=agent:task-123 dibs claim … # agents: inline, so every call agrees
96
+ ```
97
+
98
+ If it's unset, `dibs` falls back to `user@host:pid` — fine for one-off use,
99
+ wrong for agents whose shell changes every command. `--force` releases
100
+ someone else's lock when they're gone.
101
+
102
+ ### Groups
103
+
104
+ Claim several resources in one call and they form a group. The claim is
105
+ all-or-nothing — if any resource is busy, nothing is taken — and the group
106
+ gets a tag so you can drop the whole set at once:
107
+
108
+ ```console
109
+ $ dibs claim phone-a gpu-0 --note "regression run"
110
+ claimed gpu-0 as alice
111
+ claimed phone-a as alice
112
+ group g-3fa2c1: gpu-0, phone-a (discard with: dibs release g-3fa2c1)
113
+
114
+ $ dibs release g-3fa2c1
115
+ ```
116
+
117
+ Name the group yourself with `--as NAME`.
118
+
119
+ ## For AI agents
120
+
121
+ `skills/dibs/` is a drop-in skill for Claude Code (and reads fine as an
122
+ `AGENTS.md` snippet for anything else). It teaches the protocol: claim before
123
+ touching hardware, one call per set, release when done, never force-break
124
+ someone else's lock.
125
+
126
+ ```sh
127
+ cp -r skills/dibs ~/.claude/skills/
128
+ ```
129
+
130
+ ## Menu bar (macOS)
131
+
132
+ See the ledger at a glance: `dibs ✋2` in the top bar when two things are
133
+ claimed, `dibs ✓` when everything is free, and who-has-what in the dropdown.
134
+ Install [SwiftBar](https://swiftbar.app), then add the plugin:
135
+
136
+ ```sh
137
+ open "swiftbar://addplugin?src=https://raw.githubusercontent.com/NeoMarcoPolo/call-dibs/main/contrib/dibs.5s.sh"
138
+ ```
139
+
140
+ ## How it works
141
+
142
+ One JSON file per resource under `~/.dibs/` (override with `DIBS_DIR`). A
143
+ claim is an atomic `O_CREAT|O_EXCL` create, so racing claimers get exactly
144
+ one winner. Locks are advisory and are held until released — there is no
145
+ expiry. The ledger is per-machine by default; point `DIBS_DIR` at a shared
146
+ directory to span machines.
147
+
148
+ ## Contributing
149
+
150
+ `python3 -m unittest discover tests`. Keep it small.
151
+
152
+ Releases: bump `version` in `pyproject.toml` and `dibs.py`, then push a
153
+ `v*` tag — CI publishes to PyPI via trusted publishing (maintainer approval
154
+ required).
155
+
156
+ ## License
157
+
158
+ [MIT](LICENSE)
@@ -0,0 +1,7 @@
1
+ dibs.py,sha256=BOumbuEwtUQbyh7B7Tckm45uoDtyCKndAaeR02J-6Vs,14919
2
+ call_dibs-0.3.0.dist-info/licenses/LICENSE,sha256=NobDAGyTMv0DH21lcgdia5DoUdpSPucoyDs58xrcEVs,1067
3
+ call_dibs-0.3.0.dist-info/METADATA,sha256=ujey6kjx9ivGY-P7PLP199p5gu2hTWJutystIyX1nkQ,5091
4
+ call_dibs-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ call_dibs-0.3.0.dist-info/entry_points.txt,sha256=I3tSKzk5X3hCFWYKxAeVBJale3mGAqqpmJRLjFlSyK8,35
6
+ call_dibs-0.3.0.dist-info/top_level.txt,sha256=iddzCviTlLjrDlmJ7Ucz_jrPB4ESV3_hpKNJvuWiL0s,5
7
+ call_dibs-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dibs = dibs:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Luke Zhang
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
+ dibs
dibs.py ADDED
@@ -0,0 +1,413 @@
1
+ #!/usr/bin/env python3
2
+ """dibs — call dibs on shared stuff.
3
+
4
+ A tiny advisory lock ledger for physical resources shared by humans and AI
5
+ agents: test phones, GPUs, staging environments, the label printer. One JSON
6
+ file per resource. No daemon, no dependencies — Python 3.8+ stdlib only.
7
+ macOS / Linux / Windows.
8
+
9
+ dibs claim gpu-0 --note "training run"
10
+ dibs status
11
+ dibs release gpu-0
12
+
13
+ A lock is held until it is released. If a holder vanished, break the lock
14
+ with `dibs release <r> --force` (loud on purpose).
15
+
16
+ Ledger dir: $DIBS_DIR (default ~/.dibs)
17
+ Owner id: $DIBS_OWNER (default user@host:pid<ppid> — set a stable one!)
18
+ Resources: $DIBS_DIR/resources.json — you write it by hand, once:
19
+ {"gpu-0": "the shared GPU", "phone-a": "test phone"}
20
+ Only defined names can be claimed. dibs never edits this file.
21
+
22
+ Claiming several resources in one call takes them all-or-nothing and forms
23
+ a group (auto-named, or `--as NAME`); release the group to drop them all:
24
+
25
+ dibs claim phone-a gpu-0 -> group g-3fa2c1: gpu-0, phone-a
26
+ dibs release g-3fa2c1
27
+
28
+ Exit codes: 0 ok · 2 busy · 3 not held by you · 4 wait timeout · 1 error
29
+ """
30
+ import argparse
31
+ import getpass
32
+ import json
33
+ import os
34
+ import re
35
+ import socket
36
+ import sys
37
+ import time
38
+ from datetime import datetime, timezone
39
+ from pathlib import Path
40
+
41
+ __version__ = "0.3.0"
42
+
43
+ LEDGER = Path(os.environ.get("DIBS_DIR", Path.home() / ".dibs"))
44
+ NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
45
+
46
+
47
+ def now_iso():
48
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
49
+
50
+
51
+ def age_str(ts):
52
+ since = datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
53
+ secs = int((datetime.now(timezone.utc) - since).total_seconds())
54
+ if secs < 90:
55
+ return f"{secs}s"
56
+ if secs < 5400:
57
+ return f"{secs // 60}m"
58
+ if secs < 129600:
59
+ return f"{secs // 3600}h{(secs % 3600) // 60:02d}m"
60
+ return f"{secs // 86400}d"
61
+
62
+
63
+ def default_owner():
64
+ o = os.environ.get("DIBS_OWNER")
65
+ if o:
66
+ return o
67
+ return f"{getpass.getuser()}@{socket.gethostname().split('.')[0]}:pid{os.getppid()}"
68
+
69
+
70
+ def lock_path(resource):
71
+ if not NAME_RE.match(resource):
72
+ raise SystemExit(f"dibs: bad resource name {resource!r} "
73
+ "(letters/digits/._- only)")
74
+ return LEDGER / f"{resource}.lock.json"
75
+
76
+
77
+ def read_lock(path):
78
+ try:
79
+ return json.loads(path.read_text())
80
+ except (FileNotFoundError, json.JSONDecodeError):
81
+ return None
82
+
83
+
84
+ def registry():
85
+ """Hand-written {"name": "description"} map, or None if absent."""
86
+ try:
87
+ reg = json.loads((LEDGER / "resources.json").read_text())
88
+ if isinstance(reg, dict):
89
+ return reg
90
+ except FileNotFoundError:
91
+ return None
92
+ except json.JSONDecodeError as e:
93
+ raise SystemExit(f"dibs: {LEDGER / 'resources.json'} is not valid JSON: {e}")
94
+ raise SystemExit(f"dibs: {LEDGER / 'resources.json'} must be a JSON object "
95
+ '{"name": "description"}')
96
+
97
+
98
+ NO_REGISTRY = (f"dibs: no resources defined yet. Create {LEDGER / 'resources.json'} "
99
+ 'by hand, e.g. {"gpu-0": "the shared GPU", "phone-a": "test phone"}')
100
+
101
+
102
+ def check_names(names):
103
+ """Validate claim targets against the registry; return sorted, deduped."""
104
+ reg = registry()
105
+ if not reg:
106
+ raise SystemExit(NO_REGISTRY)
107
+ for name in names:
108
+ if name not in reg:
109
+ raise SystemExit(f"dibs: {name!r} is not defined in resources.json "
110
+ f"(known: {', '.join(sorted(reg))})")
111
+ return sorted(set(names))
112
+
113
+
114
+ def holder_line(resource, rec):
115
+ note = f' — "{rec["note"]}"' if rec.get("note") else ""
116
+ grp = f" [group {rec['group']}]" if rec.get("group") else ""
117
+ return (f"{resource}: held by {rec['owner']} "
118
+ f"since {rec['since']} ({age_str(rec['since'])} ago){note}{grp}")
119
+
120
+
121
+ def ledger_rows():
122
+ """All resources: registry entries plus any live locks."""
123
+ LEDGER.mkdir(parents=True, exist_ok=True)
124
+ reg = registry() or {}
125
+ rows = {name: {"resource": name, "description": desc}
126
+ for name, desc in sorted(reg.items())
127
+ if isinstance(desc, str)} # list values are groups, not resources
128
+ for p in sorted(LEDGER.glob("*.lock.json")):
129
+ rec = read_lock(p)
130
+ if rec is None:
131
+ continue
132
+ name = rec.get("resource", p.name[:-len(".lock.json")])
133
+ desc = reg.get(name, "")
134
+ rec.setdefault("description", desc if isinstance(desc, str) else "")
135
+ rows[name] = {**rows.get(name, {}), **rec}
136
+ return list(rows.values())
137
+
138
+
139
+ def try_claim_one(resource, owner, note, group=None):
140
+ """Returns 'claimed' | 'yours' | the blocking record."""
141
+ path = lock_path(resource)
142
+ rec = {"resource": resource, "owner": owner,
143
+ "host": socket.gethostname().split(".")[0],
144
+ "since": now_iso(), "note": note or None, "group": group}
145
+ while True:
146
+ LEDGER.mkdir(parents=True, exist_ok=True)
147
+ try:
148
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
149
+ with os.fdopen(fd, "w") as f:
150
+ json.dump(rec, f, indent=2)
151
+ return "claimed"
152
+ except FileExistsError:
153
+ cur = read_lock(path)
154
+ if cur is None:
155
+ continue # holder released between our create and read; retry
156
+ if cur.get("owner") == owner:
157
+ if note and note != cur.get("note"):
158
+ tmp = path.with_name(path.name + f".tmp-{os.getpid()}")
159
+ tmp.write_text(json.dumps(dict(cur, note=note), indent=2))
160
+ os.replace(tmp, path)
161
+ return "yours"
162
+ return cur
163
+
164
+
165
+ def cmd_claim(a):
166
+ """All-or-nothing: on any BUSY, locks newly taken by this call are rolled
167
+ back (sorted claim order keeps overlapping sets deadlock-free). Claiming
168
+ several resources at once forms a group — a tag stamped on each lock —
169
+ so the whole set can later be discarded with `dibs release <group>`."""
170
+ resources = check_names(a.resources)
171
+ owner = a.owner or default_owner()
172
+ group = getattr(a, "as_group", None)
173
+ if group is None and len(resources) > 1:
174
+ group = "g-" + os.urandom(3).hex()
175
+ if group and not NAME_RE.match(group):
176
+ raise SystemExit(f"dibs: bad group name {group!r}")
177
+ deadline = time.time() + a.timeout if a.timeout else None
178
+ while True:
179
+ got, blocker = [], None
180
+ for r in resources:
181
+ res = try_claim_one(r, owner, a.note, group)
182
+ if res == "claimed":
183
+ got.append(r)
184
+ elif res != "yours":
185
+ blocker = (r, res)
186
+ break
187
+ if blocker is None:
188
+ for r in resources:
189
+ print(f"claimed {r} as {owner}")
190
+ if group:
191
+ print(f"group {group}: {', '.join(resources)} "
192
+ f"(discard with: dibs release {group})")
193
+ return 0
194
+ for r in got: # roll back this call's partial set
195
+ try:
196
+ lock_path(r).unlink()
197
+ except FileNotFoundError:
198
+ pass
199
+ if not a.wait:
200
+ print("BUSY " + holder_line(*blocker), file=sys.stderr)
201
+ return 2
202
+ if deadline and time.time() > deadline:
203
+ print("timeout waiting; " + holder_line(*blocker), file=sys.stderr)
204
+ return 4
205
+ time.sleep(a.poll)
206
+
207
+
208
+ def resolve_targets(names):
209
+ """Each name is a resource with a live lock, or a group tag: expands to
210
+ every locked resource carrying that tag. Unknown names pass through."""
211
+ LEDGER.mkdir(parents=True, exist_ok=True)
212
+ locks = [rec for p in sorted(LEDGER.glob("*.lock.json"))
213
+ if (rec := read_lock(p))]
214
+ out = []
215
+ for name in names:
216
+ members = [r["resource"] for r in locks if r.get("group") == name]
217
+ if members and not any(r["resource"] == name for r in locks):
218
+ out.extend(members)
219
+ else:
220
+ out.append(name)
221
+ return sorted(set(out))
222
+
223
+
224
+ def cmd_release(a):
225
+ owner = a.owner or default_owner()
226
+ rc = 0
227
+ for resource in resolve_targets(a.resources):
228
+ path = lock_path(resource)
229
+ rec = read_lock(path)
230
+ if rec is None:
231
+ print(f"{resource}: not held")
232
+ continue
233
+ if rec.get("owner") != owner and not a.force:
234
+ print(f"NOT YOURS {holder_line(resource, rec)}\n"
235
+ f"(you are {owner}; use --force to break it)", file=sys.stderr)
236
+ rc = 3
237
+ continue
238
+ try:
239
+ path.unlink()
240
+ except FileNotFoundError:
241
+ pass
242
+ verb = "broke" if rec.get("owner") != owner else "released"
243
+ print(f"{verb} {resource} (was {rec.get('owner')})")
244
+ return rc
245
+
246
+
247
+ def cmd_status(a):
248
+ rows = ledger_rows()
249
+ if a.resource:
250
+ rows = [r for r in rows if r["resource"] == a.resource]
251
+ if not rows:
252
+ print(f"{a.resource}: free")
253
+ return 0
254
+ if a.json:
255
+ print(json.dumps(rows, indent=2))
256
+ return 0
257
+ if a.xbar:
258
+ held = [r for r in rows if r.get("owner")]
259
+ print(f"dibs ✋{len(held)}" if held else "dibs ✓")
260
+ print("---")
261
+ if not rows:
262
+ print("no resources.json yet | color=gray")
263
+ for r in rows:
264
+ if r.get("owner"):
265
+ note = f" · {r['note']}" if r.get("note") else ""
266
+ grp = f" · {r['group']}" if r.get("group") else ""
267
+ print(f"{r['resource']} — {r['owner']}{note}{grp} | color=#e05d44")
268
+ else:
269
+ print(f"{r['resource']} — free | color=#44a05d")
270
+ return 0
271
+ if not rows:
272
+ print(NO_REGISTRY, file=sys.stderr)
273
+ return 1
274
+ for r in rows:
275
+ if r.get("owner"):
276
+ print(holder_line(r["resource"], r))
277
+ else:
278
+ desc = f" ({r['description']})" if r.get("description") else ""
279
+ print(f"{r['resource']}: free{desc}")
280
+ return 0
281
+
282
+
283
+ def cmd_wait(a):
284
+ deadline = time.time() + a.timeout if a.timeout else None
285
+ while True:
286
+ resources = resolve_targets(a.resources)
287
+ held = next(((r, rec) for r in resources
288
+ if (rec := read_lock(lock_path(r)))), None)
289
+ if held is None:
290
+ print(", ".join(a.resources) + ": free")
291
+ return 0
292
+ if deadline and time.time() > deadline:
293
+ print("timeout: " + holder_line(*held), file=sys.stderr)
294
+ return 4
295
+ time.sleep(a.poll)
296
+
297
+
298
+ def cmd_run(a):
299
+ import subprocess
300
+ if "--" in a.argv:
301
+ i = a.argv.index("--")
302
+ if a.argv[:i]:
303
+ print("dibs run: put dibs flags BEFORE <resource> "
304
+ f"(these were ignored: {' '.join(a.argv[:i])})", file=sys.stderr)
305
+ return 1
306
+ argv = a.argv[i + 1:]
307
+ else:
308
+ argv = a.argv
309
+ if argv and argv[0].startswith("-"):
310
+ print("dibs run: put dibs flags BEFORE <resource>; the command goes "
311
+ "after -- (dibs run --note x <resource> -- cmd ...)", file=sys.stderr)
312
+ return 1
313
+ if not argv:
314
+ print("dibs run: no command given (dibs run <resource> -- cmd ...)",
315
+ file=sys.stderr)
316
+ return 1
317
+ a.resources = [a.resource]
318
+ rc = cmd_claim(a)
319
+ if rc != 0:
320
+ return rc
321
+ try:
322
+ return subprocess.call(argv)
323
+ finally:
324
+ try:
325
+ lock_path(a.resource).unlink()
326
+ except FileNotFoundError:
327
+ pass
328
+ print(f"released {a.resource}", file=sys.stderr)
329
+
330
+
331
+ def cmd_watch(a):
332
+ try:
333
+ while True:
334
+ rows = ledger_rows()
335
+ sys.stdout.write("\x1b[2J\x1b[H")
336
+ print(f"dibs ledger · {LEDGER} (ctrl-c to quit)\n")
337
+ if not rows:
338
+ print(" " + NO_REGISTRY)
339
+ for r in rows:
340
+ if r.get("owner"):
341
+ print(" 🔴 " + holder_line(r["resource"], r))
342
+ else:
343
+ print(f" 🟢 {r['resource']}: free")
344
+ sys.stdout.flush()
345
+ time.sleep(a.poll)
346
+ except KeyboardInterrupt:
347
+ return 0
348
+
349
+
350
+ def main():
351
+ p = argparse.ArgumentParser(
352
+ prog="dibs", description=__doc__,
353
+ formatter_class=argparse.RawDescriptionHelpFormatter)
354
+ p.add_argument("--version", action="version", version=f"dibs {__version__}")
355
+ sub = p.add_subparsers(dest="command", required=True)
356
+
357
+ def add_claim_args(sp, multi):
358
+ if multi:
359
+ sp.add_argument("resources", nargs="+", metavar="resource",
360
+ help="resource or group names")
361
+ else:
362
+ sp.add_argument("resource", help="resource or group name")
363
+ sp.add_argument("--note", help="why you have it")
364
+ sp.add_argument("--as", dest="as_group", metavar="GROUP",
365
+ help="name the group (default: auto g-xxxxxx when "
366
+ "claiming several resources)")
367
+ sp.add_argument("--owner", help="override owner id (or set $DIBS_OWNER)")
368
+ sp.add_argument("--wait", action="store_true", help="block until claimable")
369
+ sp.add_argument("--timeout", type=int,
370
+ help="give up after N seconds (with --wait)")
371
+ sp.add_argument("--poll", type=int, default=5, help="poll interval seconds")
372
+
373
+ sp = sub.add_parser(
374
+ "claim", help="claim resources/groups, all-or-nothing (exit 2 if busy)")
375
+ add_claim_args(sp, multi=True)
376
+ sp.set_defaults(fn=cmd_claim)
377
+
378
+ sp = sub.add_parser("release", help="release resources/groups you hold")
379
+ sp.add_argument("resources", nargs="+", metavar="resource")
380
+ sp.add_argument("--owner")
381
+ sp.add_argument("--force", action="store_true",
382
+ help="break someone else's lock")
383
+ sp.set_defaults(fn=cmd_release)
384
+
385
+ sp = sub.add_parser("status", help="show the ledger (all, or one resource)")
386
+ sp.add_argument("resource", nargs="?")
387
+ sp.add_argument("--json", action="store_true")
388
+ sp.add_argument("--xbar", action="store_true",
389
+ help="xbar/SwiftBar plugin output")
390
+ sp.set_defaults(fn=cmd_status)
391
+
392
+ sp = sub.add_parser("wait", help="block until resources are free (no claim)")
393
+ sp.add_argument("resources", nargs="+", metavar="resource")
394
+ sp.add_argument("--timeout", type=int)
395
+ sp.add_argument("--poll", type=int, default=5)
396
+ sp.set_defaults(fn=cmd_wait)
397
+
398
+ sp = sub.add_parser(
399
+ "run", help="claim, run a command, auto-release: dibs run gpu -- make train")
400
+ add_claim_args(sp, multi=False)
401
+ sp.add_argument("argv", nargs=argparse.REMAINDER)
402
+ sp.set_defaults(fn=cmd_run)
403
+
404
+ sp = sub.add_parser("watch", help="live terminal view of the ledger")
405
+ sp.add_argument("--poll", type=int, default=2)
406
+ sp.set_defaults(fn=cmd_watch)
407
+
408
+ a = p.parse_args()
409
+ sys.exit(a.fn(a))
410
+
411
+
412
+ if __name__ == "__main__":
413
+ main()