frontier-runner 0.2.2__tar.gz → 0.2.3__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 (80) hide show
  1. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/CHANGELOG.md +8 -0
  2. {frontier_runner-0.2.2/frontier_runner.egg-info → frontier_runner-0.2.3}/PKG-INFO +35 -9
  3. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/README.md +34 -8
  4. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/__init__.py +1 -1
  5. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/cli.py +217 -55
  6. frontier_runner-0.2.3/frontier/context.py +653 -0
  7. frontier_runner-0.2.3/frontier/credentials.py +411 -0
  8. frontier_runner-0.2.3/frontier/identity.py +137 -0
  9. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/onboard/commands.py +123 -23
  10. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/onboard/constants.py +1 -0
  11. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/onboard/doctor.py +135 -11
  12. frontier_runner-0.2.3/frontier/onboard/profile_commands.py +408 -0
  13. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/onboard/saas.py +7 -3
  14. frontier_runner-0.2.3/frontier/profiles.py +319 -0
  15. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/warehouse.py +5 -1
  16. {frontier_runner-0.2.2 → frontier_runner-0.2.3/frontier_runner.egg-info}/PKG-INFO +35 -9
  17. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier_runner.egg-info/SOURCES.txt +4 -0
  18. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/pyproject.toml +1 -1
  19. frontier_runner-0.2.2/frontier/credentials.py +0 -239
  20. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/LICENSE +0 -0
  21. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/MANIFEST.in +0 -0
  22. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/__main__.py +0 -0
  23. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/adapters/__init__.py +0 -0
  24. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/adapters/base.py +0 -0
  25. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/adapters/bigquery.py +0 -0
  26. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/adapters/databricks.py +0 -0
  27. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/adapters/postgres.py +0 -0
  28. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/adapters/redshift.py +0 -0
  29. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/adapters/snowflake.py +0 -0
  30. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/api.py +0 -0
  31. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/artifacts.py +0 -0
  32. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/cdc/__init__.py +0 -0
  33. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/cdc/config.py +0 -0
  34. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/cdc/consume.py +0 -0
  35. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/cdc/normalize.py +0 -0
  36. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/cdc/prove.py +0 -0
  37. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/cdc/route.py +0 -0
  38. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/cdc/store.py +0 -0
  39. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/cdc/streams.py +0 -0
  40. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/cdc/upload.py +0 -0
  41. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/certification.py +0 -0
  42. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/comment.py +0 -0
  43. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/compare.py +0 -0
  44. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/config.py +0 -0
  45. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/dbt_artifacts.py +0 -0
  46. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/environment.py +0 -0
  47. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/errors.py +0 -0
  48. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/execute.py +0 -0
  49. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/filter_v1.py +0 -0
  50. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/frontier.py +0 -0
  51. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/github.py +0 -0
  52. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/hashing.py +0 -0
  53. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/impact.py +0 -0
  54. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/local_config.py +0 -0
  55. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/mutation.py +0 -0
  56. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/onboard/__init__.py +0 -0
  57. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/onboard/demo.py +0 -0
  58. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/onboard/detect.py +0 -0
  59. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/onboard/discover.py +0 -0
  60. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/onboard/github.py +0 -0
  61. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/onboard/gitignore.py +0 -0
  62. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/onboard/hashkey.py +0 -0
  63. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/onboard/permissions.py +0 -0
  64. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/onboard/prompt.py +0 -0
  65. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/onboard/routes.py +0 -0
  66. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/onboard/versions.py +0 -0
  67. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/progress.py +0 -0
  68. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/proof.py +0 -0
  69. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/repair.py +0 -0
  70. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/semantic.py +0 -0
  71. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/snapshot.py +0 -0
  72. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/snowflake.py +0 -0
  73. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/snowflake_sql.py +0 -0
  74. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/sql_fingerprint.py +0 -0
  75. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier/validation.py +0 -0
  76. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier_runner.egg-info/dependency_links.txt +0 -0
  77. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier_runner.egg-info/entry_points.txt +0 -0
  78. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier_runner.egg-info/requires.txt +0 -0
  79. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/frontier_runner.egg-info/top_level.txt +0 -0
  80. {frontier_runner-0.2.2 → frontier_runner-0.2.3}/setup.cfg +0 -0
@@ -5,6 +5,13 @@ All notable changes to frontier-runner are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.2.3] — 2026-09-27
11
+
12
+ - Named Frontier profiles: local execution contexts that bind a SaaS project credential, API origin, optional dbt target, and optional profiles.yml path without replacing `dbt_project.yml` `profile:`.
13
+ - Named-profile identity uses immutable SaaS organization and project IDs from `whoami`. Names are display-only. Cross-project upload and ingest compare IDs, not names.
14
+
8
15
  ## [0.2.2] — 2026-09-22
9
16
 
10
17
  - Disposable repair validation is the authoritative repaired-vs-head check. The unchanged materialized mart versus head comparison is an expected pre-repair difference and no longer fails the run.
@@ -100,6 +107,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
100
107
  - SaaS commands resolve stored keychain credentials through one shared
101
108
  resolver instead of requiring `FRONTIER_API_KEY` in the environment.
