loobric-cli 1.0.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.
loobric/cli/main.py ADDED
@@ -0,0 +1,1917 @@
1
+ #!/usr/bin/env python3
2
+ # PYTHON_ARGCOMPLETE_OK
3
+ # MIT License
4
+ # Copyright (c) 2025 sliptonic
5
+ # SPDX-License-Identifier: MIT
6
+ """Loobric client CLI — an argparse shell over loobric.Client.
7
+
8
+ The universal command-line client: `loobric <verb>`. Commands build a Client from
9
+ the CLI's current config (transport.BASE_URL / API_KEY / session) so they
10
+ exercise the same library other clients import.
11
+ """
12
+ import argparse
13
+ import getpass
14
+ import json
15
+ import os
16
+ import sys
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ from loobric import transport
20
+ from loobric.client import Client
21
+ from loobric.errors import (
22
+ AuthRequired, ConnectionFailed, HTTPError, NotFound, LoobricClientError,
23
+ )
24
+
25
+
26
+ def _client() -> Client:
27
+ """Build a Client from the CLI's current global config (transport.BASE_URL/transport.API_KEY/
28
+ session). Used by the CLI shell so its commands exercise the same library
29
+ other clients import."""
30
+ return Client(base_url=transport.BASE_URL, api_key=transport.API_KEY, session_cookie=transport.SESSION_COOKIE)
31
+
32
+
33
+ def register(email: str = None, password: str = None):
34
+ """Register a new user account.
35
+
36
+ First user registration is open. Subsequent registrations require admin auth.
37
+
38
+ Args:
39
+ email: User email address (will prompt if not provided)
40
+ password: User password (will prompt if not provided)
41
+ """
42
+ # Prompt for email if not provided
43
+ if not email:
44
+ email = input("Email: ").strip()
45
+ if not email:
46
+ print("Error: Email is required", file=sys.stderr)
47
+ sys.exit(1)
48
+
49
+ # Prompt for password if not provided
50
+ if not password:
51
+ password = getpass.getpass("Password: ")
52
+ if not password:
53
+ print("Error: Password is required", file=sys.stderr)
54
+ sys.exit(1)
55
+ # Confirm password
56
+ password_confirm = getpass.getpass("Confirm Password: ")
57
+ if password != password_confirm:
58
+ print("Error: Passwords do not match", file=sys.stderr)
59
+ sys.exit(1)
60
+
61
+ data = _client().register(email, password)
62
+ # Pin the server we registered against so the next command targets it
63
+ # without re-passing --base-url (login would do this, but a user may run
64
+ # other commands between register and login).
65
+ if transport.BASE_URL:
66
+ transport.save_base_url(transport.BASE_URL, email=email)
67
+ print("✓ Registration successful!")
68
+ print(f" User: {data.get('email', 'Unknown')}")
69
+ if data.get('id'):
70
+ print(f" User ID: {data.get('id')}")
71
+ print("\nYou can now login with:")
72
+ print(f" loobric login {email}")
73
+
74
+
75
+ def login(email: str = None, password: str = None, base_url: str = None):
76
+ """Authenticate and capture session cookie.
77
+
78
+ Args:
79
+ email: User email address (will prompt if not provided)
80
+ password: User password (will prompt if not provided)
81
+ base_url: Base URL (will prompt if not provided and not in session)
82
+ """
83
+
84
+ # Prompt for base URL if not provided
85
+ if base_url:
86
+ transport.BASE_URL = base_url.rstrip("/")
87
+ elif not transport.BASE_URL:
88
+ url_input = input("Base URL [http://127.0.0.1:8000]: ").strip()
89
+ if not url_input:
90
+ url_input = "http://127.0.0.1:8000"
91
+ transport.BASE_URL = url_input.rstrip("/")
92
+
93
+ # Prompt for email if not provided
94
+ if not email:
95
+ email = input("Email: ").strip()
96
+ if not email:
97
+ print("Error: Email is required", file=sys.stderr)
98
+ sys.exit(1)
99
+
100
+ # Prompt for password if not provided
101
+ if not password:
102
+ password = getpass.getpass("Password: ")
103
+ if not password:
104
+ print("Error: Password is required", file=sys.stderr)
105
+ sys.exit(1)
106
+
107
+ data = _client().login(email, password)
108
+ print("✓ Login successful!")
109
+ print(f" User: {data.get('email', 'Unknown')}")
110
+ if data.get('id'):
111
+ print(f" User ID: {data.get('id')}")
112
+ if not transport.SESSION_COOKIE:
113
+ print("⚠ Warning: No session cookie received. Authentication may have failed.", file=sys.stderr)
114
+ else:
115
+ transport.save_session(email=email)
116
+ print(f" Session saved to {transport.SESSION_FILE}")
117
+ print(f" Base URL: {transport.BASE_URL}")
118
+
119
+
120
+ def create_key(
121
+ name: str,
122
+ scopes: Optional[str] = None,
123
+ tags: Optional[str] = None,
124
+ expires_at: Optional[str] = None
125
+ ):
126
+ """Create a new API key.
127
+
128
+ Args:
129
+ name: Descriptive name for the API key
130
+ scopes: Space-separated list of scopes (e.g., 'read write:items')
131
+ tags: Space-separated list of tags (e.g., 'production mill-3')
132
+ expires_at: ISO 8601 datetime string for expiration
133
+ """
134
+ data = _client().create_key(
135
+ name,
136
+ scopes=scopes.strip().split() if scopes else None,
137
+ tags=tags.strip().split() if tags else None,
138
+ expires_at=expires_at,
139
+ )
140
+
141
+ # Output the plain API key first (for easy copying/piping)
142
+ print(data.get('key'))
143
+
144
+ # Then output details to stderr so they don't interfere with key capture
145
+ print("\n✓ API key created successfully!", file=sys.stderr)
146
+ print(f" ID: {data.get('id')}", file=sys.stderr)
147
+ print(f" Name: {data.get('name')}", file=sys.stderr)
148
+ print(f" Scopes: {', '.join(data.get('scopes', []))}", file=sys.stderr)
149
+ if data.get("tags"):
150
+ print(f" Tags: {', '.join(data.get('tags', []))}", file=sys.stderr)
151
+ if data.get("expires_at"):
152
+ print(f" Expires: {data.get('expires_at')}", file=sys.stderr)
153
+ print("\n⚠ Warning: Save the key now — it won't be shown again!", file=sys.stderr)
154
+ print("\nTo use this key, set the environment variable:", file=sys.stderr)
155
+ print(f" export LOOBRIC_API_KEY={data.get('key')}", file=sys.stderr)
156
+
157
+
158
+ def list_keys():
159
+ """List all API keys for the authenticated user."""
160
+ data = _client().list_keys()
161
+
162
+ if not data:
163
+ print("No API keys found.")
164
+ return
165
+
166
+ print("\nAPI Keys:")
167
+ print("-" * 80)
168
+ for key in data:
169
+ print(f" ID: {key.get('id')}")
170
+ print(f" Name: {key.get('name')}")
171
+ print(f" Status: {'active' if key.get('is_active', True) else 'REVOKED'}")
172
+ print(f" Scopes: {', '.join(key.get('scopes', []))}")
173
+ if key.get('tags'):
174
+ print(f" Tags: {', '.join(key.get('tags', []))}")
175
+ if key.get('created_at'):
176
+ print(f" Created: {key.get('created_at')}")
177
+ if key.get('expires_at'):
178
+ print(f" Expires: {key.get('expires_at')}")
179
+ if key.get('last_used_at'):
180
+ print(f" Last Used: {key.get('last_used_at')}")
181
+ print("-" * 80)
182
+
183
+
184
+ def revoke_key(key_id: str):
185
+ """Revoke (delete) an API key.
186
+
187
+ Args:
188
+ key_id: The ID of the key to revoke
189
+ """
190
+ _client().revoke_key(key_id)
191
+ print(f"✓ API key {key_id} revoked successfully.")
192
+
193
+
194
+ def list_tool_sets():
195
+ """List the user's tool sets (v2 facade: /api/v1/tool-set-records)."""
196
+ items = _client().list_tool_sets()
197
+
198
+ if not items:
199
+ print("No tool sets found.")
200
+ return
201
+
202
+ print(f"\nTool Sets ({len(items)}):")
203
+ print("=" * 80)
204
+ for tool_set in items:
205
+ print(f" ID: {_rid(tool_set)}")
206
+ print(f" Name: {_cval(tool_set, 'name')}")
207
+ members = (tool_set.get("canonical") or {}).get("members") or []
208
+ print(f" Members: {len(members)} tool record(s)")
209
+ if _ival(tool_set, 'updated_at'):
210
+ print(f" Updated: {_ival(tool_set, 'updated_at')}")
211
+ print(f" Version: {_ival(tool_set, 'version')}")
212
+ print("=" * 80)
213
+
214
+
215
+ def show_tool_set(set_handle):
216
+ """Show one tool set and its members: each member's number (with the source
217
+ that vouches for it), the tool it points at, and that tool's geometry."""
218
+ s = _resolve_tool_set(set_handle)
219
+ members = (s.get("canonical") or {}).get("members") or []
220
+ print(f"\nTool Set {_rid(s)}")
221
+ print("=" * 78)
222
+ print(f" Name: {_cval(s, 'name') or '(unnamed)'}")
223
+ machine = _cval(s, "machine_id")
224
+ if machine:
225
+ mname = {_rid(m): _cval(m, "name") for m in _client().list_machines()}.get(machine)
226
+ print(f" Machine: {mname or str(machine)[:8]} (member numbers inherited)")
227
+ else:
228
+ print(" Machine: not linked")
229
+ print(f" Members: {len(members)}")
230
+ if not members:
231
+ print(" (none yet — add tools with 'loobric add-to-set')")
232
+ print("=" * 78)
233
+ return
234
+ tools = {_rid(t): t for t in _client().list_tool_records()}
235
+
236
+ def _num(m):
237
+ v = (m.get("number") or {}).get("value")
238
+ return v if v is not None else float("inf")
239
+
240
+ for m in sorted(members, key=_num):
241
+ tid = m.get("tool_record_id")
242
+ num = m.get("number") or {}
243
+ t = tools.get(tid)
244
+ tname = (_cval(t, "name") if t else None) or (str(tid)[:8] if tid else "?")
245
+ ndisp = f"T{num['value']}" if num.get("value") is not None else "(no #)"
246
+ dia = _cval(t, "geometry", "diameter") if t else None
247
+ extra = f" ⌀{dia}" if dia is not None else ""
248
+ print(f" {ndisp:>6} {tname}{extra} [{num.get('source', 'unknown')}]")
249
+ print("=" * 78)
250
+
251
+
252
+ def add_to_set(set_handle, tools):
253
+ """Add one or more tools to a tool set (existing members are kept)."""
254
+ s = _resolve_tool_set(set_handle)
255
+ recs = [_resolve_record(t) for t in tools]
256
+ updated = _client().add_to_set(_rid(s), [_rid(r) for r in recs])
257
+ n = len((updated.get("canonical") or {}).get("members") or [])
258
+ label = _cval(s, "name") or str(_rid(s))[:8]
259
+ names = ", ".join(_cval(r, "name") or str(_rid(r))[:8] for r in recs)
260
+ print(f"✓ Added {len(recs)} tool(s) to '{label}' — {names}. Now {n} member(s).")
261
+
262
+
263
+ def remove_from_set(set_handle, tools):
264
+ """Remove one or more tools from a tool set (the rest are kept)."""
265
+ s = _resolve_tool_set(set_handle)
266
+ recs = [_resolve_record(t) for t in tools]
267
+ updated = _client().remove_from_set(_rid(s), [_rid(r) for r in recs])
268
+ n = len((updated.get("canonical") or {}).get("members") or [])
269
+ label = _cval(s, "name") or str(_rid(s))[:8]
270
+ names = ", ".join(_cval(r, "name") or str(_rid(r))[:8] for r in recs)
271
+ print(f"✓ Removed {len(recs)} tool(s) from '{label}' — {names}. Now {n} member(s).")
272
+
273
+
274
+ def list_pending():
275
+ """List inbox items awaiting review (binding proposals).
276
+
277
+ The inbox holds what sync could not decide on its own (G2): heuristic
278
+ binding proposals awaiting a human. Resolve with `resolve <id> confirm`
279
+ or `resolve <id> reject`.
280
+ """
281
+ items = _client().list_inbox()
282
+ if not items:
283
+ print("Inbox is empty - nothing pending.")
284
+ return
285
+
286
+ print(f"\n{len(items)} unrecognized machine tool(s) - best-guess matches below.")
287
+ print()
288
+ print("This is an identity question, NOT a conflict: the machine reported")
289
+ print("tools the server doesn't recognize yet. Confirming or rejecting")
290
+ print("overwrites NOTHING on either side.")
291
+ print()
292
+ print(" confirm = 'same tool': links the machine entry to the record so")
293
+ print(" future changes route between them. Both keep their data.")
294
+ print(" reject = 'different tools': drops this suggestion permanently.")
295
+ print(" The entry stays unbound and keeps syncing fine.")
296
+ print(" unsure? = reject. A rejected pair can be linked manually later;")
297
+ print(" a wrong confirm is currently hard to undo.")
298
+ print("=" * 78)
299
+ for item in items:
300
+ entry = item.get("entry", {})
301
+ proposed = item.get("proposed_instance", {})
302
+ print(f" ID: {item.get('id')[:8]}")
303
+ print(f" Machine entry: T{entry.get('tool_number')}")
304
+ print(f" Proposed match: {proposed.get('name')}")
305
+ print(f" Confidence: {item.get('confidence'):.0%} - {item.get('reason')}")
306
+ print("-" * 78)
307
+ print("Resolve with: loobric resolve <id> confirm|reject")
308
+
309
+
310
+ def resolve_pending(item_id: str, action: str):
311
+ """Confirm or reject an inbox item.
312
+
313
+ Accepts unique id prefixes (like git short SHAs): anything shorter than
314
+ a full UUID is matched against the open inbox items client-side.
315
+
316
+ Args:
317
+ item_id: Inbox item id or unique prefix (from `pending`)
318
+ action: "confirm" (bind entry to proposed record) or "reject"
319
+ """
320
+ # The confirm/reject responses no longer echo the entry/instance, so resolve
321
+ # the item against the open inbox first — this both supports id prefixes and
322
+ # gives us the details for a friendly message.
323
+ open_items = _client().list_inbox()
324
+ matches = [i for i in open_items if i.get("id", "").startswith(item_id)]
325
+ if not matches:
326
+ print(f"Error: no open inbox item starts with '{item_id}'", file=sys.stderr)
327
+ sys.exit(1)
328
+ if len(matches) > 1:
329
+ print(f"Error: '{item_id}' is ambiguous ({len(matches)} matches):", file=sys.stderr)
330
+ for m in matches:
331
+ entry = m.get("entry", {})
332
+ print(f" {m['id'][:8]} T{entry.get('tool_number')} "
333
+ f"-> {m.get('proposed_instance', {}).get('name')}", file=sys.stderr)
334
+ sys.exit(1)
335
+ item = matches[0]
336
+ entry = item.get("entry", {})
337
+ proposed = item.get("proposed_instance", {})
338
+ c = _client()
339
+ (c.confirm_proposal if action == "confirm" else c.reject_proposal)(item["id"])
340
+ if action == "confirm":
341
+ print(f"Linked: T{entry.get('tool_number')} and '{proposed.get('name')}' are "
342
+ f"now the same tool. No data was changed on either side; future "
343
+ f"changes will route between them.")
344
+ else:
345
+ print(f"Dismissed: T{entry.get('tool_number')} is not '{proposed.get('name')}'. "
346
+ f"This suggestion won't reappear; the entry stays unbound.")
347
+
348
+
349
+ # ---------------------------------------------------------------------------
350
+ # Sectioned-record accessors (docs/TOOL_SCHEMA.md). Every v2 record is the
351
+ # three-section shape {internal, canonical, clients}; the server-owned id lives
352
+ # at internal.id and every canonical field is a provenance-tagged leaf
353
+ # {value, unit?, source}. All listing/parsing routes through these so the CLI
354
+ # never reaches for the retired flat top-level fields again.
355
+ # ---------------------------------------------------------------------------
356
+
357
+ def _rid(record: Dict[str, Any]) -> Optional[str]:
358
+ """The server-owned record id: internal.id."""
359
+ return (record.get("internal") or {}).get("id")
360
+
361
+
362
+ def _ival(record: Dict[str, Any], key: str) -> Any:
363
+ """An internal-section value (version, created_at, updated_at, machine_id)."""
364
+ return (record.get("internal") or {}).get(key)
365
+
366
+
367
+ def _cfield(record: Dict[str, Any], *path: str) -> Dict[str, Any]:
368
+ """The canonical leaf at a dotted path, e.g. ('geometry','diameter') ->
369
+ {value, unit?, source}. Returns {} when absent."""
370
+ node = record.get("canonical") or {}
371
+ for p in path:
372
+ node = (node or {}).get(p) or {}
373
+ return node if isinstance(node, dict) else {}
374
+
375
+
376
+ def _cval(record: Dict[str, Any], *path: str) -> Any:
377
+ """The `.value` of the canonical leaf at a dotted path (None when absent)."""
378
+ return _cfield(record, *path).get("value")
379
+
380
+
381
+ def _match_id(items: List[Dict[str, Any]], prefix: str, label: str) -> Dict[str, Any]:
382
+ """Resolve a possibly-abbreviated id against a list (git short-SHA style).
383
+
384
+ An exact match wins outright; otherwise a unique prefix match is used.
385
+ Exits with a helpful message on no/ambiguous match. Works on sectioned
386
+ records: the id is internal.id and the human label is canonical.name.value.
387
+ """
388
+ exact = [i for i in items if _rid(i) == prefix]
389
+ if exact:
390
+ return exact[0]
391
+ # An exact, unique name is as good as an id (humans think in names).
392
+ by_name = [i for i in items if _cval(i, "name") == prefix]
393
+ if len(by_name) == 1:
394
+ return by_name[0]
395
+ # Otherwise an id prefix, falling back to a name prefix.
396
+ matches = [i for i in items if str(_rid(i) or "").startswith(prefix)]
397
+ if not matches:
398
+ matches = [i for i in items if str(_cval(i, "name") or "").startswith(prefix)]
399
+ if not matches:
400
+ print(f"Error: no {label} matches '{prefix}'", file=sys.stderr)
401
+ sys.exit(1)
402
+ if len(matches) > 1:
403
+ print(f"Error: '{prefix}' is ambiguous ({len(matches)} {label}s):", file=sys.stderr)
404
+ for m in matches:
405
+ print(f" {str(_rid(m))[:8]} {_cval(m, 'name') or ''}", file=sys.stderr)
406
+ sys.exit(1)
407
+ return matches[0]
408
+
409
+
410
+ def _resolve_machine(prefix: str) -> Dict[str, Any]:
411
+ return _match_id(_client().list_machines(), prefix, "machine")
412
+
413
+
414
+ def _resolve_record(prefix: str) -> Dict[str, Any]:
415
+ return _match_id(_client().list_tool_records(), prefix, "tool record")
416
+
417
+
418
+ def _resolve_tool_set(prefix: str) -> Dict[str, Any]:
419
+ return _match_id(_client().list_tool_sets(), prefix, "tool set")
420
+
421
+
422
+ def _resolve_catalog(handle: str) -> Dict[str, Any]:
423
+ """Resolve a catalog record by id / unique id-prefix / name / product_code
424
+ (same shape as the other resolvers; on ambiguity it prints the candidates).
425
+ A catalog record carries a manufacturer product_code, so humans reach for it
426
+ as readily as the id or name — all three are first-class handles here."""
427
+ items = _client().list_catalog_records()
428
+ # An exact id, name, or product_code wins outright.
429
+ for keyfn in (lambda r: _rid(r),
430
+ lambda r: _cval(r, "name"),
431
+ lambda r: _cval(r, "product_code")):
432
+ exact = [r for r in items if keyfn(r) == handle]
433
+ if len(exact) == 1:
434
+ return exact[0]
435
+ if len(exact) > 1:
436
+ _ambiguous_catalog(handle, exact)
437
+ # Otherwise a unique prefix on the id, the name, or the product_code.
438
+ matches = [r for r in items
439
+ if str(_rid(r) or "").startswith(handle)
440
+ or str(_cval(r, "name") or "").startswith(handle)
441
+ or str(_cval(r, "product_code") or "").startswith(handle)]
442
+ if not matches:
443
+ print(f"Error: no catalog record matches '{handle}'", file=sys.stderr)
444
+ sys.exit(1)
445
+ if len(matches) > 1:
446
+ _ambiguous_catalog(handle, matches)
447
+ return matches[0]
448
+
449
+
450
+ def _ambiguous_catalog(handle: str, candidates: List[Dict[str, Any]]) -> None:
451
+ print(f"Error: '{handle}' is ambiguous ({len(candidates)} catalog records):",
452
+ file=sys.stderr)
453
+ for c in candidates:
454
+ print(f" {str(_rid(c))[:8]} {_cval(c, 'name') or ''} "
455
+ f"{_cval(c, 'product_code') or ''}".rstrip(), file=sys.stderr)
456
+ sys.exit(1)
457
+
458
+
459
+ def _resolve_entry(machine: Dict[str, Any], tool_number: int) -> Dict[str, Any]:
460
+ """Find a machine's tool-table entry record (a sectioned entry) by its
461
+ observed tool number. v2 mutations (bind/unbind/delete-entry) key off
462
+ the entry's own record id, not machine+tool_number, so callers resolve the
463
+ entry first."""
464
+ items = _client().list_entries(_rid(machine))
465
+ for entry in items:
466
+ if _cval(entry, "tool_number") == tool_number:
467
+ return entry
468
+ print(f"Error: machine '{_cval(machine, 'name')}' has no tool T{tool_number}",
469
+ file=sys.stderr)
470
+ sys.exit(1)
471
+
472
+
473
+ def _confirm(message: str, assume_yes: bool) -> bool:
474
+ """Guard a destructive action. --yes skips; non-interactive requires it."""
475
+ if assume_yes:
476
+ return True
477
+ if not sys.stdin.isatty():
478
+ print(f"{message}\nRefusing to proceed without confirmation; re-run with --yes.",
479
+ file=sys.stderr)
480
+ sys.exit(1)
481
+ return input(f"{message} [y/N]: ").strip().lower() in ("y", "yes")
482
+
483
+
484
+ def list_machines():
485
+ """List the user's machines (id, name, controller)."""
486
+ items = _client().list_machines()
487
+ if not items:
488
+ print("No machines found.")
489
+ return
490
+ print(f"\nMachines ({len(items)}):")
491
+ print("=" * 78)
492
+ for m in items:
493
+ print(f" ID: {_rid(m)}")
494
+ print(f" Name: {_cval(m, 'name')}")
495
+ if _cval(m, "controller_type"):
496
+ print(f" Controller: {_cval(m, 'controller_type')}")
497
+ print("=" * 78)
498
+
499
+
500
+ def list_tools():
501
+ """List the user's tool instance records (the public facade)."""
502
+ items = _client().list_tool_records()
503
+ if not items:
504
+ print("No tool records found.")
505
+ return
506
+ print(f"\nTool Records ({len(items)}):")
507
+ print("=" * 78)
508
+ for t in items:
509
+ print(f" ID: {_rid(t)}")
510
+ print(f" Name: {_cval(t, 'name')}")
511
+ bits = []
512
+ shape = _cval(t, "geometry", "shape")
513
+ if shape:
514
+ bits.append(str(shape))
515
+ dia = _cfield(t, "geometry", "diameter")
516
+ if dia.get("value") is not None:
517
+ bits.append(f"⌀{dia['value']}{dia.get('unit', '')}")
518
+ if bits:
519
+ print(f" Geometry: {' · '.join(bits)}")
520
+ print("=" * 78)
521
+
522
+
523
+ def show_tool_table(machine_id: str):
524
+ """List a machine's tool-table entries and their bind state."""
525
+ machine = _resolve_machine(machine_id)
526
+ name = _cval(machine, "name")
527
+ items = _client().list_entries(_rid(machine))
528
+ if not items:
529
+ print(f"{name}: empty tool table.")
530
+ return
531
+ plural = "entries" if len(items) != 1 else "entry"
532
+ print(f"\n{name} tool table ({len(items)} {plural}):")
533
+ print("=" * 78)
534
+ for e in items:
535
+ rec_id = _cval(e, "bound_instance_id")
536
+ state = f"bound -> {str(rec_id)[:8]}" if rec_id else "unbound"
537
+ dia = _cval(e, "offsets", "diameter")
538
+ line = f" T{_cval(e, 'tool_number')}: {_cval(e, 'description') or '—'}"
539
+ if dia is not None:
540
+ line += f" ⌀{dia}"
541
+ print(f"{line} [{state}]")
542
+ print("=" * 78)
543
+
544
+
545
+ def link_machine(set_id: str, machine_id: str, actor: str = "human@cli"):
546
+ """Link a tool set to a machine: member numbers are inherited from its entries."""
547
+ tool_set = _resolve_tool_set(set_id)
548
+ machine = _resolve_machine(machine_id)
549
+ _client().link_set_to_machine(_rid(tool_set), _rid(machine), actor)
550
+ set_name = _cval(tool_set, "name") or str(_rid(tool_set))[:8]
551
+ print(f"✓ Tool set '{set_name}' now linked to machine '{_cval(machine, 'name')}'.")
552
+
553
+
554
+ def delete_machine(machine_id: str, assume_yes: bool = False):
555
+ """Delete a machine and its tool-table entries (tool records untouched)."""
556
+ machine = _resolve_machine(machine_id)
557
+ name = _cval(machine, "name")
558
+ if not _confirm(
559
+ f"Delete machine '{name}' and its tool-table entries?", assume_yes
560
+ ):
561
+ print("Aborted.")
562
+ return
563
+ _client().delete_machine(_rid(machine))
564
+ print(f"✓ Deleted machine '{name}'. Tool records were not affected.")
565
+
566
+
567
+ def delete_tool(record_id: str, assume_yes: bool = False):
568
+ """Delete a tool record; entries bound to it are unbound, not orphaned."""
569
+ rec = _resolve_record(record_id)
570
+ name = _cval(rec, "name")
571
+ if not _confirm(
572
+ f"Delete tool record '{name}'? Bound machine entries will be unbound "
573
+ f"(their data stays on the machine).", assume_yes
574
+ ):
575
+ print("Aborted.")
576
+ return
577
+ _client().delete_tool_record(_rid(rec))
578
+ print(f"✓ Deleted tool record '{name}'. Any bound entries were unbound; "
579
+ f"their data stays on the machine.")
580
+
581
+
582
+ def delete_entry(machine_id: str, tool_number: int, assume_yes: bool = False):
583
+ """Remove a machine-reported tool-table entry by tool number."""
584
+ machine = _resolve_machine(machine_id)
585
+ name = _cval(machine, "name")
586
+ entry = _resolve_entry(machine, tool_number)
587
+ if not _confirm(
588
+ f"Remove T{tool_number} from '{name}'?", assume_yes
589
+ ):
590
+ print("Aborted.")
591
+ return
592
+ _client().delete_entry(_rid(entry))
593
+ print(f"✓ Removed T{tool_number} from '{name}'. "
594
+ f"If the controller pushes it again, it returns.")
595
+
596
+
597
+ def bind_entry(machine_id: str, tool_number: int, record_id: str):
598
+ """Link an unbound entry to an owned tool record (overwrites nothing)."""
599
+ machine = _resolve_machine(machine_id)
600
+ m_name = _cval(machine, "name")
601
+ entry = _resolve_entry(machine, tool_number)
602
+ rec = _resolve_record(record_id)
603
+ _client().bind_entry(_rid(entry), instance_id=_rid(rec))
604
+ print(f"✓ Linked T{tool_number} @ {m_name} -> '{_cval(rec, 'name')}'. "
605
+ f"Nothing was overwritten on either side.")
606
+
607
+
608
+ def unbind_entry(machine_id: str, tool_number: int):
609
+ """Unbind an entry; it keeps its data and becomes eligible for suggestions."""
610
+ machine = _resolve_machine(machine_id)
611
+ m_name = _cval(machine, "name")
612
+ entry = _resolve_entry(machine, tool_number)
613
+ _client().unbind_entry(_rid(entry))
614
+ print(f"✓ Unbound T{tool_number} @ {m_name}. The entry keeps its data.")
615
+
616
+
617
+ def create_record(args):
618
+ """Context-aware create-record: from a machine entry (-> BOUND instance) or
619
+ from a catalog record (-> UNBOUND instance). The two paths are mutually
620
+ exclusive; the outcome message names which one ran."""
621
+ qa_path = getattr(args, "qa", None)
622
+ cert = getattr(args, "cert", None)
623
+ if getattr(args, "from_catalog", None):
624
+ if args.machine or args.tool_number is not None:
625
+ print("Error: --from-catalog cannot be combined with MACHINE/TOOL_NUMBER",
626
+ file=sys.stderr)
627
+ sys.exit(1)
628
+ create_record_from_catalog(args.from_catalog, args.name,
629
+ qa_path=qa_path, cert=cert)
630
+ else:
631
+ if qa_path or cert:
632
+ print("Error: --qa/--cert are only valid with --from-catalog "
633
+ "(manufacturer QA is recorded when creating from a catalog record)",
634
+ file=sys.stderr)
635
+ sys.exit(1)
636
+ if not args.machine or args.tool_number is None:
637
+ print("Error: create-record needs MACHINE TOOL_NUMBER (entry form) "
638
+ "or --from-catalog CATALOG", file=sys.stderr)
639
+ sys.exit(1)
640
+ create_record_from_entry(args.machine, args.tool_number, args.name)
641
+
642
+
643
+ def create_record_from_entry(machine_id: str, tool_number: int, name: str = None):
644
+ """Mint a new instance record from a entry's observations and bind it, in one
645
+ step. The bind endpoint mints a new instance when no instance_id is given."""
646
+ machine = _resolve_machine(machine_id)
647
+ m_name = _cval(machine, "name")
648
+ entry = _resolve_entry(machine, tool_number)
649
+ result = _client().bind_entry(_rid(entry), name=name)
650
+ bound = (result.get("canonical", {}).get("bound_instance_id") or {}).get("value")
651
+ rec_id = str(bound or "")[:8]
652
+ print(f"✓ Created a record from T{tool_number} @ {m_name} and bound it "
653
+ f"(record {rec_id}).")
654
+
655
+
656
+ def create_record_from_catalog(catalog_handle: str, name: str = None,
657
+ qa_path: str = None, cert: str = None):
658
+ """Create a new UNBOUND instance from a catalog record (the catalog->instance
659
+ door). The server stamps the catalog_type_id link as asserted:<requester>;
660
+ the instance is left unbound (it sits in no machine entry). The name defaults
661
+ to the catalog record's name unless --name overrides it.
662
+
663
+ Optional manufacturer QA: --qa is a geometry-shaped JSON file ({diameter:
664
+ {value, unit}, ...}) and --cert its certificate/serial. --cert is required
665
+ iff --qa is given; the server stamps each measured field
666
+ observed:manufacturer@<serial>."""
667
+ if qa_path and not cert:
668
+ print("Error: --qa requires --cert (the certificate/serial the QA "
669
+ "measurements are recorded against)", file=sys.stderr)
670
+ sys.exit(1)
671
+ if cert and not qa_path:
672
+ print("Error: --cert requires --qa (a certificate needs QA measurements "
673
+ "to certify)", file=sys.stderr)
674
+ sys.exit(1)
675
+ qa = None
676
+ if qa_path:
677
+ with open(qa_path) as f:
678
+ text = f.read().strip()
679
+ try:
680
+ qa = json.loads(text) if text else {}
681
+ except json.JSONDecodeError as e:
682
+ print(f"Error: invalid JSON in --qa file: {e}", file=sys.stderr)
683
+ sys.exit(1)
684
+ if not isinstance(qa, dict):
685
+ print("Error: --qa JSON must be a geometry-shaped object", file=sys.stderr)
686
+ sys.exit(1)
687
+ catalog = _resolve_catalog(catalog_handle)
688
+ cat_name = _cval(catalog, "name")
689
+ rec = _client().create_instance_from_catalog(_rid(catalog), name=name,
690
+ qa=qa, cert=cert)
691
+ inst_id = str(_rid(rec) or "")[:8]
692
+ qa_note = f" with manufacturer QA ({cert})" if qa else ""
693
+ print(f"✓ Created instance {inst_id} from {cat_name}{qa_note} — unbound "
694
+ f"(no machine entry yet).")
695
+
696
+
697
+ def create_machine(name, controller=None):
698
+ """Create a machine and assert its name (and optional controller)."""
699
+ rec = _client().create_machine(name=name, controller_type=controller)
700
+ print(f"✓ Created machine '{name}' ({str(_rid(rec))[:8]}).")
701
+ if controller:
702
+ print(f" Controller: {controller}")
703
+
704
+
705
+ def create_set(name):
706
+ """Create a tool set and assert its name."""
707
+ rec = _client().create_tool_set(name=name)
708
+ print(f"✓ Created tool set '{name}' ({str(_rid(rec))[:8]}).")
709
+
710
+
711
+ def _load_catalog_fields(file=None) -> Dict[str, Any]:
712
+ """Read the nominal-fields JSON for a catalog record: from --file, else from
713
+ stdin (the '-' convention). An empty/absent stream is an empty object — the
714
+ convenience flags may supply every field by hand. The JSON carries values +
715
+ units; provenance is never in it (the server stamps it from --source)."""
716
+ if file:
717
+ with open(file) as f:
718
+ text = f.read()
719
+ elif not sys.stdin.isatty():
720
+ text = sys.stdin.read()
721
+ else:
722
+ text = ""
723
+ text = text.strip()
724
+ if not text:
725
+ return {}
726
+ try:
727
+ data = json.loads(text)
728
+ except json.JSONDecodeError as e:
729
+ print(f"Error: invalid JSON for catalog record: {e}", file=sys.stderr)
730
+ sys.exit(1)
731
+ if not isinstance(data, dict):
732
+ print("Error: catalog record JSON must be an object", file=sys.stderr)
733
+ sys.exit(1)
734
+ return data
735
+
736
+
737
+ def _flag_field(fields: Dict[str, Any], key: str, value: Any) -> None:
738
+ """A convenience flag supplies/overrides a top-level nominal field's value,
739
+ keeping any unit already present in the JSON."""
740
+ if value is None:
741
+ return
742
+ leaf = fields.get(key)
743
+ if isinstance(leaf, dict):
744
+ leaf["value"] = value
745
+ else:
746
+ fields[key] = {"value": value}
747
+
748
+
749
+ def create_catalog_record(source, file=None, name=None, manufacturer=None,
750
+ product_code=None, diameter=None, flutes=None):
751
+ """Create a catalog record (a ToolCatalogRecord) in one atomic, audited call.
752
+
753
+ Nominal fields arrive as JSON on stdin (the '-' convention) or via --file,
754
+ with thin convenience flags (--name/--manufacturer/--product-code/--diameter/
755
+ --flutes) for the by-hand case. --source is the declared actor; the server
756
+ stamps `asserted:<source>` on every field — the client never writes
757
+ provenance. name, manufacturer and product_code are required (the identity
758
+ floor)."""
759
+ fields = _load_catalog_fields(file)
760
+ _flag_field(fields, "name", name)
761
+ _flag_field(fields, "manufacturer", manufacturer)
762
+ _flag_field(fields, "product_code", product_code)
763
+ if diameter is not None:
764
+ fields.setdefault("geometry", {})["diameter"] = {"value": diameter, "unit": "mm"}
765
+ if flutes is not None:
766
+ fields.setdefault("geometry", {})["flutes"] = {"value": flutes}
767
+ rec = _client().create_catalog_record(source=source, fields=fields)
768
+ print(f"✓ Created catalog record '{_cval(rec, 'name')}' "
769
+ f"({str(_rid(rec))[:8]}). Every field carries source "
770
+ f"'asserted:{source}'.")
771
+
772
+
773
+ def list_catalog_records():
774
+ """List the user's catalog records (ToolCatalogRecords)."""
775
+ items = _client().list_catalog_records()
776
+ if not items:
777
+ print("No catalog records found.")
778
+ return
779
+ print(f"\nCatalog Records ({len(items)}):")
780
+ print("=" * 78)
781
+ for c in items:
782
+ print(f" ID: {_rid(c)}")
783
+ print(f" Name: {_cval(c, 'name')}")
784
+ ident = " ".join(p for p in (_cval(c, "manufacturer"),
785
+ _cval(c, "product_code")) if p)
786
+ if ident:
787
+ print(f" {ident}")
788
+ dia = _cfield(c, "geometry", "diameter")
789
+ if dia.get("value") is not None:
790
+ print(f" Geometry: ⌀{dia['value']}{dia.get('unit', '')}")
791
+ print("=" * 78)
792
+
793
+
794
+ def _print_draft(draft):
795
+ """Show the canonical fields a parsed draft would create (dry-run view)."""
796
+ cls = f" [{draft.source_class}]" if draft.source_class else ""
797
+ print(f" • {draft.name or '(unnamed)'}{cls}")
798
+ ident = " ".join(p for p in (draft.manufacturer, draft.product_code) if p)
799
+ if ident:
800
+ print(f" {ident}")
801
+ geom = draft.fields.get("geometry") or {}
802
+ for key, leaf in geom.items():
803
+ if isinstance(leaf, dict):
804
+ unit = leaf.get("unit", "")
805
+ print(f" {key}: {leaf.get('value')}{unit}")
806
+ if draft.media:
807
+ roles = ", ".join(sorted({m.role for m in draft.media}))
808
+ print(f" media: {len(draft.media)} file(s) [{roles}]")
809
+ missing = draft.missing_identity()
810
+ if missing:
811
+ print(f" ! missing identity: {', '.join(missing)} — would be skipped")
812
+
813
+
814
+ def import_tools(path, source=None, dry_run=False, no_preserve=False):
815
+ """Import tool data from a known format into catalog records.
816
+
817
+ Detects the format: DIN 4000 (CSV/XML), STEP P21, or a GTC package (.zip,
818
+ ISO 13399 — also carries 3D models/images as canonical media). Parsing is
819
+ offline; --dry-run shows exactly what would be created without sending. Each
820
+ record's full source payload is preserved in its client section unless
821
+ --no-preserve is given."""
822
+ from loobric import importers
823
+ from loobric.importers.run import import_drafts
824
+
825
+ try:
826
+ drafts = importers.parse(path)
827
+ except FileNotFoundError:
828
+ print(f"Error: file not found: {path}", file=sys.stderr)
829
+ sys.exit(1)
830
+ if not drafts:
831
+ print("No tool records found in the file.")
832
+ return
833
+
834
+ if dry_run:
835
+ print(f"Parsed {len(drafts)} record(s) from {path} "
836
+ f"(dry run — nothing sent):")
837
+ for draft in drafts:
838
+ _print_draft(draft)
839
+ return
840
+
841
+ def on_event(kind, draft, info):
842
+ name = draft.name or "(unnamed)"
843
+ if kind == "create":
844
+ print(f" ✓ created {name} [{str(info)[:8]}]")
845
+ elif kind == "skip":
846
+ print(f" – skipped {name}: {info}")
847
+ elif kind == "fail":
848
+ print(f" ✗ failed {name}: {info}")
849
+ elif kind == "preserve_fail":
850
+ print(f" ! {name}: created, but preserving raw payload failed: {info}")
851
+ elif kind == "media":
852
+ print(f" ↑ media: {info.role} ({info.filename})")
853
+ elif kind == "media_fail":
854
+ print(f" ! media upload failed ({info.filename}): {info}")
855
+
856
+ report = import_drafts(_client(), drafts, source=source,
857
+ preserve=not no_preserve, on_event=on_event)
858
+ print(f"\nImport complete: {len(report.created)} created, "
859
+ f"{len(report.skipped)} skipped, {len(report.failed)} failed"
860
+ f"{f', {report.media_uploaded} media uploaded' if report.media_uploaded else ''}.")
861
+
862
+
863
+ def _print_field(label, leaf, indent=" "):
864
+ """Show one canonical field with its provenance — value, optional unit, and
865
+ the source it came from (the whole point: fabrication stays visible)."""
866
+ value = leaf.get("value")
867
+ unit = leaf.get("unit")
868
+ disp = "—" if value is None else (f"{value} {unit}" if unit else f"{value}")
869
+ print(f"{indent}{label}: {disp} [{leaf.get('source', '')}]")
870
+
871
+
872
+ def show_catalog_record(catalog):
873
+ """Show one catalog record with full provenance — every field with its source."""
874
+ rec = _resolve_catalog(catalog)
875
+ canonical = rec.get("canonical") or {}
876
+ print(f"\nCatalog Record {_rid(rec)}")
877
+ print("=" * 78)
878
+ for key in ("name", "manufacturer", "product_code", "item_type"):
879
+ leaf = canonical.get(key)
880
+ if isinstance(leaf, dict) and "source" in leaf:
881
+ _print_field(key, leaf)
882
+ geometry = canonical.get("geometry") or {}
883
+ present = {k: v for k, v in geometry.items() if isinstance(v, dict)}
884
+ if present:
885
+ print(" geometry:")
886
+ for gkey, leaf in present.items():
887
+ _print_field(gkey, leaf, indent=" ")
888
+ print("=" * 78)
889
+
890
+
891
+ def show_tool(record_handle):
892
+ """Show one tool instance record with full provenance — every canonical field
893
+ with the source that vouches for it (mirrors show-catalog-record). RECORD
894
+ resolves by id, name, or unique prefix."""
895
+ rec = _resolve_record(record_handle)
896
+ canonical = rec.get("canonical") or {}
897
+ print(f"\nTool Record {_rid(rec)}")
898
+ print("=" * 78)
899
+ for key in ("name", "status", "item_type"):
900
+ leaf = canonical.get(key)
901
+ if isinstance(leaf, dict) and "source" in leaf:
902
+ _print_field(key, leaf)
903
+ # The catalog link is only interesting when the instance actually points at a
904
+ # catalog record (an unbound-to-catalog instance carries a null leaf).
905
+ cat = canonical.get("catalog_type_id")
906
+ if isinstance(cat, dict) and cat.get("value") is not None:
907
+ _print_field("catalog_type_id", cat)
908
+ geometry = canonical.get("geometry") or {}
909
+ present = {k: v for k, v in geometry.items() if isinstance(v, dict)}
910
+ if present:
911
+ print(" geometry:")
912
+ for gkey, leaf in present.items():
913
+ _print_field(gkey, leaf, indent=" ")
914
+ # An assembly carries canonical.components; summarize the parts it is built from.
915
+ components = canonical.get("components")
916
+ if isinstance(components, list) and components:
917
+ print(f" components: {len(components)} part(s)")
918
+ for comp in components:
919
+ cid = comp.get("tool_record_id") or comp.get("id")
920
+ cname = comp.get("name") or (str(cid)[:8] if cid else "?")
921
+ role = comp.get("role")
922
+ print(f" {cname}{f' [{role}]' if role else ''}")
923
+ print("=" * 78)
924
+
925
+
926
+ def show_machine(machine_handle):
927
+ """Show one machine with provenance, a summary of its tool table (each
928
+ entry's number, description, and bind state), and any tool sets linked to it.
929
+ MACHINE resolves by id, name, or unique prefix."""
930
+ machine = _resolve_machine(machine_handle)
931
+ canonical = machine.get("canonical") or {}
932
+ print(f"\nMachine {_rid(machine)}")
933
+ print("=" * 78)
934
+ for key in ("name", "controller_type"):
935
+ leaf = canonical.get(key)
936
+ if isinstance(leaf, dict) and "source" in leaf:
937
+ _print_field(key, leaf)
938
+ entries = _client().list_entries(_rid(machine))
939
+ plural = "entries" if len(entries) != 1 else "entry"
940
+ print(f" Tool table: {len(entries)} {plural}")
941
+ for e in entries:
942
+ rec_id = _cval(e, "bound_instance_id")
943
+ state = f"bound -> {str(rec_id)[:8]}" if rec_id else "unbound"
944
+ print(f" T{_cval(e, 'tool_number')}: {_cval(e, 'description') or '—'} [{state}]")
945
+ linked = [s for s in _client().list_tool_sets()
946
+ if _cval(s, "machine_id") == _rid(machine)]
947
+ if linked:
948
+ plural = "sets" if len(linked) != 1 else "set"
949
+ print(f" Tool {plural} linked: {len(linked)}")
950
+ for s in linked:
951
+ print(f" {_cval(s, 'name') or str(_rid(s))[:8]}")
952
+ print("=" * 78)
953
+
954
+
955
+ def _resolve_key(handle: str) -> Dict[str, Any]:
956
+ """Resolve an API key by exact id, unique id-prefix, or name. API keys are
957
+ PLAIN dicts (not sectioned records), so this matches on the flat id/name
958
+ fields rather than internal.id/canonical.name. Exits with a helpful message
959
+ on no/ambiguous match (mirrors _match_id)."""
960
+ keys = _client().list_keys()
961
+ exact = [k for k in keys if k.get("id") == handle]
962
+ if exact:
963
+ return exact[0]
964
+ by_name = [k for k in keys if k.get("name") == handle]
965
+ if len(by_name) == 1:
966
+ return by_name[0]
967
+ matches = [k for k in keys if str(k.get("id") or "").startswith(handle)]
968
+ if not matches:
969
+ matches = [k for k in keys if str(k.get("name") or "").startswith(handle)]
970
+ if not matches:
971
+ print(f"Error: no API key matches '{handle}'", file=sys.stderr)
972
+ sys.exit(1)
973
+ if len(matches) > 1:
974
+ print(f"Error: '{handle}' is ambiguous ({len(matches)} API keys):", file=sys.stderr)
975
+ for k in matches:
976
+ print(f" {str(k.get('id'))[:8]} {k.get('name') or ''}", file=sys.stderr)
977
+ sys.exit(1)
978
+ return matches[0]
979
+
980
+
981
+ def show_key(handle):
982
+ """Show one API key (id, name, status, scopes, tags, dates) — the same block
983
+ list-keys prints. KEY resolves by exact id, unique id-prefix, or name."""
984
+ key = _resolve_key(handle)
985
+ print(f"\nAPI Key {key.get('id')}")
986
+ print("=" * 78)
987
+ print(f" ID: {key.get('id')}")
988
+ print(f" Name: {key.get('name')}")
989
+ print(f" Status: {'active' if key.get('is_active', True) else 'REVOKED'}")
990
+ print(f" Scopes: {', '.join(key.get('scopes', []))}")
991
+ if key.get('tags'):
992
+ print(f" Tags: {', '.join(key.get('tags', []))}")
993
+ if key.get('created_at'):
994
+ print(f" Created: {key.get('created_at')}")
995
+ if key.get('expires_at'):
996
+ print(f" Expires: {key.get('expires_at')}")
997
+ if key.get('last_used_at'):
998
+ print(f" Last Used: {key.get('last_used_at')}")
999
+ print("=" * 78)
1000
+
1001
+
1002
+ def _parse_entry(spec):
1003
+ """Parse a --entry spec 'N[:description[:diameter]]' into a tool-table entry."""
1004
+ parts = spec.split(":")
1005
+ try:
1006
+ tool_number = int(parts[0])
1007
+ except ValueError:
1008
+ print(f"Error: bad --entry '{spec}': first field must be a tool number",
1009
+ file=sys.stderr)
1010
+ sys.exit(1)
1011
+ entry = {"tool_number": tool_number}
1012
+ if len(parts) > 1 and parts[1]:
1013
+ entry["description"] = parts[1]
1014
+ if len(parts) > 2 and parts[2]:
1015
+ try:
1016
+ entry["offsets"] = {"diameter": float(parts[2]), "diameter_unit": "mm"}
1017
+ except ValueError:
1018
+ print(f"Error: bad --entry '{spec}': diameter must be a number", file=sys.stderr)
1019
+ sys.exit(1)
1020
+ return entry
1021
+
1022
+
1023
+ def push_table(machine_id, entry_specs, client="loobric", snapshot=False):
1024
+ """Push a tool table to a machine — the controller-side sync (stand-in for a
1025
+ real controller client). Each --entry is 'N[:description[:diameter]]'."""
1026
+ machine = _resolve_machine(machine_id)
1027
+ name = _cval(machine, "name") or str(_rid(machine))[:8]
1028
+ entries = [_parse_entry(s) for s in (entry_specs or [])]
1029
+ res = _client().sync_entries(
1030
+ _rid(machine), entries, client=client, machine_name=name,
1031
+ mode="snapshot" if snapshot else "merge",
1032
+ )
1033
+ n = len(res.get("items", []))
1034
+ print(f"✓ Pushed {n} tool-table entr{'y' if n == 1 else 'ies'} to '{name}' "
1035
+ f"as client '{client}'.")
1036
+ removed = res.get("removed_tool_numbers", [])
1037
+ if removed:
1038
+ print(f" Removed (snapshot): {', '.join('T' + str(t) for t in removed)}")
1039
+
1040
+
1041
+ def reset_account(assume_yes=False):
1042
+ """Wipe ALL tool data for this account (keeps login + API keys)."""
1043
+ if not _confirm(
1044
+ "Delete ALL tool data for this account (records, sets, machines, "
1045
+ "entries)? Login and API keys are kept.", assume_yes
1046
+ ):
1047
+ print("Aborted.")
1048
+ return
1049
+ res = _client().reset_account()
1050
+ deleted = res.get("deleted", {}) or {}
1051
+ total = sum(deleted.values()) if isinstance(deleted, dict) else 0
1052
+ detail = ", ".join(f"{k}={v}" for k, v in deleted.items()) if deleted else ""
1053
+ print(f"✓ Account reset — deleted {total} item(s).{(' ' + detail) if detail else ''}")
1054
+
1055
+
1056
+ # The server's exact factory-reset confirmation phrase. Mirrored here so the CLI
1057
+ # can refuse early (and prompt for it) before sending anything.
1058
+ WIPE_PHRASE = "WIPE ALL DATA AND ACCOUNTS"
1059
+
1060
+
1061
+ def wipe_all(confirm=None):
1062
+ """Factory reset (admin): wipe ALL data, accounts, and API keys — including
1063
+ the calling admin. Guarded by an exact typed/`--confirm`ed phrase; there is
1064
+ no undo."""
1065
+ print("⚠ FACTORY RESET — deletes ALL data, ALL accounts, and ALL API keys on")
1066
+ print(" the server, INCLUDING your own admin account and key. There is no undo.")
1067
+ phrase = confirm
1068
+ if phrase is None:
1069
+ if not sys.stdin.isatty():
1070
+ print(f"Refusing: pass --confirm '{WIPE_PHRASE}' to wipe everything "
1071
+ f"non-interactively.", file=sys.stderr)
1072
+ sys.exit(1)
1073
+ phrase = input(f"Type '{WIPE_PHRASE}' to confirm: ").strip()
1074
+ if phrase != WIPE_PHRASE:
1075
+ print("Aborted — confirmation phrase did not match.", file=sys.stderr)
1076
+ sys.exit(1)
1077
+ res = _client().wipe_all(phrase)
1078
+ deleted = res.get("deleted", {}) or {}
1079
+ total = sum(deleted.values()) if isinstance(deleted, dict) else 0
1080
+ print(f"✓ Wiped everything — deleted {total} row(s) across {len(deleted)} table(s).")
1081
+ print(" The server is now empty; the next account to register becomes admin.")
1082
+ # Our own session and key were just deleted — drop the local session so a
1083
+ # stale cookie isn't reused.
1084
+ transport.SESSION_COOKIE = None
1085
+ transport.clear_session()
1086
+
1087
+
1088
+ def change_password(current=None, new=None):
1089
+ """Change the authenticated user's password (prompts when not given)."""
1090
+ if not current:
1091
+ current = getpass.getpass("Current password: ")
1092
+ if not new:
1093
+ new = getpass.getpass("New password: ")
1094
+ if new != getpass.getpass("Confirm new password: "):
1095
+ print("Error: new passwords do not match", file=sys.stderr)
1096
+ sys.exit(1)
1097
+ if not new:
1098
+ print("Error: new password is required", file=sys.stderr)
1099
+ sys.exit(1)
1100
+ _client().change_password(current, new)
1101
+ print("✓ Password changed.")
1102
+
1103
+
1104
+ def _client_version() -> str:
1105
+ """This client package's installed version, or 'unknown' (e.g. when run from
1106
+ a source checkout that was never installed)."""
1107
+ try:
1108
+ from importlib.metadata import version, PackageNotFoundError
1109
+ try:
1110
+ return version("loobric-cli")
1111
+ except PackageNotFoundError:
1112
+ return "unknown"
1113
+ except Exception:
1114
+ return "unknown"
1115
+
1116
+
1117
+ def show_version():
1118
+ """Show the client and server versions — no login required.
1119
+
1120
+ Unlike `whoami`, this needs no authentication: the server's build comes from
1121
+ the unauthenticated /version endpoint. It's the quickest 'are my client and
1122
+ server compatible / is my deploy current?' check."""
1123
+ print(f" Client: loobric-cli {_client_version()}")
1124
+ if not transport.BASE_URL:
1125
+ print(" Server: (no base URL set — pass --base-url or set LOOBRIC_BASE_URL)")
1126
+ return
1127
+ try:
1128
+ v = _client().server_version()
1129
+ print(f" Server: {v.get('version', '?')} ({v.get('commit', '?')}) "
1130
+ f"[{transport.BASE_URL}]")
1131
+ except NotFound:
1132
+ print(f" Server: unknown — older server with no /version endpoint "
1133
+ f"[{transport.BASE_URL}]")
1134
+ except LoobricClientError as e:
1135
+ print(f" Server: unreachable [{transport.BASE_URL}] ({e})")
1136
+
1137
+
1138
+ def whoami():
1139
+ """Show which server we're talking to, the authenticated account, and the
1140
+ server's build identity (the fastest 'is this the server/code I expect?'
1141
+ check)."""
1142
+ client = _client()
1143
+ print(f" Server: {client.base_url or '(none set)'}")
1144
+ me = client.whoami()
1145
+ print(f" Email: {me.get('email')}")
1146
+ print(f" Role: {me.get('role')}")
1147
+ print(f" Admin: {me.get('is_admin')}")
1148
+ if me.get("id"):
1149
+ print(f" ID: {me.get('id')}")
1150
+ # Server build identity — an older server has no /version endpoint, which is
1151
+ # itself the answer to "is it running my code?"
1152
+ try:
1153
+ v = client.server_version()
1154
+ print(f" Build: {v.get('version', '?')} ({v.get('commit', '?')})")
1155
+ except NotFound:
1156
+ print(" Build: unknown — older server with no /version endpoint")
1157
+
1158
+
1159
+ def list_users():
1160
+ """Show the admin account roster: how many accounts exist and who they are.
1161
+
1162
+ Admin-only on the server. An older server with no /admin/users endpoint
1163
+ surfaces as a clear message rather than a stack trace."""
1164
+ try:
1165
+ data = _client().list_users()
1166
+ except NotFound:
1167
+ print("This server has no /admin/users endpoint (older build).")
1168
+ return
1169
+ users = data.get("users", []) if isinstance(data, dict) else (data or [])
1170
+ total = data.get("total", len(users)) if isinstance(data, dict) else len(users)
1171
+ print(f" {total} account(s).")
1172
+ for u in users:
1173
+ flags = []
1174
+ if u.get("is_admin"):
1175
+ flags.append("admin")
1176
+ if not u.get("is_active", True):
1177
+ flags.append("inactive")
1178
+ if u.get("is_verified"):
1179
+ flags.append("verified")
1180
+ suffix = f" [{', '.join(flags)}]" if flags else ""
1181
+ when = (u.get("created_at") or "")[:10]
1182
+ keys = u.get("api_key_count", 0)
1183
+ print(f" {u.get('email', ''):32} {u.get('role', ''):12} "
1184
+ f"keys={keys:<3} {when}{suffix}")
1185
+
1186
+
1187
+ def list_audit(limit=50):
1188
+ """Show recent audit-log entries (who changed what, when)."""
1189
+ data = _client().list_audit_logs()
1190
+ items = data.get("logs", []) if isinstance(data, dict) else (data or [])
1191
+ if not items:
1192
+ print("No audit entries.")
1193
+ return
1194
+ for e in list(items)[:limit]:
1195
+ when = e.get("created_at") or e.get("timestamp") or ""
1196
+ print(f" {when} {e.get('operation', ''):8} "
1197
+ f"{e.get('entity_type', '')} {str(e.get('entity_id', ''))[:8]}")
1198
+
1199
+
1200
+ def backup_export(path=None):
1201
+ """Export a full account backup (admin)."""
1202
+ data = _client().export_backup()
1203
+ text = data if isinstance(data, str) else json.dumps(data, indent=2)
1204
+ if path:
1205
+ with open(path, "w") as f:
1206
+ f.write(text)
1207
+ print(f"✓ Backup written to {path}.")
1208
+ else:
1209
+ print(text)
1210
+
1211
+
1212
+ def backup_import(path):
1213
+ """Restore an account backup from a JSON file (admin)."""
1214
+ with open(path) as f:
1215
+ backup_json = f.read()
1216
+ _client().import_backup(backup_json)
1217
+ print(f"✓ Backup imported from {path}.")
1218
+
1219
+
1220
+ def assert_canonical(resource, record_id, path, value, actor="human@cli"):
1221
+ """Assert a canonical field (the assert door): loobric assert <resource> <id> <path> <value>."""
1222
+ try:
1223
+ parsed = json.loads(value)
1224
+ except (json.JSONDecodeError, TypeError):
1225
+ parsed = value
1226
+ rec = _client().assert_field(resource, record_id, path, parsed, actor=actor)
1227
+ print(f"✓ Asserted {path}={parsed!r} on {resource}/{str(_rid(rec))[:8]}.")
1228
+
1229
+
1230
+ def ping():
1231
+ """Check server health/connectivity."""
1232
+ conn = transport.get_connection()
1233
+ path = "/api/health"
1234
+ headers = {
1235
+ "Accept": "application/json",
1236
+ }
1237
+
1238
+ try:
1239
+ conn.request("GET", path, headers=headers)
1240
+ response = conn.getresponse()
1241
+ status = response.status
1242
+ content = response.read().decode("utf-8")
1243
+
1244
+ if 200 <= status < 300:
1245
+ data = json.loads(content) if content.strip() else {}
1246
+ print("✓ Server is healthy")
1247
+ print(f" Status: {data.get('status', 'ok')}")
1248
+ if data.get('version'):
1249
+ print(f" Version: {data.get('version')}")
1250
+ print(f" URL: {transport.BASE_URL}")
1251
+ else:
1252
+ print(f"✗ Server returned HTTP {status}")
1253
+ print(f" URL: {transport.BASE_URL}")
1254
+ sys.exit(1)
1255
+ except Exception as e:
1256
+ print(f"✗ Server unreachable at {transport.BASE_URL}")
1257
+ print(f" Error: {e}")
1258
+ sys.exit(1)
1259
+ finally:
1260
+ conn.close()
1261
+
1262
+
1263
+ def logout():
1264
+ """End session."""
1265
+ if not transport.SESSION_COOKIE:
1266
+ print("No active session to logout.")
1267
+ return
1268
+
1269
+ _client().logout()
1270
+ transport.SESSION_COOKIE = None
1271
+ transport.clear_session()
1272
+ print("✓ Logged out successfully.")
1273
+ print(f" Session cleared from {transport.SESSION_FILE}")
1274
+
1275
+
1276
+ def _run(fn, *args, **kwargs):
1277
+ """Run a CLI command, turning library errors into a message + exit code.
1278
+ The library raises; the CLI shell is where that becomes user-facing output."""
1279
+ try:
1280
+ return fn(*args, **kwargs)
1281
+ except LoobricClientError as e:
1282
+ print(f"Error: {e}", file=sys.stderr)
1283
+ sys.exit(1)
1284
+
1285
+
1286
+ def main():
1287
+ """Main CLI entry point."""
1288
+ parser = argparse.ArgumentParser(
1289
+ description="Loobric API Key Management CLI",
1290
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1291
+ epilog="""Examples:
1292
+ # First time setup - register a user on fresh database
1293
+ loobric --base-url http://127.0.0.1:8000 register admin@example.com
1294
+
1295
+ # Interactive login (prompts for URL, email, password)
1296
+ loobric --login
1297
+
1298
+ # Login with specific URL and email
1299
+ loobric --base-url http://127.0.0.1:8000 login user@example.com
1300
+
1301
+ # After login, base URL is saved - just run commands
1302
+ loobric list-keys
1303
+ loobric create-key "My Key" --scopes "read write:items"
1304
+
1305
+ # Create a key with tags and expiration
1306
+ loobric create-key "Backup Script" \\
1307
+ --scopes "read" --tags "backup production" --expires-at "2025-12-31T23:59:59Z"
1308
+
1309
+ # Revoke an API key
1310
+ loobric revoke-key <key_id>
1311
+
1312
+ # The sync loop, end to end (machine ids/names accept unique prefixes)
1313
+ loobric create-machine millstone --controller linuxcnc
1314
+ loobric push millstone --entry "3:1/4 downcut:6.35" --entry "7:vee:6.0"
1315
+ loobric tool-table millstone
1316
+ loobric create-record millstone 3 --name "1/4 downcut" # mint + bind T3
1317
+ loobric bind millstone 7 <record> # or bind an existing record
1318
+ loobric unbind millstone 7
1319
+
1320
+ # Catalog records -> a physical instance (unbound: not in any machine yet)
1321
+ loobric create-record --from-catalog B201 # by product code
1322
+ loobric create-record --from-catalog B201 --name "1/4 downcut, lot 7"
1323
+ loobric create-record --from-catalog B201 \\
1324
+ --qa qa.json --cert "kennametal@SN12345" # + manufacturer QA
1325
+
1326
+ # Inspect (list, then drill into one by id / unique prefix / name)
1327
+ loobric list-machines
1328
+ loobric show-machine millstone # + its tool table and linked sets
1329
+ loobric list-tools
1330
+ loobric show-tool "1/4 downcut" # one instance with full provenance
1331
+ loobric list-tool-sets
1332
+ loobric show-tool-set "Aluminum job"
1333
+ loobric list-keys
1334
+ loobric show-key <key_id> # one API key (id/prefix/name)
1335
+ loobric pending # binding proposals awaiting review
1336
+ loobric audit --limit 20
1337
+
1338
+ # Tool sets
1339
+ loobric create-set "Aluminum job"
1340
+ loobric link-machine "Aluminum job" millstone
1341
+
1342
+ # Canonical assert door
1343
+ loobric assert tool-set-records <id> name "Aluminum job v2"
1344
+
1345
+ # Admin / housekeeping
1346
+ loobric reset --yes # wipe all tool data (keeps login + keys)
1347
+ loobric backup-export --out backup.json
1348
+ loobric backup-import backup.json
1349
+ loobric delete-machine millstone --yes
1350
+ loobric ping
1351
+ loobric logout
1352
+
1353
+ Environment Variables:
1354
+ LOOBRIC_BASE_URL - Default base URL (can override with --base-url)
1355
+ LOOBRIC_API_KEY - API key for authentication (alternative to login)
1356
+ """
1357
+ )
1358
+ parser.add_argument(
1359
+ "--login",
1360
+ action="store_true",
1361
+ help="Interactive login (prompts for URL, email, password)"
1362
+ )
1363
+ parser.add_argument(
1364
+ "--logout",
1365
+ action="store_true",
1366
+ help="End current session (clears session cookie)"
1367
+ )
1368
+ parser.add_argument(
1369
+ "--api-key",
1370
+ help="API key for authentication (overrides session cookie and $LOOBRIC_API_KEY)"
1371
+ )
1372
+ parser.add_argument(
1373
+ "--base-url", "-b",
1374
+ default=os.environ.get("LOOBRIC_BASE_URL"),
1375
+ help="Base API URL (default: $LOOBRIC_BASE_URL or saved session)"
1376
+ )
1377
+ parser.add_argument(
1378
+ "--verbose", "-v",
1379
+ action="store_true",
1380
+ help="Enable verbose output"
1381
+ )
1382
+
1383
+ subparsers = parser.add_subparsers(dest="command", required=False)
1384
+
1385
+ # === register ===
1386
+ register_parser = subparsers.add_parser(
1387
+ "register",
1388
+ help="Register a new user account",
1389
+ description="Create a new user account. Use this for the first user on a fresh database."
1390
+ )
1391
+ register_parser.add_argument("email", nargs="?", help="User email address (will prompt if not provided)")
1392
+ register_parser.add_argument(
1393
+ "--password", "-p",
1394
+ help="Password (will be prompted if not provided)"
1395
+ )
1396
+ register_parser.set_defaults(func=lambda args: register(
1397
+ email=args.email,
1398
+ password=args.password
1399
+ ))
1400
+
1401
+ # === login ===
1402
+ login_parser = subparsers.add_parser(
1403
+ "login",
1404
+ help="Authenticate with email/password",
1405
+ description="Authenticate with email and password to create a session."
1406
+ )
1407
+ login_parser.add_argument("email", nargs="?", help="User email address (will prompt if not provided)")
1408
+ login_parser.add_argument(
1409
+ "--password", "-p",
1410
+ help="Password (will be prompted if not provided)"
1411
+ )
1412
+ login_parser.add_argument(
1413
+ "--url",
1414
+ help="Base URL (will be prompted if not provided)"
1415
+ )
1416
+ login_parser.set_defaults(func=lambda args: login(
1417
+ email=args.email,
1418
+ password=args.password,
1419
+ base_url=args.url or args.base_url
1420
+ ))
1421
+
1422
+ # === create-key ===
1423
+ create_parser = subparsers.add_parser("create-key", help="Generate a new API key")
1424
+ create_parser.add_argument("name", help="Name for the API key (e.g., 'My App')")
1425
+ create_parser.add_argument("--scopes", help="Space-separated scopes, e.g., 'read write'")
1426
+ create_parser.add_argument("--tags", help="Space-separated tags, e.g., 'production mill-3'")
1427
+ create_parser.add_argument("--expires-at", help="ISO datetime, e.g., 2025-12-31T23:59:59Z")
1428
+ create_parser.set_defaults(func=lambda args: create_key(
1429
+ args.name, args.scopes, args.tags, args.expires_at
1430
+ ))
1431
+
1432
+ # === list-keys ===
1433
+ list_parser = subparsers.add_parser("list-keys", help="List all API keys")
1434
+ list_parser.set_defaults(func=lambda _: list_keys())
1435
+
1436
+ # === show-key ===
1437
+ show_key_parser = subparsers.add_parser(
1438
+ "show-key",
1439
+ help="Show one API key (id, name, status, scopes, tags, dates)",
1440
+ description="Show a single API key's details. KEY resolves by exact id, "
1441
+ "unique id-prefix, or name.",
1442
+ )
1443
+ show_key_parser.add_argument("key", help="API key id, unique prefix, or name")
1444
+ show_key_parser.set_defaults(func=lambda args: show_key(args.key))
1445
+
1446
+ # === revoke-key ===
1447
+ revoke_parser = subparsers.add_parser("revoke-key", help="Revoke an API key")
1448
+ revoke_parser.add_argument("key_id", help="ID of the key to revoke")
1449
+ revoke_parser.set_defaults(func=lambda args: revoke_key(args.key_id))
1450
+
1451
+ # === list-tool-sets ===
1452
+ list_tool_sets_parser = subparsers.add_parser(
1453
+ "list-tool-sets",
1454
+ help="List tool sets",
1455
+ description="List the user's tool sets (named collections of ToolRecords)."
1456
+ )
1457
+ list_tool_sets_parser.set_defaults(func=lambda args: list_tool_sets())
1458
+
1459
+ # === show-tool-set ===
1460
+ show_tool_set_parser = subparsers.add_parser(
1461
+ "show-tool-set",
1462
+ help="Show one tool set and its members",
1463
+ description="Show a tool set's members — each member's number (with the "
1464
+ "source that vouches for it), the tool, and its geometry. SET "
1465
+ "resolves by id, name, or unique prefix.",
1466
+ )
1467
+ show_tool_set_parser.add_argument("set", help="Tool set id, name, or unique prefix")
1468
+ show_tool_set_parser.set_defaults(func=lambda args: show_tool_set(args.set))
1469
+
1470
+ # === link-machine ===
1471
+ link_parser = subparsers.add_parser(
1472
+ "link-machine",
1473
+ help="Link a tool set to a machine",
1474
+ description="Assert a tool set's machine_id so its member numbers are "
1475
+ "inherited from that machine's tool table."
1476
+ )
1477
+ link_parser.add_argument("set", help="Tool set id or unique prefix")
1478
+ link_parser.add_argument("machine", help="Machine id or unique prefix")
1479
+ link_parser.set_defaults(func=lambda args: link_machine(args.set, args.machine))
1480
+
1481
+ # === pending / resolve (v2 inbox) ===
1482
+ pending_parser = subparsers.add_parser(
1483
+ "pending", help="List inbox items awaiting review (binding proposals)"
1484
+ )
1485
+ pending_parser.set_defaults(func=lambda _: list_pending())
1486
+
1487
+ resolve_parser = subparsers.add_parser(
1488
+ "resolve", help="Confirm or reject an inbox item"
1489
+ )
1490
+ resolve_parser.add_argument("item_id", help="Inbox item id or unique prefix (see 'pending')")
1491
+ resolve_parser.add_argument("action", choices=["confirm", "reject"])
1492
+ resolve_parser.set_defaults(func=lambda args: resolve_pending(
1493
+ args.item_id, args.action
1494
+ ))
1495
+
1496
+ # === machines / tool records / entries (v2 management) ===
1497
+ list_machines_parser = subparsers.add_parser(
1498
+ "list-machines", help="List machines (id, name, controller)"
1499
+ )
1500
+ list_machines_parser.set_defaults(func=lambda _: list_machines())
1501
+
1502
+ show_machine_parser = subparsers.add_parser(
1503
+ "show-machine",
1504
+ help="Show one machine with its tool table and linked tool sets",
1505
+ description="Show a machine with provenance, a summary of its tool-table "
1506
+ "entries (number, description, bind state), and any tool sets "
1507
+ "linked to it. MACHINE resolves by id, name, or unique prefix.",
1508
+ )
1509
+ show_machine_parser.add_argument("machine", help="Machine id, name, or unique prefix")
1510
+ show_machine_parser.set_defaults(func=lambda args: show_machine(args.machine))
1511
+
1512
+ list_tools_parser = subparsers.add_parser(
1513
+ "list-tools", help="List tool records (the public facade)"
1514
+ )
1515
+ list_tools_parser.set_defaults(func=lambda _: list_tools())
1516
+
1517
+ show_tool_parser = subparsers.add_parser(
1518
+ "show-tool",
1519
+ help="Show one tool instance record with provenance",
1520
+ description="Show a tool instance record's canonical fields, each with "
1521
+ "the source that vouches for it. RECORD resolves by id, name, "
1522
+ "or unique prefix.",
1523
+ )
1524
+ show_tool_parser.add_argument("record", help="Tool record id, name, or unique prefix")
1525
+ show_tool_parser.set_defaults(func=lambda args: show_tool(args.record))
1526
+
1527
+ tool_table_parser = subparsers.add_parser(
1528
+ "tool-table", help="Show a machine's tool-table entries and bind state"
1529
+ )
1530
+ tool_table_parser.add_argument("machine", help="Machine id or unique prefix")
1531
+ tool_table_parser.set_defaults(func=lambda args: show_tool_table(args.machine))
1532
+
1533
+ delete_machine_parser = subparsers.add_parser(
1534
+ "delete-machine",
1535
+ help="Delete a machine and its tool-table entries (records untouched)",
1536
+ )
1537
+ delete_machine_parser.add_argument("machine", help="Machine id or unique prefix")
1538
+ delete_machine_parser.add_argument(
1539
+ "--yes", "-y", action="store_true", help="Skip the confirmation prompt"
1540
+ )
1541
+ delete_machine_parser.set_defaults(func=lambda args: delete_machine(args.machine, args.yes))
1542
+
1543
+ delete_tool_parser = subparsers.add_parser(
1544
+ "delete-tool",
1545
+ help="Delete a tool record (bound entries are unbound, not orphaned)",
1546
+ )
1547
+ delete_tool_parser.add_argument("record", help="Tool record id or unique prefix")
1548
+ delete_tool_parser.add_argument(
1549
+ "--yes", "-y", action="store_true", help="Skip the confirmation prompt"
1550
+ )
1551
+ delete_tool_parser.set_defaults(func=lambda args: delete_tool(args.record, args.yes))
1552
+
1553
+ delete_entry_parser = subparsers.add_parser(
1554
+ "delete-entry", help="Remove a machine-reported tool-table entry"
1555
+ )
1556
+ delete_entry_parser.add_argument("machine", help="Machine id or unique prefix")
1557
+ delete_entry_parser.add_argument("tool_number", type=int, help="Tool number (e.g. 3)")
1558
+ delete_entry_parser.add_argument(
1559
+ "--yes", "-y", action="store_true", help="Skip the confirmation prompt"
1560
+ )
1561
+ delete_entry_parser.set_defaults(
1562
+ func=lambda args: delete_entry(args.machine, args.tool_number, args.yes)
1563
+ )
1564
+
1565
+ bind_parser = subparsers.add_parser(
1566
+ "bind", help="Link an unbound entry to a tool record"
1567
+ )
1568
+ bind_parser.add_argument("machine", help="Machine id or unique prefix")
1569
+ bind_parser.add_argument("tool_number", type=int, help="Tool number (e.g. 3)")
1570
+ bind_parser.add_argument("record", help="Tool record id or unique prefix")
1571
+ bind_parser.set_defaults(
1572
+ func=lambda args: bind_entry(args.machine, args.tool_number, args.record)
1573
+ )
1574
+
1575
+ unbind_parser = subparsers.add_parser(
1576
+ "unbind", help="Unbind an entry (it keeps its data)"
1577
+ )
1578
+ unbind_parser.add_argument("machine", help="Machine id or unique prefix")
1579
+ unbind_parser.add_argument("tool_number", type=int, help="Tool number (e.g. 3)")
1580
+ unbind_parser.set_defaults(
1581
+ func=lambda args: unbind_entry(args.machine, args.tool_number)
1582
+ )
1583
+
1584
+ create_record_parser = subparsers.add_parser(
1585
+ "create-record",
1586
+ help="Create a tool instance — from an entry (bound) or a catalog (unbound)",
1587
+ description="Two context-aware forms. MACHINE TOOL_NUMBER creates an "
1588
+ "instance from a machine entry and BINDS it to that position. "
1589
+ "--from-catalog creates an instance from a catalog record and "
1590
+ "leaves it UNBOUND (a catalog is not a machine position).",
1591
+ )
1592
+ create_record_parser.add_argument(
1593
+ "machine", nargs="?", help="Machine id or unique prefix (entry form)"
1594
+ )
1595
+ create_record_parser.add_argument(
1596
+ "tool_number", nargs="?", type=int, help="Tool number, e.g. 3 (entry form)"
1597
+ )
1598
+ create_record_parser.add_argument(
1599
+ "--from-catalog", metavar="CATALOG",
1600
+ help="Create an UNBOUND instance from a catalog record "
1601
+ "(id/prefix/name/product_code)",
1602
+ )
1603
+ create_record_parser.add_argument(
1604
+ "--name",
1605
+ help="Name for the new instance (entry form: defaults to the entry "
1606
+ "description; catalog form: defaults to the catalog record's name)",
1607
+ )
1608
+ create_record_parser.add_argument(
1609
+ "--qa", metavar="FILE",
1610
+ help="Manufacturer QA: a geometry-shaped JSON file ({diameter:{value,"
1611
+ "unit}, ...}) measured on the certified tool (catalog form only; "
1612
+ "requires --cert)",
1613
+ )
1614
+ create_record_parser.add_argument(
1615
+ "--cert",
1616
+ help="Certificate/serial the --qa measurements are recorded against; the "
1617
+ "server stamps them observed:manufacturer@<serial> (required iff --qa)",
1618
+ )
1619
+ create_record_parser.set_defaults(func=create_record)
1620
+
1621
+ # === create-machine ===
1622
+ create_machine_parser = subparsers.add_parser(
1623
+ "create-machine", help="Create a machine and name it"
1624
+ )
1625
+ create_machine_parser.add_argument("name", help="Machine name (e.g. millstone)")
1626
+ create_machine_parser.add_argument("--controller", help="Controller type (e.g. linuxcnc)")
1627
+ create_machine_parser.set_defaults(
1628
+ func=lambda args: create_machine(args.name, args.controller)
1629
+ )
1630
+
1631
+ # === create-set ===
1632
+ create_set_parser = subparsers.add_parser(
1633
+ "create-set", help="Create a tool set and name it"
1634
+ )
1635
+ create_set_parser.add_argument("name", help="Tool set name")
1636
+ create_set_parser.set_defaults(func=lambda args: create_set(args.name))
1637
+
1638
+ add_to_set_parser = subparsers.add_parser(
1639
+ "add-to-set", help="Add one or more tools to a tool set",
1640
+ description="Append tool record(s) to a set's membership. Existing "
1641
+ "members (and their numbers) are kept; a tool already in the "
1642
+ "set is skipped. SET and each TOOL resolve by id, name, or "
1643
+ "unique prefix.",
1644
+ )
1645
+ add_to_set_parser.add_argument("set", help="Tool set id, name, or unique prefix")
1646
+ add_to_set_parser.add_argument("tool", nargs="+",
1647
+ help="Tool record id(s), name(s), or unique prefix(es)")
1648
+ add_to_set_parser.set_defaults(func=lambda args: add_to_set(args.set, args.tool))
1649
+
1650
+ remove_from_set_parser = subparsers.add_parser(
1651
+ "remove-from-set", help="Remove one or more tools from a tool set",
1652
+ description="Remove tool record(s) from a set's membership; the rest are "
1653
+ "kept. SET and each TOOL resolve by id, name, or unique prefix.",
1654
+ )
1655
+ remove_from_set_parser.add_argument("set", help="Tool set id, name, or unique prefix")
1656
+ remove_from_set_parser.add_argument("tool", nargs="+",
1657
+ help="Tool record id(s), name(s), or unique prefix(es)")
1658
+ remove_from_set_parser.set_defaults(
1659
+ func=lambda args: remove_from_set(args.set, args.tool))
1660
+
1661
+ # === create-catalog-record ===
1662
+ create_catalog_parser = subparsers.add_parser(
1663
+ "create-catalog-record",
1664
+ help="Create a catalog record from JSON (stdin/--file) or flags",
1665
+ description="Create a ToolCatalogRecord in one atomic, audited call. "
1666
+ "Nominal fields come from JSON on stdin (the '-' convention) "
1667
+ "or --file, plus convenience flags. --source is the declared "
1668
+ "actor; the server stamps 'asserted:<source>' on every field.",
1669
+ )
1670
+ create_catalog_parser.add_argument(
1671
+ "input", nargs="?",
1672
+ help="'-' to read JSON from stdin (default when piped), or a path",
1673
+ )
1674
+ create_catalog_parser.add_argument(
1675
+ "--source", required=True,
1676
+ help="Declared actor, e.g. manufacturer:kennametal "
1677
+ "(server stamps asserted:<source> on every field)",
1678
+ )
1679
+ create_catalog_parser.add_argument("--file", help="Read nominal fields JSON from this file")
1680
+ create_catalog_parser.add_argument("--name", help="Catalog record name")
1681
+ create_catalog_parser.add_argument("--manufacturer", help="Manufacturer")
1682
+ create_catalog_parser.add_argument("--product-code", help="Manufacturer product code")
1683
+ create_catalog_parser.add_argument("--diameter", type=float, help="Nominal diameter (mm)")
1684
+ create_catalog_parser.add_argument("--flutes", type=int, help="Flute count")
1685
+ create_catalog_parser.set_defaults(func=lambda args: create_catalog_record(
1686
+ source=args.source,
1687
+ file=args.file or (args.input if args.input and args.input != "-" else None),
1688
+ name=args.name, manufacturer=args.manufacturer,
1689
+ product_code=args.product_code, diameter=args.diameter, flutes=args.flutes,
1690
+ ))
1691
+
1692
+ # === list-catalog-records ===
1693
+ list_catalog_parser = subparsers.add_parser(
1694
+ "list-catalog-records", help="List catalog records (ToolCatalogRecords)"
1695
+ )
1696
+ list_catalog_parser.set_defaults(func=lambda _: list_catalog_records())
1697
+
1698
+ # === show-catalog-record ===
1699
+ show_catalog_parser = subparsers.add_parser(
1700
+ "show-catalog-record", help="Show one catalog record with provenance"
1701
+ )
1702
+ show_catalog_parser.add_argument(
1703
+ "catalog", help="Catalog record id, unique prefix, name, or product_code"
1704
+ )
1705
+ show_catalog_parser.set_defaults(func=lambda args: show_catalog_record(args.catalog))
1706
+
1707
+ # === push ===
1708
+ push_parser = subparsers.add_parser(
1709
+ "push", help="Push a tool table to a machine (controller-side sync)"
1710
+ )
1711
+ push_parser.add_argument("machine", help="Machine id, name, or unique prefix")
1712
+ push_parser.add_argument(
1713
+ "--entry", action="append", dest="entries", metavar="N[:DESC[:DIA]]",
1714
+ help="A tool-table entry, e.g. --entry '3:1/4 downcut:6.35' (repeatable)"
1715
+ )
1716
+ push_parser.add_argument("--client", default="loobric",
1717
+ help="Client name stamped on the push (default: loobric)")
1718
+ push_parser.add_argument("--snapshot", action="store_true",
1719
+ help="Snapshot mode: entries absent from this push are removed")
1720
+ push_parser.set_defaults(
1721
+ func=lambda args: push_table(args.machine, args.entries, args.client, args.snapshot)
1722
+ )
1723
+
1724
+ # === reset ===
1725
+ reset_parser = subparsers.add_parser(
1726
+ "reset", help="Wipe all tool data for this account (keeps login + API keys)"
1727
+ )
1728
+ reset_parser.add_argument("--yes", "-y", action="store_true",
1729
+ help="Skip the confirmation prompt")
1730
+ reset_parser.set_defaults(func=lambda args: reset_account(args.yes))
1731
+
1732
+ # === list-users (admin) ===
1733
+ list_users_parser = subparsers.add_parser(
1734
+ "list-users",
1735
+ help="ADMIN: list all accounts — how many exist and who they are",
1736
+ description="Admin-only account roster: how many accounts exist and who "
1737
+ "they are (email, role, flags, API-key count, created date), "
1738
+ "newest first. No secrets are shown.",
1739
+ )
1740
+ list_users_parser.set_defaults(func=lambda _: list_users())
1741
+
1742
+ # === wipe-all (admin factory reset) ===
1743
+ wipe_parser = subparsers.add_parser(
1744
+ "wipe-all",
1745
+ help="ADMIN: wipe ALL data, accounts, and keys — including yours (no undo)",
1746
+ description="Factory reset (admin-only): delete ALL data, ALL accounts, "
1747
+ "and ALL API keys on the server, including the calling admin. "
1748
+ "Prompts for an exact confirmation phrase; pass --confirm to "
1749
+ "run non-interactively. There is no undo.",
1750
+ )
1751
+ wipe_parser.add_argument(
1752
+ "--confirm", metavar="PHRASE",
1753
+ help=f"The exact phrase '{WIPE_PHRASE}' (required when non-interactive)",
1754
+ )
1755
+ wipe_parser.set_defaults(func=lambda args: wipe_all(args.confirm))
1756
+
1757
+ # === change-password ===
1758
+ change_pw_parser = subparsers.add_parser(
1759
+ "change-password",
1760
+ help="Change your password (prompts for current + new)",
1761
+ description="Change the authenticated user's password. Prompts for the "
1762
+ "current and new password when not given as flags.",
1763
+ )
1764
+ change_pw_parser.add_argument("--current", help="Current password (prompted if omitted)")
1765
+ change_pw_parser.add_argument("--new", help="New password (prompted if omitted)")
1766
+ change_pw_parser.set_defaults(func=lambda args: change_password(args.current, args.new))
1767
+
1768
+ # === version ===
1769
+ version_parser = subparsers.add_parser(
1770
+ "version",
1771
+ help="Show client and server versions (no login required)",
1772
+ description="Print this client's version and the server's build "
1773
+ "(from the unauthenticated /version endpoint). The quickest "
1774
+ "'are my client and server compatible?' check.",
1775
+ )
1776
+ version_parser.set_defaults(func=lambda _: show_version())
1777
+
1778
+ # === whoami ===
1779
+ whoami_parser = subparsers.add_parser("whoami", help="Show the authenticated account")
1780
+ whoami_parser.set_defaults(func=lambda _: whoami())
1781
+
1782
+ # === audit ===
1783
+ audit_parser = subparsers.add_parser("audit", help="Show recent audit-log entries")
1784
+ audit_parser.add_argument("--limit", type=int, default=50, help="Max entries (default 50)")
1785
+ audit_parser.set_defaults(func=lambda args: list_audit(args.limit))
1786
+
1787
+ # === backup-export / backup-import ===
1788
+ backup_export_parser = subparsers.add_parser(
1789
+ "backup-export", help="Export a full account backup (admin)")
1790
+ backup_export_parser.add_argument("--out", help="Write to this file (default: stdout)")
1791
+ backup_export_parser.set_defaults(func=lambda args: backup_export(args.out))
1792
+
1793
+ backup_import_parser = subparsers.add_parser(
1794
+ "backup-import", help="Restore an account backup from a JSON file (admin)")
1795
+ backup_import_parser.add_argument("file", help="Path to the backup JSON file")
1796
+ backup_import_parser.set_defaults(func=lambda args: backup_import(args.file))
1797
+
1798
+ # === assert (the canonical assert door) ===
1799
+ assert_parser = subparsers.add_parser(
1800
+ "assert", help="Assert a canonical field: assert <resource> <id> <path> <value>")
1801
+ assert_parser.add_argument("resource", help="e.g. machine-records, tool-set-records")
1802
+ assert_parser.add_argument("record_id", help="Record id")
1803
+ assert_parser.add_argument("path", help="Canonical path, e.g. name")
1804
+ assert_parser.add_argument("value", help="Value (JSON-parsed if possible, else string)")
1805
+ assert_parser.set_defaults(
1806
+ func=lambda args: assert_canonical(args.resource, args.record_id, args.path, args.value))
1807
+
1808
+ # === import (format importers) ===
1809
+ import_parser = subparsers.add_parser(
1810
+ "import",
1811
+ help="Import tool data from a known format (DIN 4000, STEP P21, GTC, "
1812
+ "SolidCAM, hyperMILL) into catalog records")
1813
+ import_parser.add_argument("file", help="Path to the export file (.csv/.xml/.p21/.zip)")
1814
+ import_parser.add_argument(
1815
+ "--source", default=None,
1816
+ help="Declared actor; the server stamps asserted:<source> (default: din4000-import)")
1817
+ import_parser.add_argument(
1818
+ "--dry-run", action="store_true",
1819
+ help="Parse and show what would be created, without sending anything")
1820
+ import_parser.add_argument(
1821
+ "--no-preserve", action="store_true",
1822
+ help="Do not store the raw source payload in the record's client section")
1823
+ import_parser.set_defaults(func=lambda args: import_tools(
1824
+ args.file, source=args.source, dry_run=args.dry_run, no_preserve=args.no_preserve))
1825
+
1826
+ # === ping ===
1827
+ ping_parser = subparsers.add_parser(
1828
+ "ping",
1829
+ help="Check server health",
1830
+ description="Check if the server is reachable and healthy."
1831
+ )
1832
+ ping_parser.set_defaults(func=lambda _: ping())
1833
+
1834
+ # === logout ===
1835
+ logout_parser = subparsers.add_parser(
1836
+ "logout",
1837
+ help="End current session",
1838
+ description="End the current session (clears session cookie)."
1839
+ )
1840
+ logout_parser.set_defaults(func=lambda _: logout())
1841
+
1842
+ # Optional shell tab-completion. argcomplete is NOT a dependency — this file
1843
+ # stays stdlib-only and fully runnable without it; when argcomplete is
1844
+ # present and registered (see README/CLI.md), it wires up completion for
1845
+ # every subcommand and flag derived from this parser. No-op when absent.
1846
+ try:
1847
+ import argcomplete
1848
+ argcomplete.autocomplete(parser)
1849
+ except ImportError:
1850
+ pass
1851
+
1852
+ # Parse args
1853
+ args = parser.parse_args()
1854
+
1855
+ # Set global state
1856
+
1857
+ # Handle --login shortcut
1858
+ if args.login:
1859
+ _run(login)
1860
+ return
1861
+
1862
+ # Handle --logout shortcut
1863
+ if args.logout:
1864
+ # Load session to get transport.BASE_URL for logout request
1865
+ transport.API_KEY = os.environ.get("LOOBRIC_API_KEY")
1866
+ if not transport.API_KEY:
1867
+ session_data = transport.load_session()
1868
+ _run(logout)
1869
+ return
1870
+
1871
+ # Set transport.BASE_URL from args or environment first (before loading session)
1872
+ if args.base_url:
1873
+ transport.BASE_URL = args.base_url.rstrip("/")
1874
+
1875
+ # Load session first (for session-based auth)
1876
+ session_data = transport.load_session()
1877
+
1878
+ # API key precedence: --api-key flag > LOOBRIC_API_KEY env > saved session
1879
+ # cookie. The env var makes the create-key "export LOOBRIC_API_KEY=…" advice
1880
+ # actually take effect without repeating --api-key on every command — the
1881
+ # right default for a sandbox, where sessions die on each redeploy but API
1882
+ # keys persist. A flag still wins; the transport prefers a key over a cookie.
1883
+ if args.api_key:
1884
+ transport.API_KEY = args.api_key
1885
+ elif os.environ.get("LOOBRIC_API_KEY"):
1886
+ transport.API_KEY = os.environ["LOOBRIC_API_KEY"]
1887
+
1888
+ # Validate transport.BASE_URL is set. `version` is exempt: it reports the
1889
+ # client version with or without a server, and shows the server build only
1890
+ # when a base URL happens to be configured.
1891
+ if not transport.BASE_URL and getattr(args, "command", None) != "version":
1892
+ print("Error: Base URL required. Set it once and every command targets "
1893
+ "that server, e.g.:\n"
1894
+ " export LOOBRIC_BASE_URL=https://api.loobric.com\n"
1895
+ "or pass --base-url <url>, or run 'loobric --login' first.",
1896
+ file=sys.stderr)
1897
+ sys.exit(1)
1898
+
1899
+ if args.verbose:
1900
+ print(f"Base URL: {transport.BASE_URL}", file=sys.stderr)
1901
+ if transport.API_KEY:
1902
+ src = "--api-key flag" if args.api_key else "$LOOBRIC_API_KEY"
1903
+ print(f"Using API key from {src}", file=sys.stderr)
1904
+ elif transport.SESSION_COOKIE:
1905
+ print(f"Using saved session from {transport.SESSION_FILE}", file=sys.stderr)
1906
+ else:
1907
+ print(f"No authentication (login required for protected endpoints)", file=sys.stderr)
1908
+
1909
+ # Run command if one was provided
1910
+ if hasattr(args, 'func'):
1911
+ _run(args.func, args)
1912
+ else:
1913
+ parser.print_help()
1914
+
1915
+
1916
+ if __name__ == "__main__":
1917
+ main()