agent-trace-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. agent_trace/__init__.py +3 -0
  2. agent_trace/blame.py +471 -0
  3. agent_trace/blame_git.py +133 -0
  4. agent_trace/blame_meta.py +167 -0
  5. agent_trace/cli.py +2680 -0
  6. agent_trace/commit_link.py +226 -0
  7. agent_trace/config.py +137 -0
  8. agent_trace/context.py +385 -0
  9. agent_trace/conversations.py +358 -0
  10. agent_trace/git_notes.py +491 -0
  11. agent_trace/hooks/__init__.py +163 -0
  12. agent_trace/hooks/base.py +233 -0
  13. agent_trace/hooks/claude.py +483 -0
  14. agent_trace/hooks/codex.py +232 -0
  15. agent_trace/hooks/cursor.py +278 -0
  16. agent_trace/hooks/git.py +144 -0
  17. agent_trace/ledger.py +955 -0
  18. agent_trace/models.py +618 -0
  19. agent_trace/record.py +417 -0
  20. agent_trace/registry.py +230 -0
  21. agent_trace/remote.py +673 -0
  22. agent_trace/rewrite.py +176 -0
  23. agent_trace/rules.py +209 -0
  24. agent_trace/schemas/commit-link.schema.json +34 -0
  25. agent_trace/schemas/git-note.schema.json +88 -0
  26. agent_trace/schemas/ledger.schema.json +97 -0
  27. agent_trace/schemas/remotes.schema.json +30 -0
  28. agent_trace/schemas/sync-state.schema.json +49 -0
  29. agent_trace/schemas/trace-record.schema.json +130 -0
  30. agent_trace/session.py +136 -0
  31. agent_trace/storage.py +229 -0
  32. agent_trace/summary.py +465 -0
  33. agent_trace/summary_presets.py +188 -0
  34. agent_trace/sync.py +1182 -0
  35. agent_trace/telemetry.py +142 -0
  36. agent_trace/trace.py +460 -0
  37. agent_trace_cli-0.1.0.dist-info/METADATA +535 -0
  38. agent_trace_cli-0.1.0.dist-info/RECORD +42 -0
  39. agent_trace_cli-0.1.0.dist-info/WHEEL +5 -0
  40. agent_trace_cli-0.1.0.dist-info/entry_points.txt +2 -0
  41. agent_trace_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  42. agent_trace_cli-0.1.0.dist-info/top_level.txt +1 -0
