buildai-cli 0.3.87__tar.gz → 0.3.89__tar.gz

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 (44) hide show
  1. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/.gitignore +5 -0
  2. buildai_cli-0.3.89/AGENTS.md +44 -0
  3. buildai_cli-0.3.89/CLAUDE.md +1 -0
  4. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/PKG-INFO +1 -1
  5. buildai_cli-0.3.89/cli/commands/ego_frame_search.py +569 -0
  6. buildai_cli-0.3.89/cli/commands/spec.py +173 -0
  7. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/main.py +5 -0
  8. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/pyproject.toml +1 -1
  9. buildai_cli-0.3.87/AGENTS.md +0 -1
  10. buildai_cli-0.3.87/CLAUDE.md +0 -47
  11. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/buildai_bootstrap.py +0 -0
  12. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/__init__.py +0 -0
  13. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/_has_core.py +0 -0
  14. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/auth_local.py +0 -0
  15. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/commands/__init__.py +0 -0
  16. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/commands/api_proxy.py +0 -0
  17. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/commands/auth.py +0 -0
  18. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/commands/db/__init__.py +0 -0
  19. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/commands/db/broker.py +0 -0
  20. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/commands/db/common.py +0 -0
  21. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/commands/db/migrate.py +0 -0
  22. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/commands/db/query.py +0 -0
  23. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/commands/db/schema.py +0 -0
  24. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/commands/db/status.py +0 -0
  25. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/commands/db/tunnel.py +0 -0
  26. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/commands/dev.py +0 -0
  27. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/commands/doctor.py +0 -0
  28. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/commands/egoexo.py +0 -0
  29. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/commands/gigcamera.py +0 -0
  30. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/commands/grid.py +0 -0
  31. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/commands/ingest.py +0 -0
  32. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/commands/ingest_docs.py +0 -0
  33. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/commands/processing.py +0 -0
  34. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/config.py +0 -0
  35. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/console.py +0 -0
  36. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/context.py +0 -0
  37. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/db_broker.py +0 -0
  38. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/guard.py +0 -0
  39. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/internal_api.py +0 -0
  40. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/nl_query/__init__.py +0 -0
  41. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/nl_query/dataset_tools.py +0 -0
  42. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/ops_init.py +0 -0
  43. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/output.py +0 -0
  44. {buildai_cli-0.3.87 → buildai_cli-0.3.89}/cli/pagination.py +0 -0
@@ -21,6 +21,8 @@ dist/
21
21
  !apps/buildai-openai-ingest/frontend/dist/
22
22
  !apps/buildai-openai-ingest/frontend/dist/index.html
23
23
  !apps/buildai-openai-ingest/frontend/dist/mac-mini.png
24
+ # Vendored BuildSpec engine — commit its built dist; node_modules stays ignored.
25
+ !tools/buildspec/dist/
24
26
  build/
25
27
  .pytest_cache/
26
28
  .mypy_cache/
@@ -106,6 +108,9 @@ recording-cache/
106
108
  /datasets/250k-hour-dataset/build/**/out/
107
109
  /datasets/imu-on-head-frames-2026-04-25/*
108
110
  !/datasets/imu-on-head-frames-2026-04-25/README.md
111
+ !/datasets/golden-ego-exo/build/
112
+ !/datasets/golden-ego-exo/build/**
113
+ /datasets/golden-ego-exo/build/**/out/
109
114
  /datasets/dataset-*.json
110
115
  /datasets/**/__pycache__/
111
116
  /datasets/**/frames/