102
109
 
110
+ [0.2.3]: https://github.com/jadsamara/frontier-runner/compare/v0.2.2...v0.2.3
103
111
  [0.2.2]: https://github.com/jadsamara/frontier-runner/compare/v0.2.1...v0.2.2
104
112
  [0.2.1]: https://github.com/jadsamara/frontier-runner/compare/v0.2.0...v0.2.1
105
113
  [0.2.0]: https://github.com/jadsamara/frontier-runner/compare/v0.1.3...v0.2.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: frontier-runner
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Customer-side Frontier CLI for dbt + Snowflake or BigQuery + GitHub impact assessments.
5
5
  Author: Frontier
6
6
  License: MIT License
@@ -92,7 +92,7 @@ Until the package is on PyPI, install the GitHub Release wheel for a version
92
92
  tag. You do not need a commit SHA:
93
93
 
94
94
  ```bash
95
- pipx install \ "frontier-runner[snowflake] @ https://github.com/jadsamara/frontier-runner/releases/download/v0.2.2/frontier_runner-0.2.2-py3-none-any.whl"
95
+ pipx install \ "frontier-runner[snowflake] @ https://github.com/jadsamara/frontier-runner/releases/download/v0.2.3/frontier_runner-0.2.3-py3-none-any.whl"
96
96
  pipx inject frontier-runner "snowflake-connector-python>=3.12,<4"
97
97
  ```
98
98
 
@@ -119,6 +119,31 @@ API keys are stored in the OS keychain (or `~/.config/frontier/credentials`
119
119
  mode 0600). They are never written to `frontier.yml`, `dbt_project.yml`,
120
120
  `profiles.yml`, Git, or generated workflows.
121
121
 
122
+ ## Frontier profiles
123
+
124
+ A **Frontier profile** is a local named execution context. It selects one
125
+ Frontier SaaS project credential and may default the dbt target and
126
+ `profiles.yml` path. It does **not** replace the dbt `profile:` in
127
+ `dbt_project.yml`. A **dbt target** is an output such as `dev`, `ci`, or
128
+ `benchmark`.
129
+
130
+ ```bash
131
+ frontier profile create repair --target dev --api-key
132
+ frontier profile create benchmark --target benchmark --api-key
133
+ frontier profile use benchmark
134
+ frontier auth status
135
+ frontier doctor
136
+ ```
137
+
138
+ `--api-key` prompts securely; never pass the raw key as an argument.
139
+ Selection is gitignored in `.frontier/state.yml` and is local to this
140
+ repository. GitHub Actions ignores that file unless `FRONTIER_PROFILE` or
141
+ `--profile` is set; CI continues to use `FRONTIER_API_KEY` /
142
+ `FRONTIER_API_URL` repository secrets. `frontier upload` refuses a run
143
+ generated for another origin, organization, project, dbt project, target, or
144
+ semantic manifest (`ARTIFACT_PROFILE_MISMATCH`). Legacy login without a
145
+ named profile is unchanged.
146
+
122
147
  ## Developer setup
123
148
 
124
149
  From this repository:
@@ -200,11 +225,12 @@ assessment.
200
225
 
201
226
  `frontier upload` posts `target/frontier-run.json` to `POST /api/v1/runs`. It
202
227
  retries HTTP 429/5xx and network errors, and honors `Retry-After`. SaaS
203
- commands resolve credentials in this order: `FRONTIER_API_KEY`, the OS
204
- keychain, the `0600` fallback file, then `FRONTIER_DEMO_API_KEY` only when
205
- `FRONTIER_ALLOW_LOCAL_MANIFEST` is set outside GitHub Actions. If none are
206
- present, the CLI exits with `AUTH_REQUIRED: Run \`frontier login --api-key\``.
207
- Hashed uploads set `entityIdsHashed: true`.
228
+ commands resolve credentials in this order: `FRONTIER_API_KEY`, the selected
229
+ Frontier profile credential, the OS keychain, the `0600` fallback file, then
230
+ `FRONTIER_DEMO_API_KEY` only when `FRONTIER_ALLOW_LOCAL_MANIFEST` is set
231
+ outside GitHub Actions. If none are present, the CLI exits with
232
+ `AUTH_REQUIRED: Run \`frontier login --api-key\``. Hashed uploads set
233
+ `entityIdsHashed: true`.
208
234
 
209
235
  In GitHub Actions, assessments use `{project}-{GITHUB_SHA}` as `externalRunId`
210
236
  and record repository, branch, commit, and PR number. After a successful
@@ -222,9 +248,9 @@ workflow uses `FRONTIER_BLOCKING=false` unless you pass `--blocking`.
222
248
  Pin an immutable released version:
223
249
 
224
250
  ```bash
225
- pip install "frontier-runner[snowflake]==0.2.2"
251
+ pip install "frontier-runner[snowflake]==0.2.3"
226
252
  # or
227
- pip install "frontier-runner[bigquery]==0.2.2"
253
+ pip install "frontier-runner[bigquery]==0.2.3"
228
254
  ```
229
255
 
230
256
  Until PyPI trusted publishing is reviewed and live, install the GitHub Release
