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/__init__.py +26 -0
- loobric/cli/__init__.py +8 -0
- loobric/cli/main.py +1917 -0
- loobric/client.py +413 -0
- loobric/errors.py +40 -0
- loobric/importers/__init__.py +67 -0
- loobric/importers/_util.py +31 -0
- loobric/importers/base.py +67 -0
- loobric/importers/din4000/__init__.py +65 -0
- loobric/importers/din4000/_codes.py +116 -0
- loobric/importers/din4000/_csv.py +30 -0
- loobric/importers/din4000/_xml.py +42 -0
- loobric/importers/gtc.py +145 -0
- loobric/importers/hypermill.py +72 -0
- loobric/importers/p21.py +222 -0
- loobric/importers/run.py +102 -0
- loobric/importers/solidcam.py +89 -0
- loobric/transport.py +210 -0
- loobric_cli-1.0.0.dist-info/METADATA +141 -0
- loobric_cli-1.0.0.dist-info/RECORD +24 -0
- loobric_cli-1.0.0.dist-info/WHEEL +5 -0
- loobric_cli-1.0.0.dist-info/entry_points.txt +2 -0
- loobric_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- loobric_cli-1.0.0.dist-info/top_level.txt +1 -0
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()
|