@@ -0,0 +1,44 @@
1
+ # BuildAI CLI — Typer CLI with standalone (API-backed) and workspace (DB-direct) modes.
2
+
3
+ ## Read first
4
+
5
+ - [../../AGENTS.md](../../AGENTS.md)
6
+ - Docs site Engineering section (`make docs`)
7
+ - Docs site Operations > Database roles (`make docs`)
8
+
9
+ ## Rules
10
+
11
+ **Two planes.**
12
+
13
+ | Mode | Install | Auth | DB access |
14
+ |------|---------|------|-----------|
15
+ | Standalone (`buildai`) | `uv tool install buildai-cli` | API key / JWT | Through API |
16
+ | Workspace (`uv run buildai`) | Repo-local editable install | IAM / password | Direct via `db`, through profile-keyed local broker |
17
+
18
+ **DB guards.** `db` subcommands require workspace install + gcloud IAM. Inspection/migration uses a profile-keyed `alloydb-auth-proxy` broker with state in `~/.buildai/db-brokers` (shared across worktrees). Use `buildai db broker status|ensure|stop` for local transport.
19
+
20
+ **Writes.** Require `--write`. Production migrations prompt for confirmation.
21
+
22
+ **Targeting.** Worktree app/runtime targeting comes from explicit env vars and the repo Makefile, not a saved CLI context layer. `buildai db --env` selects `production`, `staging`, or `dev`.
23
+
24
+ **Staging.** Private-IP only. Start `buildai db --env staging tunnel`, then pass `--all-proxy socks5://127.0.0.1:1080` for queries.
25
+
26
+ **Workers.** Call the API through the SDK; do not import `core/` or access the database directly from worker shells.
27
+
28
+ ## Verify
29
+
30
+ ```bash
31
+ buildai auth whoami
32
+ uv run buildai ingest deploy plan --scope active
33
+ buildai db broker status
34
+ buildai db schema tables
35
+ buildai db query "SELECT count(*) FROM core.clips"
36
+ buildai db --env staging tunnel # in one terminal; query with --all-proxy in another
37
+ ```
38
+
39
+ ## Don't
40
+
41
+ - Run `db --write` without `--write`.
42
+ - Query staging without an IAP/VPN/SOCKS route and `--all-proxy`.
43
+ - Assume a saved CLI context picks the worktree's DB lane — use env vars and Makefile.
44
+ - Deploy ingest fleet changes from an unclear branch or dirty checkout.
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: buildai-cli
3
- Version: 0.3.87
3
+ Version: 0.3.89
4
4
  Summary: Build AI CLI (Typer)
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: httpx>=0.27.0
@@ -0,0 +1,569 @@
1
+ """Operator commands for the reusable ego frame-search embedding space."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import asdict, is_dataclass
7
+ from uuid import UUID
8
+
9
+ import typer
10
+
11
+ from cli.context import get_cli_context
12
+ from cli.ops_init import init_ops_context
13
+ from cli.output import Format, format_option, render
14
+
15
+ app = typer.Typer(no_args_is_help=True, help="Operate ego frame-search embeddings.")
16
+ _SUBMITTED_BY = "00000000-0000-0000-0000-000000000000"
17
+
18
+
19
+ @app.callback()
20
+ def ego_frame_search_callback(
21
+ ctx: typer.Context,
22
+ env: str | None = typer.Option(
23
+ None,
24
+ "--env",
25
+ "-e",
26
+ help="Target environment. Defaults to APP_ENV, then production.",
27
+ ),
28
+ auth: str = typer.Option(None, "--auth", "-a", help="Auth method: iam or password."),
29
+ user: str = typer.Option(None, "--user", "-u", help="Override database user."),
30
+ profile: str | None = typer.Option(
31
+ None,
32
+ "--profile",
33
+ "-p",
34
+ help="Auth workflow profile. Use internal_admin for writes.",
35
+ ),
36
+ all_proxy: str | None = typer.Option(
37
+ None,
38
+ "--all-proxy",
39
+ help="Proxy URL passed to alloydb-auth-proxy for private-only DB lanes.",
40
+ ),
41
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose logging."),
42
+ ) -> None:
43
+ """Capture DB-targeting flags; subcommands initialize ops context lazily."""
44
+ ctx.ensure_object(dict)
45
+ ctx.obj["cli_profile"] = profile
46
+ ctx.obj["_env"] = env
47
+ ctx.obj["_auth"] = auth
48
+ ctx.obj["_user"] = user
49
+ ctx.obj["_all_proxy"] = all_proxy
50
+ ctx.obj["_verbose"] = verbose
51
+ ctx.obj.setdefault("allow_write", False)
52
+ ctx.obj.setdefault("_ops_ready", False)
53
+
54
+
55
+ def _settings_for_command(ctx: typer.Context, *, write: bool) -> object:
56
+ """Initialize the repo DB settings for one command."""
57
+ ctx.ensure_object(dict)
58
+ ctx.obj["allow_write"] = write
59
+ if "settings" in ctx.obj:
60
+ return ctx.obj["settings"]
61
+ return init_ops_context(ctx)
62
+
63
+
64
+ def _require_internal_admin_for_write(ctx: typer.Context, *, write: bool) -> None:
65
+ """Require the explicit admin profile before direct DB writes."""
66
+ if not write:
67
+ return
68
+ profile = (ctx.obj or {}).get("cli_profile") or "engineers-dev"
69
+ if profile != "internal_admin":
70
+ raise typer.BadParameter("writes require --profile internal_admin")
71
+
72
+
73
+ def _plain(value: object) -> object:
74
+ """Convert dataclass outputs into JSON/table-friendly dictionaries."""
75
+ if is_dataclass(value) and not isinstance(value, type):
76
+ return asdict(value)
77
+ if isinstance(value, list):
78
+ return [_plain(item) for item in value]
79
+ return value
80
+
81
+
82
+ async def _resolve_space_id(dal_ctx: object, *, space_id: int | None, space_key: str | None) -> int:
83
+ """Resolve one space id from either numeric id or semantic key."""
84
+ from dal.embeddings import spaces as spaces_dal
85
+
86
+ if space_id is not None and space_key is not None:
87
+ raise typer.BadParameter("pass either --space-id or --space-key, not both")
88
+ if space_id is not None:
89
+ return space_id
90
+ if space_key is None:
91
+ space_key = "qwen.frame.global.ego.default"
92
+ space = await spaces_dal.get_by_space_key(dal_ctx, space_key)
93
+ return space.id
94
+
95
+
96
+ @app.command("corpora")
97
+ def corpora(
98
+ ctx: typer.Context,
99
+ format: Format = format_option(),
100
+ ) -> None:
101
+ """List registered ego frame-search corpora and route flags."""
102
+ settings = _settings_for_command(ctx, write=False)
103
+
104
+ async def run() -> None:
105
+ from dal.embeddings import ego_frame_search as ego_search_dal
106
+
107
+ async with get_cli_context(settings, profile=(ctx.obj or {}).get("cli_profile")) as (
108
+ _db,
109
+ dal_ctx,
110
+ ):
111
+ rows = await ego_search_dal.list_corpora(dal_ctx)
112
+ render(rows, format=format)
113
+
114
+ asyncio.run(run())
115
+
116
+
117
+ @app.command("membership-program")
118
+ def membership_program(
119
+ ctx: typer.Context,
120
+ corpus_key: str = typer.Option(..., "--corpus-key", help="Target corpus key."),
121
+ program_id: list[UUID] = typer.Option(..., "--program-id", help="Program id to include."),
122
+ run_id: str = typer.Option("manual", "--run-id", help="Membership materialization run id."),
123
+ limit: int | None = typer.Option(None, "--limit", min=1, help="Cap candidate clips."),
124
+ write: bool = typer.Option(False, "--write", help="Insert membership rows."),
125
+ format: Format = format_option(),
126
+ ) -> None:
127
+ """Dry-run or materialize corpus membership from program-source clips."""
128
+ _require_internal_admin_for_write(ctx, write=write)
129
+ settings = _settings_for_command(ctx, write=write)
130
+
131
+ async def run() -> None:
132
+ from dal.embeddings import ego_frame_search as ego_search_dal
133
+
134
+ async with get_cli_context(settings, profile=(ctx.obj or {}).get("cli_profile")) as (
135
+ _db,
136
+ dal_ctx,
137
+ ):
138
+ summary = await ego_search_dal.materialize_program_membership(
139
+ dal_ctx,
140
+ corpus_key=corpus_key,
141
+ program_ids=program_id,
142
+ membership_run_id=run_id,
143
+ limit=limit,
144
+ write=write,
145
+ )
146
+ render(_plain(summary), format=format)
147
+
148
+ asyncio.run(run())
149
+
150
+
151
+ @app.command("membership-gigcamera")
152
+ def membership_gigcamera(
153
+ ctx: typer.Context,
154
+ corpus_key: str = typer.Option("gigcamera", "--corpus-key", help="Target corpus key."),
155
+ run_id: str = typer.Option("manual", "--run-id", help="Membership materialization run id."),
156
+ limit: int | None = typer.Option(None, "--limit", min=1, help="Cap candidate clips."),
157
+ write: bool = typer.Option(False, "--write", help="Insert membership rows."),
158
+ format: Format = format_option(),
159
+ ) -> None:
160
+ """Dry-run or materialize non-duplicate GigCamera corpus membership."""
161
+ _require_internal_admin_for_write(ctx, write=write)
162
+ settings = _settings_for_command(ctx, write=write)
163
+
164
+ async def run() -> None:
165
+ from dal.embeddings import ego_frame_search as ego_search_dal
166
+
167
+ async with get_cli_context(settings, profile=(ctx.obj or {}).get("cli_profile")) as (
168
+ _db,
169
+ dal_ctx,
170
+ ):
171
+ summary = await ego_search_dal.materialize_gigcamera_membership(
172
+ dal_ctx,
173
+ corpus_key=corpus_key,
174
+ membership_run_id=run_id,
175
+ limit=limit,
176
+ write=write,
177
+ )
178
+ render(_plain(summary), format=format)
179
+
180
+ asyncio.run(run())
181
+
182
+
183
+ @app.command("coverage")
184
+ def coverage(
185
+ ctx: typer.Context,
186
+ corpus_key: str = typer.Option(..., "--corpus-key", help="Corpus key to inspect."),
187
+ space_id: int | None = typer.Option(None, "--space-id", help="Target embedding space id."),
188
+ space_key: str | None = typer.Option(
189
+ "qwen.frame.global.ego.default",
190
+ "--space-key",
191
+ help="Target embedding space key.",
192
+ ),
193
+ selection_kind: str = typer.Option("near_90s", "--selection-kind"),
194
+ refresh: bool = typer.Option(False, "--refresh", help="Upsert readiness counts."),
195
+ route_ready: bool = typer.Option(False, "--route-ready", help="Mark routing verified."),
196
+ smoke_passed: bool = typer.Option(False, "--smoke-passed", help="Mark smoke benchmark passed."),
197
+ write: bool = typer.Option(False, "--write", help="Apply readiness refresh."),
198
+ format: Format = format_option(),
199
+ ) -> None:
200
+ """Inspect or refresh corpus coverage/readiness for a target space."""
201
+ write_requested = write or refresh
202
+ _require_internal_admin_for_write(ctx, write=write_requested)
203
+ settings = _settings_for_command(ctx, write=write_requested)
204
+
205
+ async def run() -> None:
206
+ from dal.embeddings import ego_frame_search as ego_search_dal
207
+
208
+ async with get_cli_context(settings, profile=(ctx.obj or {}).get("cli_profile")) as (
209
+ _db,
210
+ dal_ctx,
211
+ ):
212
+ target_space_id = await _resolve_space_id(
213
+ dal_ctx,
214
+ space_id=space_id,
215
+ space_key=space_key,
216
+ )
217
+ if refresh:
218
+ summary = await ego_search_dal.refresh_corpus_readiness(
219
+ dal_ctx,
220
+ corpus_key=corpus_key,
221
+ target_space_id=target_space_id,
222
+ selection_kind=selection_kind,
223
+ route_ready=route_ready,
224
+ smoke_passed=smoke_passed,
225
+ )
226
+ else:
227
+ summary = await ego_search_dal.compute_corpus_coverage(
228
+ dal_ctx,
229
+ corpus_key=corpus_key,
230
+ target_space_id=target_space_id,
231
+ selection_kind=selection_kind,
232
+ )
233
+ render(_plain(summary), format=format)
234
+
235
+ asyncio.run(run())
236
+
237
+
238
+ @app.command("missing-vectors")
239
+ def missing_vectors(
240
+ ctx: typer.Context,
241
+ corpus_key: str = typer.Option(..., "--corpus-key", help="Corpus key to inspect."),
242
+ space_id: int | None = typer.Option(None, "--space-id", help="Target embedding space id."),
243
+ space_key: str | None = typer.Option("qwen.frame.global.ego.default", "--space-key"),
244
+ selection_kind: str = typer.Option("near_90s", "--selection-kind"),
245
+ format: Format = format_option(),
246
+ ) -> None:
247
+ """Count sampled corpus frames that still need embeddings."""
248
+ settings = _settings_for_command(ctx, write=False)
249
+
250
+ async def run() -> None:
251
+ from dal.embeddings import ego_frame_search as ego_search_dal
252
+
253
+ async with get_cli_context(settings, profile=(ctx.obj or {}).get("cli_profile")) as (
254
+ _db,
255
+ dal_ctx,
256
+ ):
257
+ target_space_id = await _resolve_space_id(
258
+ dal_ctx,
259
+ space_id=space_id,
260
+ space_key=space_key,
261
+ )
262
+ count = await ego_search_dal.count_missing_vectors(
263
+ dal_ctx,
264
+ corpus_key=corpus_key,
265
+ target_space_id=target_space_id,
266
+ selection_kind=selection_kind,
267
+ )
268
+ render(
269
+ {
270
+ "corpus_key": corpus_key,
271
+ "target_space_id": target_space_id,
272
+ "selection_kind": selection_kind,
273
+ "missing_vector_count": count,
274
+ },
275
+ format=format,
276
+ )
277
+
278
+ asyncio.run(run())
279
+
280
+
281
+ @app.command("missing-demian-labels")
282
+ def missing_demian_labels(
283
+ ctx: typer.Context,
284
+ corpus_key: str = typer.Option(..., "--corpus-key", help="Corpus key to inspect."),
285
+ selection_kind: str = typer.Option("near_90s", "--selection-kind"),
286
+ observation_kind: str = typer.Option("demian_frame_label_v1", "--observation-kind"),
287
+ format: Format = format_option(),
288
+ ) -> None:
289
+ """Count sampled corpus frames missing DeMiAn labels."""
290
+ settings = _settings_for_command(ctx, write=False)
291
+
292
+ async def run() -> None:
293
+ from dal.embeddings import ego_frame_search as ego_search_dal
294
+
295
+ async with get_cli_context(settings, profile=(ctx.obj or {}).get("cli_profile")) as (
296
+ _db,
297
+ dal_ctx,
298
+ ):
299
+ count = await ego_search_dal.count_missing_demian_labels(
300
+ dal_ctx,
301
+ corpus_key=corpus_key,
302
+ selection_kind=selection_kind,
303
+ observation_kind=observation_kind,
304
+ )
305
+ render(
306
+ {
307
+ "corpus_key": corpus_key,
308
+ "selection_kind": selection_kind,
309
+ "observation_kind": observation_kind,
310
+ "missing_demian_label_count": count,
311
+ },
312
+ format=format,
313
+ )
314
+
315
+ asyncio.run(run())
316
+
317
+
318
+ @app.command("copy-plan")
319
+ def copy_plan(
320
+ ctx: typer.Context,
321
+ source_space_id: int = typer.Option(..., "--source-space-id"),
322
+ target_space_id: int | None = typer.Option(None, "--target-space-id"),
323
+ target_space_key: str | None = typer.Option(
324
+ "qwen.frame.global.ego.default", "--target-space-key"
325
+ ),
326
+ selection_kind: str = typer.Option("near_90s", "--selection-kind"),
327
+ format: Format = format_option(),
328
+ ) -> None:
329
+ """Inspect how many legacy vectors can be copied safely."""
330
+ settings = _settings_for_command(ctx, write=False)
331
+
332
+ async def run() -> None:
333
+ from dal.embeddings import ego_frame_search as ego_search_dal
334
+
335
+ async with get_cli_context(settings, profile=(ctx.obj or {}).get("cli_profile")) as (
336
+ _db,
337
+ dal_ctx,
338
+ ):
339
+ resolved_target_space_id = await _resolve_space_id(
340
+ dal_ctx,
341
+ space_id=target_space_id,
342
+ space_key=target_space_key,
343
+ )
344
+ plan = await ego_search_dal.plan_vector_copy(
345
+ dal_ctx,
346
+ source_space_id=source_space_id,
347
+ target_space_id=resolved_target_space_id,
348
+ selection_kind=selection_kind,
349
+ )
350
+ render(_plain(plan), format=format)
351
+
352
+ asyncio.run(run())
353
+
354
+
355
+ @app.command("copy-vectors")
356
+ def copy_vectors(
357
+ ctx: typer.Context,
358
+ source_space_id: int = typer.Option(..., "--source-space-id"),
359
+ target_space_id: int | None = typer.Option(None, "--target-space-id"),
360
+ target_space_key: str | None = typer.Option(
361
+ "qwen.frame.global.ego.default", "--target-space-key"
362
+ ),
363
+ selection_kind: str = typer.Option("near_90s", "--selection-kind"),
364
+ run_id: str = typer.Option(..., "--run-id", help="Operator copy run id."),
365
+ limit: int | None = typer.Option(None, "--limit", min=1, help="Cap copied vectors."),
366
+ write: bool = typer.Option(False, "--write", help="Insert copied vector rows."),
367
+ format: Format = format_option(),
368
+ ) -> None:
369
+ """Copy compatible legacy vectors without overwriting target rows."""
370
+ _require_internal_admin_for_write(ctx, write=write)
371
+ settings = _settings_for_command(ctx, write=write)
372
+
373
+ async def run() -> None:
374
+ from dal.embeddings import ego_frame_search as ego_search_dal
375
+ from dal.embeddings import spaces as spaces_dal
376
+
377
+ async with get_cli_context(settings, profile=(ctx.obj or {}).get("cli_profile")) as (
378
+ _db,
379
+ dal_ctx,
380
+ ):
381
+ resolved_target_space_id = await _resolve_space_id(
382
+ dal_ctx,
383
+ space_id=target_space_id,
384
+ space_key=target_space_key,
385
+ )
386
+ source_space = await spaces_dal.get(dal_ctx, source_space_id)
387
+ target_space = await spaces_dal.get(dal_ctx, resolved_target_space_id)
388
+ compatibility = ego_search_dal.compare_spaces(
389
+ source=source_space,
390
+ target=target_space,
391
+ )
392
+ if not compatibility.compatible:
393
+ raise typer.BadParameter("; ".join(compatibility.reasons))
394
+ plan = await ego_search_dal.plan_vector_copy(
395
+ dal_ctx,
396
+ source_space_id=source_space_id,
397
+ target_space_id=resolved_target_space_id,
398
+ selection_kind=selection_kind,
399
+ )
400
+ inserted = 0
401
+ if write:
402
+ inserted = await ego_search_dal.copy_compatible_vectors(
403
+ dal_ctx,
404
+ source_space_id=source_space_id,
405
+ target_space_id=resolved_target_space_id,
406
+ selection_kind=selection_kind,
407
+ copy_run_id=run_id,
408
+ limit=limit,
409
+ )
410
+ render(
411
+ {
412
+ "write": write,
413
+ "inserted": inserted,
414
+ "compatibility": _plain(compatibility),
415
+ "plan": _plain(plan),
416
+ },
417
+ format=format,
418
+ )
419
+
420
+ asyncio.run(run())
421
+
422
+
423
+ @app.command("build-scann")
424
+ def build_scann(
425
+ ctx: typer.Context,
426
+ space_id: int | None = typer.Option(None, "--space-id", help="Target embedding space id."),
427
+ space_key: str | None = typer.Option("qwen.frame.global.ego.default", "--space-key"),
428
+ strategy: str = typer.Option("balanced", "--strategy", help="speed, balanced, or quality."),
429
+ quantizer: str = typer.Option("SQ8", "--quantizer", help="SQ8, FLAT, or AH."),
430
+ write: bool = typer.Option(False, "--write", help="Create btree/ANALYZE/ScaNN."),
431
+ format: Format = format_option(),
432
+ ) -> None:
433
+ """Analyze a partition and create its ScaNN index after bulk load."""
434
+ _require_internal_admin_for_write(ctx, write=write)
435
+ settings = _settings_for_command(ctx, write=write)
436
+
437
+ async def run() -> None:
438
+ from dal.embeddings import indexes as index_dal
439
+
440
+ async with get_cli_context(settings, profile=(ctx.obj or {}).get("cli_profile")) as (
441
+ _db,
442
+ dal_ctx,
443
+ ):
444
+ target_space_id = await _resolve_space_id(
445
+ dal_ctx,
446
+ space_id=space_id,
447
+ space_key=space_key,
448
+ )
449
+ info = await index_dal.create_scann_index(
450
+ dal_ctx,
451
+ target_space_id,
452
+ strategy=strategy, # type: ignore[arg-type]
453
+ quantizer=quantizer, # type: ignore[arg-type]
454
+ dry_run=not write,
455
+ )
456
+ render(info, format=format)
457
+
458
+ asyncio.run(run())
459
+
460
+
461
+ @app.command("queue-missing-embeddings")
462
+ def queue_missing_embeddings(
463
+ ctx: typer.Context,
464
+ corpus_key: list[str] = typer.Option(..., "--corpus-key", help="Corpus key. Repeatable."),
465
+ space_id: int | None = typer.Option(None, "--space-id", help="Target embedding space id."),
466
+ space_key: str | None = typer.Option("qwen.frame.global.ego.default", "--space-key"),
467
+ selection_kind: str = typer.Option("near_90s", "--selection-kind"),
468
+ limit: int | None = typer.Option(None, "--limit", min=1, help="Cap selected sampled frames."),
469
+ idempotency_key: str = typer.Option(..., "--idempotency-key", help="Manifest idempotency key."),
470
+ cost_cap_usd: float | None = typer.Option(None, "--cost-cap-usd", min=0.0),
471
+ write: bool = typer.Option(False, "--write", help="Create the processing manifest."),
472
+ format: Format = format_option(),
473
+ ) -> None:
474
+ """Queue a capped missing-only embed_frames manifest from corpus membership."""
475
+ _require_internal_admin_for_write(ctx, write=write)
476
+ settings = _settings_for_command(ctx, write=write)
477
+
478
+ async def run() -> None:
479
+ from dal.processing import media_jobs
480
+
481
+ async with get_cli_context(settings, profile=(ctx.obj or {}).get("cli_profile")) as (
482
+ _db,
483
+ dal_ctx,
484
+ ):
485
+ target_space_id = await _resolve_space_id(
486
+ dal_ctx,
487
+ space_id=space_id,
488
+ space_key=space_key,
489
+ )
490
+ selection_spec = {
491
+ "kind": "by_corpus",
492
+ "corpus_keys": corpus_key,
493
+ "selection_kind": selection_kind,
494
+ "limit": limit,
495
+ "only_missing_vectors": True,
496
+ }
497
+ sink_overrides = {"embedding": {"space_id": target_space_id}}
498
+ resource_overrides = {"cost_cap_usd": cost_cap_usd} if cost_cap_usd is not None else {}
499
+ if write:
500
+ manifest = await media_jobs.queue_processor_job(
501
+ dal_ctx,
502
+ processor_ref=media_jobs.EMBED_FRAMES_PROCESSOR_REF,
503
+ selection_spec=selection_spec,
504
+ sink_overrides=sink_overrides,
505
+ resource_overrides=resource_overrides,
506
+ submitted_by_principal=_SUBMITTED_BY,
507
+ idempotency_key=idempotency_key,
508
+ )
509
+ else:
510
+ manifest = {
511
+ "dry_run": True,
512
+ "processor_ref": media_jobs.EMBED_FRAMES_PROCESSOR_REF,
513
+ "selection_spec": selection_spec,
514
+ "sink_overrides": sink_overrides,
515
+ "resource_overrides": resource_overrides,
516
+ }
517
+ render(manifest, format=format)
518
+
519
+ asyncio.run(run())
520
+
521
+
522
+ @app.command("queue-missing-sampled-frames")
523
+ def queue_missing_sampled_frames(
524
+ ctx: typer.Context,
525
+ corpus_key: list[str] = typer.Option(..., "--corpus-key", help="Corpus key. Repeatable."),
526
+ selection_kind: str = typer.Option("near_90s", "--selection-kind"),
527
+ limit: int | None = typer.Option(None, "--limit", min=1, help="Cap selected clips."),
528
+ idempotency_key: str = typer.Option(..., "--idempotency-key", help="Manifest idempotency key."),
529
+ write: bool = typer.Option(False, "--write", help="Create the processing manifest."),
530
+ format: Format = format_option(),
531
+ ) -> None:
532
+ """Queue extraction for corpus clips missing the sampled-frame anchor."""
533
+ _require_internal_admin_for_write(ctx, write=write)
534
+ settings = _settings_for_command(ctx, write=write)
535
+
536
+ async def run() -> None:
537
+ from dal.processing import media_jobs
538
+
539
+ async with get_cli_context(settings, profile=(ctx.obj or {}).get("cli_profile")) as (
540
+ _db,
541
+ dal_ctx,
542
+ ):
543
+ selection_spec = {
544
+ "kind": "by_corpus",
545
+ "corpus_keys": corpus_key,
546
+ "selection_kind": selection_kind,
547
+ "limit": limit,
548
+ "only_missing_sampled_frames": True,
549
+ "require_zero_frames": False,
550
+ }
551
+ if write:
552
+ manifest = await media_jobs.queue_processor_job(
553
+ dal_ctx,
554
+ processor_ref=media_jobs.EXTRACT_FRAMES_PROCESSOR_REF,
555
+ selection_spec=selection_spec,
556
+ sink_overrides=None,
557
+ resource_overrides=None,
558
+ submitted_by_principal=_SUBMITTED_BY,
559
+ idempotency_key=idempotency_key,
560
+ )
561
+ else:
562
+ manifest = {
563
+ "dry_run": True,
564
+ "processor_ref": media_jobs.EXTRACT_FRAMES_PROCESSOR_REF,
565
+ "selection_spec": selection_spec,
566
+ }
567
+ render(manifest, format=format)
568
+
569
+ asyncio.run(run())
@@ -0,0 +1,173 @@
1
+ """BuildSpec wrapper commands for the Build AI CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ import typer
11
+
12
+ from cli.console import error, info
13
+
14
+
15
+ def _repo_root() -> Path | None:
16
+ """Return the current Build AI repo root when running from a checkout."""
17
+
18
+ current = Path(__file__).resolve()
19
+ for parent in current.parents:
20
+ if (parent / ".git").exists() and (parent / "buildspec").exists():
21
+ return parent
22
+ cwd = Path.cwd().resolve()
23
+ for parent in (cwd, *cwd.parents):
24
+ if (parent / ".git").exists() and (parent / "buildspec").exists():
25
+ return parent
26
+ return None
27
+
28
+
29
+ def _candidate_commands(repo_root: Path | None) -> list[list[str]]:
30
+ """Return BuildSpec engine command candidates in preference order.
31
+
32
+ Order: an explicit ``BUILDSPEC_BIN`` override, then the engine vendored
33
+ in-repo at ``tools/buildspec/`` (the default for everyone after a plain
34
+ ``git pull``), then a sibling ``../buildspec`` dev clone, then a ``buildspec``
35
+ binary on PATH.
36
+ """
37
+
38
+ candidates: list[list[str]] = []
39
+ node = shutil.which("node")
40
+
41
+ env_bin = os.environ.get("BUILDSPEC_BIN")
42
+ if env_bin:
43
+ candidates.append([env_bin])
44
+
45
+ if repo_root is not None and node:
46
+ vendored = repo_root / "tools" / "buildspec" / "bin" / "buildspec.js"
47
+ if vendored.exists():
48
+ candidates.append([node, str(vendored)])
49
+
50
+ sibling = repo_root.parent / "buildspec" / "bin" / "buildspec.js"
51
+ if sibling.exists():
52
+ candidates.append([node, str(sibling)])
53
+
54
+ path_bin = shutil.which("buildspec")
55
+ if path_bin:
56
+ candidates.append([path_bin])
57
+
58
+ # De-duplicate while preserving order.
59
+ seen: set[tuple[str, ...]] = set()
60
+ unique: list[list[str]] = []
61
+ for cmd in candidates:
62
+ key = tuple(cmd)
63
+ if key not in seen:
64
+ seen.add(key)
65
+ unique.append(cmd)
66
+ return unique
67
+
68
+
69
+ def _resolve_buildspec(repo_root: Path | None) -> list[str]:
70
+ for cmd in _candidate_commands(repo_root):
71
+ exe = cmd[0]
72
+ if Path(exe).exists() or shutil.which(exe):
73
+ return cmd
74
+ raise RuntimeError(
75
+ "BuildSpec engine not found. Set BUILDSPEC_BIN, install `buildspec`, "
76
+ "or clone build-ai-ego/buildspec next to the build-ai repo."
77
+ )
78
+
79
+
80
+ CONTEXT_SETTINGS = {"allow_extra_args": True, "ignore_unknown_options": True}
81
+
82
+
83
+ def run_buildspec(args: list[str]) -> int:
84
+ """Run BuildSpec in the repo root with the provided arguments."""
85
+
86
+ repo_root = _repo_root()
87
+ if repo_root is None:
88
+ raise RuntimeError("Run `buildai spec` from the Build AI repo checkout.")
89
+
90
+ command = _resolve_buildspec(repo_root)
91
+ env = os.environ.copy()
92
+ env.setdefault("BUILDSPEC_PROJECT_ROOT", str(repo_root))
93
+
94
+ result = subprocess.run([*command, *args], cwd=repo_root, env=env)
95
+ return result.returncode
96
+
97
+
98
+ def doctor() -> int:
99
+ """Diagnose the BuildSpec engine integration: node, engine resolution,
100
+ project schema, and end-to-end change listing. Prints a report and returns
101
+ a non-zero exit code when anything is unhealthy."""
102
+
103
+ healthy = True
104
+ repo_root = _repo_root()
105
+ if repo_root is None:
106
+ error("Run `buildai spec doctor` from the Build AI repo checkout.")
107
+ return 1
108
+
109
+ node = shutil.which("node")
110
+ info(f"node: {node or 'NOT FOUND — install Node >= 20.19'}")
111
+ healthy = healthy and node is not None
112
+
113
+ try:
114
+ command = _resolve_buildspec(repo_root)
115
+ info(f"engine: {' '.join(command)}")
116
+ except RuntimeError as exc:
117
+ error(f"engine: NOT RESOLVED — {exc}")
118
+ return 1
119
+
120
+ vendored_dir = repo_root / "tools" / "buildspec"
121
+ if str(vendored_dir) in " ".join(command):
122
+ source = vendored_dir / "SOURCE"
123
+ if source.exists():
124
+ info("vendored: " + source.read_text(encoding="utf-8").strip().replace("\n", "; "))
125
+ if not (vendored_dir / "node_modules").exists():
126
+ error("vendored engine deps missing — run `make buildspec-setup`")
127
+ healthy = False
128
+
129
+ env = os.environ.copy()
130
+ env.setdefault("BUILDSPEC_PROJECT_ROOT", str(repo_root))
131
+
132
+ schema = subprocess.run(
133
+ [*command, "schema", "which", "build-ai-change"],
134
+ cwd=repo_root,
135
+ env=env,
136
+ capture_output=True,
137
+ text=True,
138
+ )
139
+ project = "Source: project" in (schema.stdout + schema.stderr)
140
+ info(f"schema build-ai-change: {'project ✓' if project else 'NOT project ✗'}")
141
+ healthy = healthy and project
142
+
143
+ listing = subprocess.run(
144
+ [*command, "list"], cwd=repo_root, env=env, capture_output=True, text=True
145
+ )
146
+ info(f"engine list: {'ok ✓' if listing.returncode == 0 else 'FAILED ✗'}")
147
+ healthy = healthy and listing.returncode == 0
148
+
149
+ info(f"buildai spec doctor: {'OK' if healthy else 'PROBLEMS'}")
150
+ return 0 if healthy else 1
151
+
152
+
153
+ def spec_command(
154
+ ctx: typer.Context,
155
+ args: list[str] | None = typer.Argument(
156
+ None,
157
+ help="Arguments passed through to BuildSpec, e.g. `list --specs` or `validate <change> --strict`. Use `doctor` to diagnose the engine integration.",
158
+ ),
159
+ ) -> None:
160
+ """Run BuildSpec against this repo's `buildspec/` directory."""
161
+
162
+ forwarded = list(args or []) + list(ctx.args)
163
+ if forwarded and forwarded[0] == "doctor":
164
+ raise typer.Exit(code=doctor())
165
+ if not forwarded:
166
+ forwarded = ["--help"]
167
+ try:
168
+ code = run_buildspec(forwarded)
169
+ except RuntimeError as exc:
170
+ error(str(exc))
171
+ info("Try: BUILDSPEC_BIN=/path/to/buildspec buildai spec list")
172
+ raise typer.Exit(code=1) from exc
173
+ raise typer.Exit(code=code)
@@ -20,6 +20,8 @@ from cli.commands.dev import app as dev_app
20
20
  from cli.commands.doctor import app as doctor_app