@@ -34,7 +34,7 @@ Until the package is on PyPI, install the GitHub Release wheel for a version
34
34
  tag. You do not need a commit SHA:
35
35
 
36
36
  ```bash
37
- pipx install \ "frontier-runner[snowflake] @ https://github.com/jadsamara/frontier-runner/releases/download/v0.2.2/frontier_runner-0.2.2-py3-none-any.whl"
37
+ pipx install \ "frontier-runner[snowflake] @ https://github.com/jadsamara/frontier-runner/releases/download/v0.2.3/frontier_runner-0.2.3-py3-none-any.whl"
38
38
  pipx inject frontier-runner "snowflake-connector-python>=3.12,<4"
39
39
  ```
40
40
 
@@ -61,6 +61,31 @@ API keys are stored in the OS keychain (or `~/.config/frontier/credentials`
61
61
  mode 0600). They are never written to `frontier.yml`, `dbt_project.yml`,
62
62
  `profiles.yml`, Git, or generated workflows.
63
63
 
64
+ ## Frontier profiles
65
+
66
+ A **Frontier profile** is a local named execution context. It selects one
67
+ Frontier SaaS project credential and may default the dbt target and
68
+ `profiles.yml` path. It does **not** replace the dbt `profile:` in
69
+ `dbt_project.yml`. A **dbt target** is an output such as `dev`, `ci`, or
70
+ `benchmark`.
71
+
72
+ ```bash
73
+ frontier profile create repair --target dev --api-key
74
+ frontier profile create benchmark --target benchmark --api-key
75
+ frontier profile use benchmark
76
+ frontier auth status
77
+ frontier doctor
78
+ ```
79
+
80
+ `--api-key` prompts securely; never pass the raw key as an argument.
81
+ Selection is gitignored in `.frontier/state.yml` and is local to this
82
+ repository. GitHub Actions ignores that file unless `FRONTIER_PROFILE` or
83
+ `--profile` is set; CI continues to use `FRONTIER_API_KEY` /
84
+ `FRONTIER_API_URL` repository secrets. `frontier upload` refuses a run
85
+ generated for another origin, organization, project, dbt project, target, or
86
+ semantic manifest (`ARTIFACT_PROFILE_MISMATCH`). Legacy login without a
87
+ named profile is unchanged.
88
+
64
89
  ## Developer setup
65
90
 
66
91
  From this repository:
@@ -142,11 +167,12 @@ assessment.
142
167
 
143
168
  `frontier upload` posts `target/frontier-run.json` to `POST /api/v1/runs`. It
144
169
  retries HTTP 429/5xx and network errors, and honors `Retry-After`. SaaS
145
- commands resolve credentials in this order: `FRONTIER_API_KEY`, the OS
146
- keychain, the `0600` fallback file, then `FRONTIER_DEMO_API_KEY` only when
147
- `FRONTIER_ALLOW_LOCAL_MANIFEST` is set outside GitHub Actions. If none are
148
- present, the CLI exits with `AUTH_REQUIRED: Run \`frontier login --api-key\``.
149
- Hashed uploads set `entityIdsHashed: true`.
170
+ commands resolve credentials in this order: `FRONTIER_API_KEY`, the selected
171
+ Frontier profile credential, the OS keychain, the `0600` fallback file, then
172
+ `FRONTIER_DEMO_API_KEY` only when `FRONTIER_ALLOW_LOCAL_MANIFEST` is set
173
+ outside GitHub Actions. If none are present, the CLI exits with
174
+ `AUTH_REQUIRED: Run \`frontier login --api-key\``. Hashed uploads set
175
+ `entityIdsHashed: true`.
150
176
 
151
177
  In GitHub Actions, assessments use `{project}-{GITHUB_SHA}` as `externalRunId`
152
178
  and record repository, branch, commit, and PR number. After a successful
@@ -164,9 +190,9 @@ workflow uses `FRONTIER_BLOCKING=false` unless you pass `--blocking`.
164
190
  Pin an immutable released version:
165
191
 
166
192
  ```bash
167
- pip install "frontier-runner[snowflake]==0.2.2"
193
+ pip install "frontier-runner[snowflake]==0.2.3"
168
194
  # or
169
- pip install "frontier-runner[bigquery]==0.2.2"
195
+ pip install "frontier-runner[bigquery]==0.2.3"
170
196
  ```
171
197
 
172
198
  Until PyPI trusted publishing is reviewed and live, install the GitHub Release