agent_trace/remote.py ADDED
@@ -0,0 +1,673 @@
1
+ """
2
+ Remote configuration management — git remote-like model.
3
+
4
+ Each project can have multiple named remotes. The remote URL has the shape
5
+ ``<scheme>://<host>[:port]/<org_slug>/<project_slug>`` — mirroring
6
+ ``github.com/<org>/<repo>``. The ``project_slug`` parsed out of the URL is
7
+ the **wire** ``project_id`` used by the sync routes; the local on-disk
8
+ data directory still uses the anchor-derived local id, so the
9
+ standalone-no-service flow is unaffected.
10
+
11
+ Tokens are stored separately:
12
+ - ``global:<project_id>::<name>`` → ``~/.agent-trace/config.json`` under
13
+ ``tokens.<project_id>::<name>``. Scoping by ``project_id`` keeps two
14
+ projects that both call a remote ``origin`` from clobbering each
15
+ other's token.
16
+ - ``env:<VAR>`` → ``os.environ[VAR]`` at runtime (never persisted)
17
+ - ``keychain:<name>``→ OS keychain (scaffold only — not yet implemented)
18
+
19
+ Legacy bare-name refs (``global:<name>``) written by older versions still
20
+ resolve — ``resolve_token`` treats whatever follows ``global:`` as a flat
21
+ key into ``tokens``.
22
+
23
+ No external dependencies — stdlib only.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ import re
31
+ import urllib.parse
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ from .storage import ensure_project_dir, get_project_dir
36
+
37
+
38
+ # -------------------------------------------------------------------
39
+ # URL parsing
40
+ # -------------------------------------------------------------------
41
+
42
+ # Mirrors the server-side CHECK on orgs.slug and projects.project_id.
43
+ _SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
44
+
45
+
46
+ class RemoteUrlError(ValueError):
47
+ """Raised when a remote URL doesn't match the slug-grammar."""
48
+
49
+
50
+ def parse_remote_url(url: str) -> tuple[str, str, str]:
51
+ """Parse a remote URL into (base_url, org_slug, project_slug).
52
+
53
+ Accepts: ``<scheme>://<host>[:port]/<org_slug>/<project_slug>`` with an
54
+ optional trailing slash. Rejects bare-host URLs and URLs whose path
55
+ component does not match the slug grammar.
56
+
57
+ Raises ``RemoteUrlError`` with a hint pointing at the expected shape.
58
+ """
59
+ parsed = urllib.parse.urlsplit(url.strip())
60
+ if parsed.scheme not in ("http", "https"):
61
+ raise RemoteUrlError(
62
+ f"Unsupported scheme {parsed.scheme!r}. Expected http or https."
63
+ )
64
+ if not parsed.netloc:
65
+ raise RemoteUrlError(f"URL is missing host: {url!r}")
66
+
67
+ path = parsed.path.strip("/")
68
+ parts = [p for p in path.split("/") if p]
69
+ if len(parts) != 2:
70
+ raise RemoteUrlError(
71
+ "Remote URL must include the project path. "
72
+ f"Expected ``<scheme>://<host>/<org_slug>/<project_slug>``; got {url!r}. "
73
+ "Run `agent-trace project create <url>` if you need to register the slug first."
74
+ )
75
+
76
+ org_slug, project_slug = parts
77
+ if not _SLUG_RE.match(org_slug):
78
+ raise RemoteUrlError(
79
+ f"Org slug {org_slug!r} must match {_SLUG_RE.pattern}."
80
+ )
81
+ if not _SLUG_RE.match(project_slug):
82
+ raise RemoteUrlError(
83
+ f"Project slug {project_slug!r} must match {_SLUG_RE.pattern}."
84
+ )
85
+
86
+ base = urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
87
+ return base, org_slug, project_slug
88
+
89
+
90
+ def is_slug(value: str) -> bool:
91
+ return bool(_SLUG_RE.match(value or ""))
92
+
93
+
94
+ # -------------------------------------------------------------------
95
+ # File helpers
96
+ # -------------------------------------------------------------------
97
+
98
+ def _remotes_path(project_id: str) -> Path:
99
+ return get_project_dir(project_id) / "remotes.json"
100
+
101
+
102
+ def _load_remotes(project_id: str) -> dict[str, Any]:
103
+ p = _remotes_path(project_id)
104
+ if not p.is_file():
105
+ return {}
106
+ try:
107
+ data = json.loads(p.read_text())
108
+ return data if isinstance(data, dict) else {}
109
+ except (json.JSONDecodeError, OSError):
110
+ return {}
111
+
112
+
113
+ def _save_remotes(project_id: str, remotes: dict[str, Any]) -> None:
114
+ ensure_project_dir(project_id)
115
+ p = _remotes_path(project_id)
116
+ p.write_text(json.dumps(remotes, indent=2) + "\n")
117
+ try:
118
+ p.chmod(0o600)
119
+ except OSError:
120
+ pass
121
+
122
+
123
+ # -------------------------------------------------------------------
124
+ # Token resolution
125
+ # -------------------------------------------------------------------
126
+
127
+ def _token_key(project_id: str, name: str) -> str:
128
+ """Per-project key for global token storage.
129
+
130
+ ``::`` separates the project_id from the remote name. project_ids are
131
+ either opaque ``at-<hex>`` anchors or path-derived slugs — neither
132
+ contains ``::`` — so the join is unambiguous.
133
+ """
134
+ return f"{project_id}::{name}"
135
+
136
+
137
+ def resolve_token(token_ref: str) -> str | None:
138
+ """Resolve a token reference to an actual token value.
139
+
140
+ Supported schemes:
141
+ ``global:<key>`` — reads ``tokens.<key>`` from global config.
142
+ Modern refs use ``<project_id>::<name>``;
143
+ legacy bare-name keys still work.
144
+ ``env:<VAR>`` — reads environment variable
145
+ ``keychain:<name>``— (stub) returns None
146
+ raw string — returned as-is (direct token)
147
+ """
148
+ if token_ref.startswith("global:"):
149
+ key = token_ref[7:]
150
+ from .config import get_global_config
151
+ tokens = get_global_config().get("tokens", {})
152
+ return tokens.get(key)
153
+
154
+ if token_ref.startswith("env:"):
155
+ var = token_ref[4:]
156
+ return os.environ.get(var)
157
+
158
+ if token_ref.startswith("keychain:"):
159
+ return None
160
+
161
+ return token_ref
162
+
163
+
164
+ def _store_global_token(project_id: str, name: str, token: str) -> str:
165
+ """Store a token under a per-project key. Returns the storage key so
166
+ the caller can write the matching ``token_ref``.
167
+ """
168
+ from .config import get_global_config, save_global_config
169
+ key = _token_key(project_id, name)
170
+ cfg = get_global_config()
171
+ tokens = cfg.setdefault("tokens", {})
172
+ tokens[key] = token
173
+ save_global_config(cfg)
174
+ return key
175
+
176
+
177
+ def _delete_global_token(project_id: str, name: str) -> None:
178
+ """Remove the per-project token entry from global config, if present."""
179
+ from .config import get_global_config, save_global_config
180
+ key = _token_key(project_id, name)
181
+ cfg = get_global_config()
182
+ tokens = cfg.get("tokens") or {}
183
+ if key in tokens:
184
+ del tokens[key]
185
+ cfg["tokens"] = tokens
186
+ save_global_config(cfg)
187
+
188
+
189
+ def _mask_token(token: str | None) -> str:
190
+ """Mask a token for safe display."""
191
+ if not token:
192
+ return "(unresolved)"
193
+ if len(token) <= 8:
194
+ return "****"
195
+ return f"{'*' * 8}...{token[-4:]}"
196
+
197
+
198
+ # -------------------------------------------------------------------
199
+ # CRUD operations
200
+ # -------------------------------------------------------------------
201
+
202
+ def add_remote(
203
+ project_id: str,
204
+ name: str,
205
+ url: str,
206
+ *,
207
+ token: str | None = None,
208
+ token_env: str | None = None,
209
+ token_keychain: str | None = None,
210
+ ) -> dict[str, Any]:
211
+ """Add a new named remote. Returns the remote config dict.
212
+
213
+ The URL must include the ``<org>/<project>`` path; ``parse_remote_url``
214
+ raises ``RemoteUrlError`` otherwise.
215
+ """
216
+ remotes = _load_remotes(project_id)
217
+ if name in remotes:
218
+ raise ValueError(f"Remote '{name}' already exists. Use set-url to change its URL.")
219
+
220
+ base_url, org_slug, project_slug = parse_remote_url(url)
221
+
222
+ auth: dict[str, str] | None = None
223
+ if token:
224
+ key = _store_global_token(project_id, name, token)
225
+ auth = {"type": "bearer", "token_ref": f"global:{key}"}
226
+ elif token_env:
227
+ auth = {"type": "bearer", "token_ref": f"env:{token_env}"}
228
+ elif token_keychain:
229
+ auth = {"type": "bearer", "token_ref": f"keychain:{token_keychain}"}
230
+
231
+ entry: dict[str, Any] = {
232
+ "url": url.rstrip("/"),
233
+ "base_url": base_url,
234
+ "org_slug": org_slug,
235
+ "project_slug": project_slug,
236
+ }
237
+ if auth:
238
+ entry["auth"] = auth
239
+
240
+ remotes[name] = entry
241
+ _save_remotes(project_id, remotes)
242
+ return entry
243
+
244
+
245
+ def get_remote(project_id: str, name: str) -> dict[str, Any] | None:
246
+ """Return config for a single remote, or None."""
247
+ return _load_remotes(project_id).get(name)
248
+
249
+
250
+ def list_remotes(project_id: str) -> list[dict[str, Any]]:
251
+ """Return list of ``{name, url, token_ref}`` dicts."""
252
+ remotes = _load_remotes(project_id)
253
+ result = []
254
+ for rname, rconf in remotes.items():
255
+ auth = rconf.get("auth") or {}
256
+ result.append({
257
+ "name": rname,
258
+ "url": rconf.get("url", ""),
259
+ "token_ref": auth.get("token_ref", ""),
260
+ })
261
+ return result
262
+
263
+
264
+ def remove_remote(project_id: str, name: str) -> bool:
265
+ """Remove a named remote. Returns True if it existed.
266
+
267
+ Also drops the per-project token entry from the global config so a
268
+ deleted remote doesn't leave its secret behind.
269
+ """
270
+ remotes = _load_remotes(project_id)
271
+ if name not in remotes:
272
+ return False
273
+ del remotes[name]
274
+ _save_remotes(project_id, remotes)
275
+ _delete_global_token(project_id, name)
276
+ return True
277
+
278
+
279
+ def set_remote_url(project_id: str, name: str, url: str) -> None:
280
+ """Change the URL for an existing remote. Re-parses to refresh the
281
+ derived slug fields.
282
+ """
283
+ remotes = _load_remotes(project_id)
284
+ if name not in remotes:
285
+ raise ValueError(f"Remote '{name}' does not exist.")
286
+ base_url, org_slug, project_slug = parse_remote_url(url)
287
+ remotes[name].update({
288
+ "url": url.rstrip("/"),
289
+ "base_url": base_url,
290
+ "org_slug": org_slug,
291
+ "project_slug": project_slug,
292
+ })
293
+ _save_remotes(project_id, remotes)
294
+
295
+
296
+ def set_remote_token(
297
+ project_id: str,
298
+ name: str,
299
+ *,
300
+ token: str | None = None,
301
+ token_env: str | None = None,
302
+ ) -> None:
303
+ """Update the auth token for an existing remote."""
304
+ remotes = _load_remotes(project_id)
305
+ if name not in remotes:
306
+ raise ValueError(f"Remote '{name}' does not exist.")
307
+
308
+ if token:
309
+ key = _store_global_token(project_id, name, token)
310
+ remotes[name]["auth"] = {"type": "bearer", "token_ref": f"global:{key}"}
311
+ elif token_env:
312
+ remotes[name]["auth"] = {"type": "bearer", "token_ref": f"env:{token_env}"}
313
+ else:
314
+ raise ValueError("Provide --token or --token-env.")
315
+
316
+ _save_remotes(project_id, remotes)
317
+
318
+
319
+ def rename_remote(project_id: str, old_name: str, new_name: str) -> None:
320
+ """Rename a remote.
321
+
322
+ If the remote's token lives in the global config under the per-project
323
+ scoped key, migrate it to match the new name and rewrite ``token_ref``.
324
+ """
325
+ remotes = _load_remotes(project_id)
326
+ if old_name not in remotes:
327
+ raise ValueError(f"Remote '{old_name}' does not exist.")
328
+ if new_name in remotes:
329
+ raise ValueError(f"Remote '{new_name}' already exists.")
330
+
331
+ entry = remotes.pop(old_name)
332
+ auth = entry.get("auth") or {}
333
+ old_ref = auth.get("token_ref", "")
334
+ old_key = _token_key(project_id, old_name)
335
+ if old_ref == f"global:{old_key}":
336
+ from .config import get_global_config, save_global_config
337
+ cfg = get_global_config()
338
+ tokens = cfg.setdefault("tokens", {})
339
+ if old_key in tokens:
340
+ new_key = _token_key(project_id, new_name)
341
+ tokens[new_key] = tokens.pop(old_key)
342
+ save_global_config(cfg)
343
+ entry["auth"] = {**auth, "token_ref": f"global:{new_key}"}
344
+
345
+ remotes[new_name] = entry
346
+ _save_remotes(project_id, remotes)
347
+
348
+
349
+ def get_default_remote(project_id: str) -> str | None:
350
+ """Return the default remote name.
351
+
352
+ Rules: if only one remote, it's the default.
353
+ If multiple, look for ``default`` key in project config.
354
+ """
355
+ remotes = _load_remotes(project_id)
356
+ if not remotes:
357
+ return None
358
+ if len(remotes) == 1:
359
+ return next(iter(remotes))
360
+
361
+ from .config import get_project_config
362
+ from .storage import resolve_project_id
363
+ cfg = get_project_config() or {}
364
+ default = cfg.get("remote", {}).get("default")
365
+ if default and default in remotes:
366
+ return default
367
+ if "origin" in remotes:
368
+ return "origin"
369
+ return None
370
+
371
+
372
+ def set_default_remote(project_id: str, name: str) -> None:
373
+ """Set the default remote in project config."""
374
+ remotes = _load_remotes(project_id)
375
+ if name not in remotes:
376
+ raise ValueError(f"Remote '{name}' does not exist.")
377
+ from .config import get_project_config, save_project_config
378
+ cfg = get_project_config() or {}
379
+ cfg.setdefault("remote", {})["default"] = name
380
+ save_project_config(cfg)
381
+
382
+
383
+ def resolve_remote(project_id: str, name: str | None = None) -> tuple[str, dict[str, Any]]:
384
+ """Resolve a remote by name (or default). Returns ``(name, config)``."""
385
+ if name is None:
386
+ name = get_default_remote(project_id)
387
+ if name is None:
388
+ raise ValueError(
389
+ "No remote configured. Run 'agent-trace remote add <name> <url>' first."
390
+ )
391
+ conf = get_remote(project_id, name)
392
+ if conf is None:
393
+ raise ValueError(f"Remote '{name}' not found.")
394
+ return name, conf
395
+
396
+
397
+ def get_remote_url(remote_conf: dict[str, Any]) -> str:
398
+ """Full user-facing URL (including ``/<org>/<project>``)."""
399
+ return remote_conf.get("url", "")
400
+
401
+
402
+ def get_remote_base_url(remote_conf: dict[str, Any]) -> str:
403
+ """Service base URL (``<scheme>://<host>``) — used for API calls.
404
+
405
+ Falls back to re-parsing ``url`` for older remotes written before the
406
+ derived fields existed.
407
+ """
408
+ base = remote_conf.get("base_url")
409
+ if base:
410
+ return base
411
+ url = remote_conf.get("url", "")
412
+ if not url:
413
+ return ""
414
+ try:
415
+ base, _, _ = parse_remote_url(url)
416
+ return base
417
+ except RemoteUrlError:
418
+ return url
419
+
420
+
421
+ def get_remote_project_slug(remote_conf: dict[str, Any]) -> str | None:
422
+ """Wire ``project_id`` for sync calls. ``None`` only for malformed remotes."""
423
+ slug = remote_conf.get("project_slug")
424
+ if slug:
425
+ return slug
426
+ url = remote_conf.get("url", "")
427
+ if not url:
428
+ return None
429
+ try:
430
+ _, _, slug = parse_remote_url(url)
431
+ return slug
432
+ except RemoteUrlError:
433
+ return None
434
+
435
+
436
+ def get_remote_org_slug(remote_conf: dict[str, Any]) -> str | None:
437
+ slug = remote_conf.get("org_slug")
438
+ if slug:
439
+ return slug
440
+ url = remote_conf.get("url", "")
441
+ if not url:
442
+ return None
443
+ try:
444
+ _, slug, _ = parse_remote_url(url)
445
+ return slug
446
+ except RemoteUrlError:
447
+ return None
448
+
449
+
450
+ def get_remote_token(remote_conf: dict[str, Any]) -> str | None:
451
+ """Resolve the auth token for a remote config dict."""
452
+ auth = remote_conf.get("auth")
453
+ if not auth:
454
+ return None
455
+ ref = auth.get("token_ref", "")
456
+ return resolve_token(ref)
457
+
458
+
459
+ def show_remote(project_id: str, name: str) -> dict[str, Any] | None:
460
+ """Return a display-safe view of a remote (token masked)."""
461
+ conf = get_remote(project_id, name)
462
+ if conf is None:
463
+ return None
464
+ auth = conf.get("auth") or {}
465
+ token_ref = auth.get("token_ref", "")
466
+ resolved = resolve_token(token_ref) if token_ref else None
467
+ return {
468
+ "name": name,
469
+ "url": conf.get("url", ""),
470
+ "base_url": get_remote_base_url(conf),
471
+ "org_slug": get_remote_org_slug(conf) or "",
472
+ "project_slug": get_remote_project_slug(conf) or "",
473
+ "auth_type": auth.get("type", "none"),
474
+ "token_ref": token_ref,
475
+ "token_masked": _mask_token(resolved),
476
+ }
477
+
478
+
479
+ # -------------------------------------------------------------------
480
+ # Network: token introspection (whoami) + scope guards
481
+ # -------------------------------------------------------------------
482
+
483
+ class TokenScopeError(Exception):
484
+ """A token's resolved scope doesn't match the URL it's being used against.
485
+
486
+ Carries the structured ``code`` so CLI handlers (project create, remote
487
+ add, push/pull, doctor) can render a uniform message and the user can
488
+ grep for a stable identifier.
489
+
490
+ Codes:
491
+ - ``org_slug_mismatch`` — token org differs from URL ``<org_slug>``.
492
+ - ``project_scope_mismatch`` — project-scoped token used with a URL
493
+ whose ``<project_slug>`` is a different project.
494
+ - ``whoami_unsupported`` — the service is too old to expose
495
+ ``GET /api/v1/auth/whoami``; the caller decides whether to soft-fail.
496
+ - ``whoami_unauthorized`` — token rejected (401).
497
+ - ``whoami_failed`` — any other reason whoami didn't answer.
498
+ """
499
+
500
+ def __init__(self, code: str, message: str) -> None:
501
+ self.code = code
502
+ super().__init__(message)
503
+
504
+
505
+ class WhoamiUnsupportedError(TokenScopeError):
506
+ """Service did not implement ``GET /api/v1/auth/whoami`` (404)."""
507
+
508
+ def __init__(self) -> None:
509
+ super().__init__(
510
+ "whoami_unsupported",
511
+ "Remote service does not implement GET /api/v1/auth/whoami "
512
+ "(upgrade the service to enable strict org/project scope checks).",
513
+ )
514
+
515
+
516
+ def whoami(base_url: str, token: str, *, timeout: int = 15) -> dict[str, Any]:
517
+ """Fetch the resolved scope of ``token`` from ``base_url``.
518
+
519
+ Returns a dict with ``org_id``, ``org_slug``, ``project_id_scope``,
520
+ ``scopes``. Raises ``WhoamiUnsupportedError`` for 404 (older service)
521
+ and ``TokenScopeError`` for any other failure (401, 5xx, network).
522
+ """
523
+ import json as _json
524
+ import urllib.error as _ue
525
+ import urllib.request as _ur
526
+
527
+ req = _ur.Request(
528
+ f"{base_url.rstrip('/')}/api/v1/auth/whoami",
529
+ method="GET",
530
+ )
531
+ req.add_header("Authorization", f"Bearer {token}")
532
+ try:
533
+ with _ur.urlopen(req, timeout=timeout) as resp:
534
+ return _json.loads(resp.read().decode())
535
+ except _ue.HTTPError as e:
536
+ if e.code == 404:
537
+ raise WhoamiUnsupportedError() from None
538
+ if e.code == 401:
539
+ raise TokenScopeError(
540
+ "whoami_unauthorized",
541
+ "Token rejected by remote service (401). The token may be "
542
+ "invalid, revoked, or not minted by this service.",
543
+ ) from None
544
+ raise TokenScopeError(
545
+ "whoami_failed",
546
+ f"GET /api/v1/auth/whoami returned HTTP {e.code}.",
547
+ ) from None
548
+ except (_ue.URLError, OSError, TimeoutError) as e:
549
+ raise TokenScopeError(
550
+ "whoami_failed",
551
+ f"Could not reach {base_url}/api/v1/auth/whoami: {e}",
552
+ ) from None
553
+
554
+
555
+ def assert_token_matches_url(
556
+ base_url: str,
557
+ token: str,
558
+ *,
559
+ expected_org_slug: str,
560
+ expected_project_slug: str | None = None,
561
+ allow_unsupported: bool = True,
562
+ ) -> dict[str, Any]:
563
+ """Verify the token's scope matches the URL the user typed.
564
+
565
+ - ``expected_org_slug`` MUST equal the slug of the token's org.
566
+ - When the token is project-scoped (``project_id_scope`` set), and
567
+ ``expected_project_slug`` is provided, they must be equal.
568
+
569
+ Returns the whoami payload on success. Raises ``TokenScopeError`` on
570
+ mismatch. If the remote service is too old to support ``whoami`` and
571
+ ``allow_unsupported`` is True, returns the unsupported error in the
572
+ payload (caller decides to warn vs. fail).
573
+ """
574
+ try:
575
+ info = whoami(base_url, token)
576
+ except WhoamiUnsupportedError:
577
+ if allow_unsupported:
578
+ return {"_unsupported": True}
579
+ raise
580
+
581
+ actual_org = info.get("org_slug")
582
+ if actual_org and actual_org != expected_org_slug:
583
+ raise TokenScopeError(
584
+ "org_slug_mismatch",
585
+ f"Token belongs to org {actual_org!r} but the URL targets "
586
+ f"org {expected_org_slug!r}. Use a token for {expected_org_slug!r} "
587
+ f"or change the URL's org segment to {actual_org!r}.",
588
+ )
589
+
590
+ project_scope = info.get("project_id_scope")
591
+ if (
592
+ project_scope
593
+ and expected_project_slug
594
+ and project_scope != expected_project_slug
595
+ ):
596
+ raise TokenScopeError(
597
+ "project_scope_mismatch",
598
+ f"Token is scoped to project {project_scope!r} but the URL targets "
599
+ f"project {expected_project_slug!r}. Use an org-scoped token, a "
600
+ f"token scoped to {expected_project_slug!r}, or change the URL.",
601
+ )
602
+
603
+ return info
604
+
605
+
606
+ # -------------------------------------------------------------------
607
+ # Network: project registration
608
+ # -------------------------------------------------------------------
609
+
610
+ class ProjectRegistrationError(Exception):
611
+ """``register_project_via_remote`` raised; carries the HTTP status."""
612
+
613
+ def __init__(self, status: int, message: str, code: str | None = None) -> None:
614
+ self.status = status
615
+ self.code = code
616
+ super().__init__(message)
617
+
618
+
619
+ def register_project_via_remote(
620
+ base_url: str,
621
+ project_slug: str,
622
+ *,
623
+ org_slug: str | None = None,
624
+ token: str | None = None,
625
+ admin_secret: str | None = None,
626
+ name: str | None = None,
627
+ description: str | None = None,
628
+ ) -> dict[str, Any]:
629
+ """POST /api/v1/projects on the service. Returns the project record on
630
+ success.
631
+
632
+ Auth: pass ``token`` for an org-scoped Bearer (must carry
633
+ ``projects:write``), or ``admin_secret`` for the X-Admin-Secret path.
634
+
635
+ ``org_slug`` is sent in the body so the server cross-checks it against the
636
+ caller's org. The CLI always derives this from the URL's first path
637
+ segment so a URL like ``https://svc/foo-org/myproj`` cannot end up writing
638
+ rows under a *different* org just because the token happened to be valid.
639
+ """
640
+ import json as _json
641
+ import urllib.error as _ue
642
+ import urllib.request as _ur
643
+
644
+ body: dict[str, Any] = {"project_id": project_slug}
645
+ if org_slug is not None:
646
+ body["org_slug"] = org_slug
647
+ if name is not None:
648
+ body["name"] = name
649
+ if description is not None:
650
+ body["description"] = description
651
+
652
+ req = _ur.Request(
653
+ f"{base_url.rstrip('/')}/api/v1/projects",
654
+ data=_json.dumps(body).encode("utf-8"),
655
+ method="POST",
656
+ )
657
+ req.add_header("Content-Type", "application/json")
658
+ if admin_secret:
659
+ req.add_header("X-Admin-Secret", admin_secret)
660
+ if token:
661
+ req.add_header("Authorization", f"Bearer {token}")
662
+
663
+ try:
664
+ with _ur.urlopen(req, timeout=30) as resp:
665
+ return _json.loads(resp.read().decode())
666
+ except _ue.HTTPError as e:
667
+ raw = e.read().decode() if e.fp else ""
668
+ try:
669
+ payload = _json.loads(raw) if raw else {}
670
+ except _json.JSONDecodeError:
671
+ payload = {"raw": raw}
672
+ msg = payload.get("error") or f"HTTP {e.code}"
673
+ raise ProjectRegistrationError(e.code, msg, payload.get("code")) from None