21
21
  from cli.commands.egoexo import app as egoexo_app
22
22
  from cli.commands.processing import app as processing_app
23
+ from cli.commands.spec import CONTEXT_SETTINGS as SPEC_CONTEXT_SETTINGS
24
+ from cli.commands.spec import spec_command
23
25
  from cli.console import error, info, set_verbose, success, warning
24
26
 
25
27
 
@@ -274,6 +276,7 @@ app.add_typer(dev_app, name="dev")
274
276
  app.add_typer(doctor_app, name="doctor")
275
277
  app.add_typer(egoexo_app, name="egoexo")
276
278
  app.add_typer(processing_app, name="processing")
279
+ app.command("spec", context_settings=SPEC_CONTEXT_SETTINGS)(spec_command)
277
280
 
278
281
 
279
282
  def db_callback(
@@ -329,11 +332,13 @@ def _has_ingest_workspace() -> bool:
329
332
 
330
333
  if has_core():
331
334
  from cli.commands.db import app as db_app
335
+ from cli.commands.ego_frame_search import app as ego_frame_search_app
332
336
  from cli.commands.gigcamera import app as gigcamera_app
333
337
  from cli.commands.grid import app as grid_app
334
338
 
335
339
  db_app.callback()(db_callback)
336
340
  app.add_typer(db_app, name="db")
341
+ app.add_typer(ego_frame_search_app, name="ego-frame-search")
337
342
  app.add_typer(gigcamera_app, name="gigcamera")
338
343
  app.add_typer(grid_app, name="grid")
339
344
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "buildai-cli"
7
- version = "0.3.87"
7
+ version = "0.3.89"
8
8
  description = "Build AI CLI (Typer)"
9
9
  requires-python = ">=3.11"
10
10
  dependencies = [
@@ -1 +0,0 @@
1
- CLAUDE.md
@@ -1,47 +0,0 @@
1
- # BuildAI CLI
2
-
3
- Typer-based CLI with two modes: standalone (PyPI, API-backed) and workspace (repo-local, DB-direct).
4
-
5
- ## Two Planes
6
-
7
- | Mode | Install | Auth | DB Access |
8
- |------|---------|------|-----------|
9
- | Standalone (`buildai`) | `uv tool install buildai-cli` | API key / JWT | Through API |
10
- | Workspace (`uv run buildai`) | Repo-local editable install | IAM / password | Direct via `db`, through the profile-keyed local broker |
11
-
12
- ## Key Commands
13
-
14
- ```bash
15
- buildai auth whoami # API auth inspection
16
- uv run buildai ingest deploy plan --scope active # Dry-run ingest fleet plan
17
- uv run buildai ingest deploy run --scope station:2 # Staged ingest rollout
18
- uv run buildai ingest fleet readiness --scope active # Host readiness gate
19
- buildai db query "SELECT count(*) FROM core.clips" # DB-direct
20
- buildai db --env staging tunnel # IAP/SOCKS route for private staging DB
21
- buildai db --env staging --all-proxy socks5://127.0.0.1:1080 query "SELECT 1" # staging query
22
- buildai db broker status # Active local DB broker state
23
- buildai db schema tables # Schema introspection
24
- buildai db schema describe core.clips # Table details
25
- buildai db --write migrate all # Run migrations
26
- buildai db status # Migration status
27
- ```
28
-
29
- ## Guards
30
-
31
- - `db` subcommands require workspace install + gcloud IAM.
32
- - `db` inspection/migration commands use a profile-keyed `alloydb-auth-proxy`
33
- broker owned by the local machine, with state in `~/.buildai/db-brokers` so
34
- all worktrees reuse the same listener; use `buildai db broker status|ensure|stop`
35
- for that local transport.
36
- - Writes require `--write` flag.
37
- - Production migrations prompt for confirmation.
38
- - Worktree app/runtime targeting comes from explicit env vars and the repo Makefile, not a saved CLI context layer.
39
- - `buildai db --env` selects the explicit DB lane: `production`, `staging`, `dev`.
40
- - Staging is private-IP only. Start an IAP/VPN/SOCKS route first, then pass it
41
- with `--all-proxy` so the local AlloyDB broker can reach the private address.
42
- The standard route is `buildai db --env staging tunnel`.
43
-
44
- ## Reference
45
-
46
- CLI mode guide: docs site Engineering section (run `make docs`)
47
- Database roles: docs site Operations section (run `make docs`)
File without changes
File without changes
File without changes