@@ -1,3 +1,3 @@
1
1
  """Customer-side Frontier runner."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.2.3"
@@ -15,6 +15,13 @@ from frontier.api import (
15
15
  redact_api_key,
16
16
  upload_run,
17
17
  )
18
+ from frontier.context import (
19
+ assert_artifact_matches_context,
20
+ assessment_identity_fields,
21
+ print_execution_banner,
22
+ resolve_execution_context,
23
+ warehouse_connect_kwargs,
24
+ )
18
25
  from frontier.credentials import resolve_api_credential, try_resolve_api_credential
19
26
  from frontier.comment import maybe_upsert_pr_comment
20
27
  from frontier.config import (
@@ -133,6 +140,14 @@ from frontier.onboard.commands import (
133
140
  cmd_update_check,
134
141
  maybe_version_notice,
135
142
  )
143
+ from frontier.onboard.profile_commands import (
144
+ FRONTIER_PROFILE_HELP,
145
+ cmd_profile_create,
146
+ cmd_profile_list,
147
+ cmd_profile_remove,
148
+ cmd_profile_status,
149
+ cmd_profile_use,
150
+ )
136
151
  from frontier.onboard.constants import DEFAULT_API_URL
137
152
  from frontier.onboard.routes import (
138
153
  derive_source_route,
@@ -344,6 +359,7 @@ def _assessment_identity(
344
359
  profile_schema: str | None = None,
345
360
  pinned_version: int | None = None,
346
361
  pinned_fingerprint: str | None = None,
362
+ extra: dict[str, Any] | None = None,
347
363
  ) -> dict[str, Any]:
348
364
  identity: dict[str, Any] = {
349
365
  "runnerVersion": __version__,
@@ -359,6 +375,13 @@ def _assessment_identity(
359
375
  identity["pinnedManifestVersion"] = pinned_version
360
376
  if pinned_fingerprint:
361
377
  identity["pinnedManifestFingerprint"] = pinned_fingerprint
378
+ if extra:
379
+ for key, value in extra.items():
380
+ if value in (None, ""):
381
+ continue
382
+ if key in {"credential_ref", "profiles_path"}:
383
+ continue
384
+ identity.setdefault(key, value)
362
385
  return identity
363
386
 
364
387
 
@@ -442,6 +465,60 @@ def _config_path(args: argparse.Namespace, project_dir: Path) -> Path:
442
465
  return project_dir / "frontier.yml"
443
466
 
444
467
 
468
+ def _execution_context(args: argparse.Namespace, project_dir: Path):
469
+ return resolve_execution_context(args, project_dir)
470
+
471
+
472
+ def _connect_warehouse(args: argparse.Namespace, project_dir: Path, ctx=None):
473
+ current = ctx or _execution_context(args, project_dir)
474
+ return connect_warehouse(project_dir, **warehouse_connect_kwargs(args, current))
475
+
476
+
477
+ def _resolved_dbt_target_label(args: argparse.Namespace, project_dir: Path, config) -> str | None:
478
+ ctx = _execution_context(args, project_dir)
479
+ return getattr(args, "target", None) or ctx.dbt_target or getattr(config, "environment", None)
480
+
481
+
482
+ def _identity_extra(args: argparse.Namespace, project_dir: Path, config) -> dict[str, Any]:
483
+ ctx = _execution_context(args, project_dir)
484
+ if not ctx.selected:
485
+ return {}
486
+ version, fingerprint = _pinned_compare_fields(config)
487
+ return assessment_identity_fields(
488
+ ctx,
489
+ dbt_target=_resolved_dbt_target_label(args, project_dir, config),
490
+ dbt_project=ctx.dbt_project_name or getattr(config, "project", None),
491
+ pinned_version=version,
492
+ pinned_fingerprint=fingerprint,
493
+ )
494
+
495
+
496
+ def _print_warehouse_context(args: argparse.Namespace, project_dir: Path, ctx, warehouse=None) -> None:
497
+ if not ctx.selected:
498
+ return
499
+ database = schema = warehouse_type = None
500
+ try:
501
+ output = load_dbt_profile_output(
502
+ project_dir,
503
+ **warehouse_connect_kwargs(args, ctx),
504
+ )
505
+ database = str(output.get("database") or output.get("dbname") or output.get("project") or "") or None
506
+ schema = str(output.get("schema") or output.get("dataset") or "") or None
507
+ warehouse_type = str(output.get("type") or "") or None
508
+ except Exception:
509
+ pass
510
+ if warehouse is not None:
511
+ warehouse_type = warehouse.warehouse_type or warehouse_type
512
+ print_execution_banner(
513
+ ctx,
514
+ dbt_profile=ctx.dbt_profile_name,
515
+ dbt_target=ctx.dbt_target or getattr(args, "target", None),
516
+ warehouse_type=warehouse_type,
517
+ database=database,
518
+ schema=schema,
519
+ )
520
+
521
+
445
522
  def _api_url(args: argparse.Namespace, config=None, creds=None, local=None) -> str:
446
523
  return str(
447
524
  getattr(args, "api_url", None)
@@ -467,9 +544,10 @@ def _resolve_runtime_config(
467
544
  *,
468
545
  dbt_manifest=None,
469
546
  ):
470
- local = load_local_config(project_dir)
547
+ ctx = _execution_context(args, project_dir)
548
+ local = ctx.local
471
549
  explicit = getattr(args, "manifest_file", None)
472
- creds = None if explicit else try_resolve_api_credential()
550
+ creds = None if explicit else ctx.creds
473
551
  api_key = creds.api_key if creds else None
474
552
  use_local_yml = allow_local_manifest(args) and not api_key and not explicit
475
553
  legacy_path = _config_path(args, project_dir)
@@ -480,14 +558,21 @@ def _resolve_runtime_config(
480
558
  # Explicit --config without SaaS credentials still loads the legacy mapping.
481
559
  if getattr(args, "config", None):
482
560
  legacy = load_frontier_config(legacy_path)
483
- api_url = _api_url(args, legacy, creds, local)
561
+ if ctx.selected:
562
+ api_url = ctx.api_url
563
+ else:
564
+ api_url = _api_url(args, legacy, creds, local)
484
565
  if legacy is not None:
485
566
  config = legacy
486
567
  else:
487
568
  config = saas_runtime_config(
488
- project=_saas_project_name(project_dir, creds, local),
569
+ project=_saas_project_name(project_dir, creds, local) if not ctx.selected else (ctx.project_name or _saas_project_name(project_dir, creds, local)),
489
570
  api_url=api_url,
490
- environment=local.dbt_target if local else "dev",
571
+ environment=(
572
+ ctx.dbt_target
573
+ if ctx.selected and ctx.dbt_target
574
+ else (local.dbt_target if local else "dev")
575
+ ),
491
576
  )
492
577
  config, _pinned = resolve_semantic_manifest(
493
578
  args,
@@ -1161,14 +1246,15 @@ def _emit_run(
1161
1246
  ),
1162
1247
  **_ingest_manifest_fields(config),
1163
1248
  runner_version=__version__,
1164
- dbt_target=getattr(args, "target", None) or getattr(config, "environment", None),
1249
+ dbt_target=_resolved_dbt_target_label(args, _project_dir(args), config),
1165
1250
  assessment_identity=_assessment_identity(
1166
1251
  run_id=run_id,
1167
- dbt_target=getattr(args, "target", None) or getattr(config, "environment", None),
1252
+ dbt_target=_resolved_dbt_target_label(args, _project_dir(args), config),
1168
1253
  profile_database=str(model.database) if model.database else None,
1169
1254
  profile_schema=str(model.schema) if model.schema else None,
1170
1255
  pinned_version=_pinned_compare_fields(config)[0],
1171
1256
  pinned_fingerprint=_pinned_compare_fields(config)[1],
1257
+ extra=_identity_extra(args, _project_dir(args), config),
1172
1258
  ),
1173
1259
  )
1174
1260
  output = Path(args.output) if args.output else _target_dir(_project_dir(args)) / RUN_FILE_NAME
@@ -1204,12 +1290,11 @@ def cmd_run(args: argparse.Namespace) -> int:
1204
1290
  }
1205
1291
  )
1206
1292
  print("Using in-memory warehouse (--dry-run). No live warehouse session.")
1293
+ _print_warehouse_context(args, project_dir, _execution_context(args, project_dir))
1207
1294
  else:
1208
- warehouse = connect_warehouse(
1209
- project_dir,
1210
- profiles_path=Path(args.profiles).expanduser() if args.profiles else None,
1211
- target=args.target,
1212
- )
1295
+ warehouse = _connect_warehouse(args, project_dir)
1296
+ ctx = _execution_context(args, project_dir)
1297
+ _print_warehouse_context(args, project_dir, ctx, warehouse)
1213
1298
  print(f"{warehouse.warehouse_type}: " + json.dumps(describe_adapter(warehouse)))
1214
1299
 
1215
1300
  base_sql, after_sql = _proof_sql_pair(
@@ -1535,6 +1620,7 @@ def cmd_prove(args: argparse.Namespace) -> int:
1535
1620
  }
1536
1621
  )
1537
1622
  print("Using in-memory warehouse (--dry-run). No live warehouse session.", flush=True)
1623
+ _print_warehouse_context(args, project_dir, _execution_context(args, project_dir))
1538
1624
  if sql_change_demo:
1539
1625
  sql_proof = recorded_sql_change_proof()
1540
1626
  else:
@@ -1549,17 +1635,12 @@ def cmd_prove(args: argparse.Namespace) -> int:
1549
1635
  log_step("Snowflake connection started")
1550
1636
  live: WarehouseAdapter | None = None
1551
1637
  try:
1552
- live = connect_warehouse(
1553
- project_dir,
1554
- profiles_path=Path(args.profiles).expanduser() if args.profiles else None,
1555
- target=args.target,
1556
- )
1638
+ live = _connect_warehouse(args, project_dir)
1557
1639
  profile: dict[str, Any] = {}
1558
1640
  try:
1559
1641
  profile = load_dbt_profile_output(
1560
1642
  project_dir,
1561
- profiles_path=Path(args.profiles).expanduser() if args.profiles else None,
1562
- target=args.target,
1643
+ **warehouse_connect_kwargs(args, _execution_context(args, project_dir)),
1563
1644
  )
1564
1645
  except ConfigError:
1565
1646
  profile = {}
@@ -1619,10 +1700,12 @@ def cmd_prove(args: argparse.Namespace) -> int:
1619
1700
  live.close()
1620
1701
  else:
1621
1702
  try:
1622
- warehouse = connect_warehouse(
1703
+ warehouse = _connect_warehouse(args, project_dir)
1704
+ _print_warehouse_context(
1705
+ args,
1623
1706
  project_dir,
1624
- profiles_path=Path(args.profiles).expanduser() if args.profiles else None,
1625
- target=args.target,
1707
+ _execution_context(args, project_dir),
1708
+ warehouse,
1626
1709
  )
1627
1710
  except Exception as error:
1628
1711
  _write_failed_prove_run(
@@ -1643,8 +1726,7 @@ def cmd_prove(args: argparse.Namespace) -> int:
1643
1726
  try:
1644
1727
  profile = load_dbt_profile_output(
1645
1728
  project_dir,
1646
- profiles_path=Path(args.profiles).expanduser() if args.profiles else None,
1647
- target=args.target,
1729
+ **warehouse_connect_kwargs(args, _execution_context(args, project_dir)),
1648
1730
  )
1649
1731
  except ConfigError:
1650
1732
  profile = {}
@@ -2502,9 +2584,26 @@ def cmd_upload(args: argparse.Namespace) -> int:
2502
2584
  )
2503
2585
  if args.run_id:
2504
2586
  payload["externalRunId"] = args.run_id
2505
- creds = resolve_api_credential()
2587
+ ctx = _execution_context(args, project_dir)
2588
+ identity = payload.get("assessmentIdentity") or {}
2589
+ pinned_version = payload.get("semanticManifestVersion")
2590
+ pinned_fingerprint = payload.get("semanticManifestFingerprint")
2591
+ if isinstance(identity, dict):
2592
+ pinned_version = identity.get("pinnedManifestVersion", pinned_version)
2593
+ pinned_fingerprint = identity.get("pinnedManifestFingerprint", pinned_fingerprint)
2594
+ assert_artifact_matches_context(
2595
+ payload,
2596
+ ctx,
2597
+ dbt_project=ctx.dbt_project_name,
2598
+ dbt_target=ctx.dbt_target or getattr(args, "target", None),
2599
+ pinned_version=pinned_version if isinstance(pinned_version, int) else None,
2600
+ pinned_fingerprint=str(pinned_fingerprint) if pinned_fingerprint else None,
2601
+ )
2602
+ creds = ctx.creds
2603
+ if creds is None:
2604
+ creds = resolve_api_credential()
2506
2605
  api_key, api_key_source = creds.api_key, creds.source
2507
- api_url = _api_url(args, None, creds, local)
2606
+ api_url = ctx.api_url if ctx.selected else _api_url(args, None, creds, local)
2508
2607
  print(
2509
2608
  f"Uploading {payload.get('externalRunId')} to {api_url} "
2510
2609
  f"as {redact_api_key(api_key)} ({api_key_source})",
@@ -2541,11 +2640,11 @@ def cmd_upload(args: argparse.Namespace) -> int:
2541
2640
 
2542
2641
  def cmd_manifest_fetch(args: argparse.Namespace) -> int:
2543
2642
  project_dir = _project_dir(args)
2544
- local = load_local_config(project_dir)
2545
- creds = resolve_api_credential()
2643
+ ctx = _execution_context(args, project_dir)
2644
+ creds = ctx.creds or resolve_api_credential()
2546
2645
  api_key = creds.api_key
2547
- api_url = _api_url(args, None, creds, local)
2548
- project = _saas_project_name(project_dir, creds, local)
2646
+ api_url = ctx.api_url if ctx.selected else _api_url(args, None, creds, ctx.local)
2647
+ project = ctx.project_name or _saas_project_name(project_dir, creds, ctx.local)
2549
2648
  output = Path(args.output).expanduser().resolve() if args.output else default_pin_path(project_dir)
2550
2649
  started = time.perf_counter()
2551
2650
  log_step("manifest fetch started", prefix="manifest")
@@ -2582,6 +2681,17 @@ def _add_project_dir(parser: argparse.ArgumentParser) -> None:
2582
2681
  dest="project_dir_opt",
2583
2682
  help="dbt project directory (same as the positional path)",
2584
2683
  )
2684
+ _add_profile_option(parser)
2685
+
2686
+
2687
+ def _add_profile_option(parser: argparse.ArgumentParser) -> None:
2688
+ parser.add_argument(
2689
+ "--profile",
2690
+ dest="frontier_profile",
2691
+ metavar="NAME",
2692
+ default=None,
2693
+ help=FRONTIER_PROFILE_HELP,
2694
+ )
2585
2695
 
2586
2696
 
2587
2697
  def _add_manifest_flags(parser: argparse.ArgumentParser) -> None:
@@ -2641,6 +2751,7 @@ def cmd_cdc_inspect(args: argparse.Namespace) -> int:
2641
2751
  _config_path(args, project_dir).is_file()
2642
2752
  or getattr(args, "manifest_file", None)
2643
2753
  or try_resolve_api_credential() is not None
2754
+ or _execution_context(args, project_dir).creds is not None
2644
2755
  ):
2645
2756
  dbt_path = _target_dir(project_dir) / "manifest.json"
2646
2757
  dbt_manifest = load_manifest(dbt_path) if dbt_path.is_file() else None
@@ -2664,11 +2775,8 @@ def cmd_cdc_inspect(args: argparse.Namespace) -> int:
2664
2775
  def cmd_cdc_status(args: argparse.Namespace) -> int:
2665
2776
  project_dir = _project_dir(args)
2666
2777
  config = load_cdc_config(_cdc_path(args, project_dir))
2667
- warehouse = connect_warehouse(
2668
- project_dir,
2669
- profiles_path=Path(args.profiles).expanduser() if getattr(args, "profiles", None) else None,
2670
- target=getattr(args, "target", None),
2671
- )
2778
+ warehouse = _connect_warehouse(args, project_dir)
2779
+ _print_warehouse_context(args, project_dir, _execution_context(args, project_dir), warehouse)
2672
2780
  _assert_cdc_supported(warehouse)
2673
2781
  try:
2674
2782
  store = SnowflakeCdcStore(warehouse, config)
@@ -2686,11 +2794,8 @@ def cmd_cdc_status(args: argparse.Namespace) -> int:
2686
2794
  def cmd_cdc_consume(args: argparse.Namespace) -> int:
2687
2795
  project_dir = _project_dir(args)
2688
2796
  config = load_cdc_config(_cdc_path(args, project_dir))
2689
- warehouse = connect_warehouse(
2690
- project_dir,
2691
- profiles_path=Path(args.profiles).expanduser() if getattr(args, "profiles", None) else None,
2692
- target=getattr(args, "target", None),
2693
- )
2797
+ warehouse = _connect_warehouse(args, project_dir)
2798
+ _print_warehouse_context(args, project_dir, _execution_context(args, project_dir), warehouse)
2694
2799
  _assert_cdc_supported(warehouse)
2695
2800
  started = time.perf_counter()
2696
2801
  log_step("consume started", prefix="cdc")
@@ -2732,11 +2837,8 @@ def cmd_cdc_prove(args: argparse.Namespace) -> int:
2732
2837
  cdc_config = load_cdc_config(_cdc_path(args, project_dir))
2733
2838
  frontier_config = _resolve_runtime_config(args, project_dir, dbt_manifest=manifest)
2734
2839
  cdc_config = overlay_cdc_with_manifest(cdc_config, frontier_config.pinned)
2735
- warehouse = connect_warehouse(
2736
- project_dir,
2737
- profiles_path=Path(args.profiles).expanduser() if getattr(args, "profiles", None) else None,
2738
- target=getattr(args, "target", None),
2739
- )
2840
+ warehouse = _connect_warehouse(args, project_dir)
2841
+ _print_warehouse_context(args, project_dir, _execution_context(args, project_dir), warehouse)
2740
2842
  _assert_cdc_supported(warehouse)
2741
2843
  started = time.perf_counter()
2742
2844
  log_step("prove started", prefix="cdc")
@@ -2799,15 +2901,13 @@ def cmd_cdc_upload(args: argparse.Namespace) -> int:
2799
2901
  cdc_config = load_cdc_config(_cdc_path(args, project_dir))
2800
2902
  frontier_config = _resolve_runtime_config(args, project_dir, dbt_manifest=manifest)
2801
2903
  cdc_config = overlay_cdc_with_manifest(cdc_config, frontier_config.pinned)
2802
- warehouse = connect_warehouse(
2803
- project_dir,
2804
- profiles_path=Path(args.profiles).expanduser() if getattr(args, "profiles", None) else None,
2805
- target=getattr(args, "target", None),
2806
- )
2904
+ warehouse = _connect_warehouse(args, project_dir)
2905
+ _print_warehouse_context(args, project_dir, _execution_context(args, project_dir), warehouse)
2807
2906
  _assert_cdc_supported(warehouse)
2808
- creds = resolve_api_credential()
2907
+ ctx = _execution_context(args, project_dir)
2908
+ creds = ctx.creds or resolve_api_credential()
2809
2909
  api_key, api_key_source = creds.api_key, creds.source
2810
- api_url = _api_url(args, frontier_config, creds)
2910
+ api_url = ctx.api_url if ctx.selected else _api_url(args, frontier_config, creds)
2811
2911
  print(
2812
2912
  f"Uploading CDC assessment to {api_url} "
2813
2913
  f"as {redact_api_key(api_key)} ({api_key_source})",
@@ -2852,9 +2952,15 @@ def cmd_cdc_upload(args: argparse.Namespace) -> int:
2852
2952
  def build_parser() -> argparse.ArgumentParser:
2853
2953
  parser = argparse.ArgumentParser(
2854
2954
  prog="frontier",
2855
- description="Run a change-frontier assessment in the customer environment.",
2955
+ description=(
2956
+ "Run a change-frontier assessment in the customer environment. "
2957
+ "A Frontier profile is a local named execution context. It selects a Frontier "
2958
+ "SaaS project credential and may provide a default dbt target and profiles.yml "
2959
+ "path. It does not replace the dbt `profile:` declared by dbt_project.yml."
2960
+ ),
2856
2961
  )
2857
2962
  parser.add_argument("--version", action="version", version=f"frontier {__version__}")
2963
+ _add_profile_option(parser)
2858
2964
  sub = parser.add_subparsers(dest="command", required=True)
2859
2965
 
2860
2966
  init = sub.add_parser("init", help="Detect the dbt project and write .frontier/config.yml")
@@ -2874,18 +2980,74 @@ def build_parser() -> argparse.ArgumentParser:
2874
2980
  signup.set_defaults(func=cmd_signup)
2875
2981
 
2876
2982
  login = sub.add_parser("login", help="Store a project API key in the OS keychain")
2877
- login.add_argument("--api-key", action="store_true", help="Paste a project API key (hidden input)")
2983
+ login.add_argument("--api-key", action="store_true", help="Paste a project API key (hidden input). Never pass the key as a positional argument.")
2878
2984
  login.add_argument("--api-url", help="SaaS origin")
2985
+ _add_profile_option(login)
2879
2986
  login.set_defaults(func=cmd_login)
2880
2987
 
2881
2988
  logout = sub.add_parser("logout", help="Remove stored Frontier API credentials")
2989
+ _add_profile_option(logout)
2990
+ logout.add_argument(
2991
+ "--remove-metadata",
2992
+ action="store_true",
2993
+ help="Also delete the named Frontier profile metadata (profile logout only)",
2994
+ )
2882
2995
  logout.set_defaults(func=cmd_logout)
2883
2996
 
2884
2997
  auth = sub.add_parser("auth", help="Show authentication status")
2885
2998
  auth_sub = auth.add_subparsers(dest="auth_command", required=True)
2886
2999
  auth_status = auth_sub.add_parser("status", help="Show whether the CLI is authenticated")
3000
+ _add_profile_option(auth_status)
2887
3001
  auth_status.set_defaults(func=cmd_auth_status)
2888
3002
 
3003
+ profile = sub.add_parser(
3004
+ "profile",
3005
+ help=(
3006
+ "Create and select named Frontier profiles. A Frontier profile is a local "
3007
+ "execution context for a SaaS project credential; it does not replace the "
3008
+ "dbt `profile:` in dbt_project.yml."
3009
+ ),
3010
+ )
3011
+ profile_sub = profile.add_subparsers(dest="profile_command", required=True)
3012
+ profile_create = profile_sub.add_parser(
3013
+ "create",
3014
+ help="Verify a project API key and store a named Frontier profile",
3015
+ )
3016
+ profile_create.add_argument("name", help="Frontier profile name")
3017
+ profile_create.add_argument("--api-url", help="SaaS origin")
3018
+ profile_create.add_argument("--target", help="Default dbt target for this Frontier profile")
3019
+ profile_create.add_argument("--profiles", help="Optional profiles.yml path for this Frontier profile")
3020
+ profile_create.add_argument(
3021
+ "--api-key",
3022
+ action="store_true",
3023
+ help="Prompt securely for the project API key (never pass the key as an argument)",
3024
+ )
3025
+ profile_create.add_argument("--force", action="store_true", help="Replace an existing Frontier profile")
3026
+ profile_create.add_argument("--yes", action="store_true", help="Accept the default API origin")
3027
+ _add_project_dir(profile_create)
3028
+ profile_create.set_defaults(func=cmd_profile_create)
3029
+ profile_list = profile_sub.add_parser("list", help="List local Frontier profiles")
3030
+ _add_project_dir(profile_list)
3031
+ profile_list.set_defaults(func=cmd_profile_list)
3032
+ profile_use = profile_sub.add_parser("use", help="Select a Frontier profile for this dbt project")
3033
+ profile_use.add_argument("name", help="Frontier profile name")
3034
+ profile_use.add_argument(
3035
+ "--offline",
3036
+ action="store_true",
3037
+ help="Skip SaaS revalidation when the credential is present",
3038
+ )
3039
+ _add_project_dir(profile_use)
3040
+ profile_use.set_defaults(func=cmd_profile_use)
3041
+ profile_status = profile_sub.add_parser("status", help="Show the resolved Frontier execution context")
3042
+ _add_project_dir(profile_status)
3043
+ profile_status.set_defaults(func=cmd_profile_status)
3044
+ profile_remove = profile_sub.add_parser("remove", help="Remove a named Frontier profile")
3045
+ profile_remove.add_argument("name", help="Frontier profile name")
3046
+ profile_remove.add_argument("--force", action="store_true", help="Remove even if this profile is selected")
3047
+ profile_remove.add_argument("--yes", action="store_true", help="Delete the stored credential without prompting")
3048
+ _add_project_dir(profile_remove)
3049
+ profile_remove.set_defaults(func=cmd_profile_remove)
3050
+
2889
3051
  discover = sub.add_parser(
2890
3052
  "discover",
2891
3053
  help="Derive and upload a generated semantic configuration from dbt artifacts",
@@ -3113,7 +3275,7 @@ def main(argv: Sequence[str] | None = None) -> int:
3113
3275
  parser = build_parser()
3114
3276
  args = parser.parse_args(argv)
3115
3277
  command = getattr(args, "command", None)
3116
- if command not in {"update-check", "doctor", "login", "logout", "signup"}:
3278
+ if command not in {"update-check", "doctor", "login", "logout", "signup", "profile"}:
3117
3279
  from frontier.onboard.constants import DEFAULT_API_URL
3118
3280
 
3119
3281
  maybe_version_notice(str(getattr(args, "api_url", None) or DEFAULT_API_URL))