diff --git a/AGENTS.md b/AGENTS.md
index 91e25ae..8894278 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -33,8 +33,8 @@ OmniGraph is a typed property-graph engine built as a coordination layer over ma
- **Multi-modal querying**: vector ANN (`nearest`), full-text (`search`/`fuzzy`/`match_text`/`bm25`), Reciprocal Rank Fusion (`rrf`), and graph traversal (`Expand`, anti-join `not { … }`) in one runtime.
- **Branches and commits across the whole graph**: Git-style — every successful publish appends to a commit DAG; merges are three-way at the row level.
- **Atomic per-query writes**: `mutate_as` and `load` accumulate insert/update batches into an in-memory `MutationStaging.pending` per touched table; one `stage_*` + `commit_staged` per table runs at end-of-query, then `ManifestBatchPublisher::publish` commits the manifest atomically with per-table `expected_table_versions` CAS. A mid-query failure leaves Lance HEAD untouched on staged tables — no drift, no run state machine, no staging branches. Deletes still inline-commit; D₂ at parse time prevents inserts/updates and deletes from coexisting in one query.
-- **HTTP server**: Axum + utoipa OpenAPI, bearer auth (SHA-256 hashed, optional AWS Secrets Manager). Cedar policy enforcement is engine-wide — every `_as` writer calls `Omnigraph::enforce(action, scope, actor)`, so HTTP, CLI, and embedded SDK consumers all hit the same gate. **Two modes** (v0.6.0+): single-graph (legacy flat routes) and multi-graph (`/graphs/{graph_id}/...` cluster routes + read-only `GET /graphs` enumeration). Per-graph + server-level Cedar policies. Multi-graph mode boots from a cluster directory (`--cluster
`, RFC-005) or the legacy `omnigraph.yaml` `graphs:` map. Runtime add/remove (`POST /graphs`, `DELETE /graphs/{id}`) is not exposed — operators run `cluster apply` (or edit the legacy file) and restart.
-- **CLI** with two-surface config (RFC-008): the team-owned cluster directory (`cluster.yaml`) plus the per-operator `~/.omnigraph/config.yaml` (servers, credentials, actor, aliases). The legacy combined `omnigraph.yaml` still loads with per-key deprecation warnings — `config migrate` proposes the split, `OMNIGRAPH_NO_LEGACY_CONFIG=1` enforces strict mode. **Never extend `omnigraph.yaml`.** Multi-format output (json/jsonl/csv/kv/table).
+- **HTTP server**: Axum + utoipa OpenAPI, bearer auth (SHA-256 hashed, optional AWS Secrets Manager). Cedar policy enforcement is engine-wide — every `_as` writer calls `Omnigraph::enforce(action, scope, actor)`, so HTTP, CLI, and embedded SDK consumers all hit the same gate. **Cluster-only boot** (RFC-011): the server always boots from a cluster directory (`--cluster `, RFC-005) and serves N graphs (N ≥ 1) under multi-graph routes (`/graphs/{graph_id}/...` + read-only `GET /graphs` enumeration); there are no single-graph flat routes and no positional-URI boot. Per-graph + server-level Cedar policies. Runtime add/remove (`POST /graphs`, `DELETE /graphs/{id}`) is not exposed — operators run `cluster apply` and restart.
+- **CLI** with two-surface config (RFC-007/008): the team-owned cluster directory (`cluster.yaml`) plus the per-operator `~/.omnigraph/config.yaml` (servers, clusters, credentials, actor, profiles, aliases, defaults). Graphs are addressed via `--store`/`--server`/`--cluster`/`--profile`/operator defaults (RFC-011). Multi-format output (json/jsonl/csv/kv/table).
Throughout the docs, capabilities are split into **L1 — Inherited from Lance** vs **L2 — Added by OmniGraph**.
@@ -96,7 +96,7 @@ Full diagram and concurrency model: [docs/dev/architecture.md](docs/dev/architec
| Cedar policy actions, scopes, CLI | [docs/user/operations/policy.md](docs/user/operations/policy.md) |
| HTTP server endpoints, auth, error model, body limits | [docs/user/operations/server.md](docs/user/operations/server.md) |
| CLI quick-start | [docs/user/cli/index.md](docs/user/cli/index.md) |
-| CLI command surface and config schemas (`~/.omnigraph/config.yaml`, legacy `omnigraph.yaml`) | [docs/user/cli/reference.md](docs/user/cli/reference.md) |
+| CLI command surface and config schema (`~/.omnigraph/config.yaml`) | [docs/user/cli/reference.md](docs/user/cli/reference.md) |
| Audit / actor tracking | [docs/user/operations/audit.md](docs/user/operations/audit.md) |
| Error taxonomy and result serialization | [docs/user/operations/errors.md](docs/user/operations/errors.md) |
| Install (binary / Homebrew / source / channels) | [docs/user/install.md](docs/user/install.md) |
@@ -144,6 +144,7 @@ These are architectural rules that need to be in scope on every change. They're
4. **Bearer-token plaintext never persists in process memory.** Tokens are hashed at startup; auth uses constant-time comparison; the actor id is server-resolved from the hash match and must not be settable by the client.
5. **Reads always see the current index state for the branch they're reading.** Indexes track the branch head, not historical snapshots. If you change index lifecycle, preserve this guarantee.
6. **Stable type IDs survive renames.** Schema migration relies on identity that's stable across rename — don't mint new IDs on rename.
+7. **Logical contract over physical state.** Physical state (index coverage, fragment layout, compaction versions, staged writes) is derived and rebuildable; it must never fail a logical operation. Check preconditions against logical state and let reconciliation converge the physical state idempotently — genuine logical conflicts still fail loudly. This is the rule rules 1–6 instantiate; full statement and applications in [docs/dev/invariants.md](docs/dev/invariants.md).
### Deny-list (fast-pass review filter — full reasoning in [docs/dev/invariants.md](docs/dev/invariants.md))
@@ -179,7 +180,7 @@ Rust stable workspace (edition 2024). `protoc` is a build dependency (`brew inst
cargo build --workspace --locked # build everything
cargo test --workspace --locked # the canonical CI gate (matches CI exactly)
cargo run -p omnigraph-cli -- # run the `omnigraph` CLI from source
-cargo run -p omnigraph-server -- --bind 0.0.0.0:8080 # run the server from source
+cargo run -p omnigraph-server -- --cluster --bind 0.0.0.0:8080 # run the server from source
# Run one crate / one test file / one test fn
cargo test -p omnigraph-engine --test traversal # one integration-test file (see docs/dev/testing.md)
@@ -231,10 +232,10 @@ omnigraph cleanup --keep 10 --older-than 7d --confirm s3://my-bucket/graph.omni
# Stand up the HTTP server (token from env)
OMNIGRAPH_SERVER_BEARER_TOKEN=xxxx \
- omnigraph-server s3://my-bucket/graph.omni --bind 0.0.0.0:8080
+ omnigraph-server --cluster s3://my-bucket/cluster --bind 0.0.0.0:8080
# Cedar policy explain
-omnigraph policy explain --actor act-alice --action change --branch main
+omnigraph policy explain --cluster ./company-brain --graph knowledge --actor act-alice --action change --branch main
```
---
@@ -250,7 +251,7 @@ omnigraph policy explain --actor act-alice --action change --branch main
| Compaction (`compact_files`) + reindex (`optimize_indices`) | ✅ | `omnigraph optimize` orchestrates over all node/edge tables, bounded concurrency; per table runs `compact_files` **then Lance `optimize_indices`** (folds appended/rewritten fragments back into existing indexes — incremental merge, not retrain) and **publishes the resulting version to `__manifest`** (so the manifest tracks the Lance HEAD — required for reads to observe the work and for schema apply / strict writes to pass their HEAD-vs-manifest precondition), under the per-`(table, main)` write queue with `SidecarKind::Optimize` recovery coverage spanning both ops; **commits even with no compaction work if index coverage is stale**; **refuses on an unrecovered graph**; **skips uncovered HEAD > manifest drift** with `DriftNeedsRepair`; **skips blob-bearing tables** (reported via `TableOptimizeStats.skipped`, not silent; reindex is skipped for them too today), gated on `LANCE_SUPPORTS_BLOB_COMPACTION` until the upstream blob-v2 compaction-decode bug is fixed (see [docs/dev/invariants.md](docs/dev/invariants.md) Known Gaps) |
| Repair uncovered drift | — | `omnigraph repair` explicitly classifies uncovered table `HEAD > manifest` drift: verified maintenance drift (`ReserveFragments`/`Rewrite`) can be published with `--confirm`; suspicious or unverifiable drift requires `--force --confirm`. Sidecar-covered crash residuals still recover automatically on open. |
| Cleanup (`cleanup_old_versions`) | ✅ | `omnigraph cleanup` with `--keep` / `--older-than` policy |
-| BTREE / inverted (FTS) / vector indexes | ✅ | `ensure_indices` builds them per `@index`/`@key` column, dispatched by type via `node_prop_index_kind` (enum + orderable scalar → BTREE, free-text String → FTS, Vector → vector); idempotent; lazy across branches. Coverage of fragments appended after build is restored by `optimize`'s `optimize_indices` pass (see Compaction row). |
+| BTREE / inverted (FTS) / vector indexes | ✅ | `@index`/`@key` declares intent; the physical index is derived state that never fails a logical op. Built per column through one chokepoint (`build_indices_on_dataset_for_catalog`, type-dispatched by `node_prop_index_kind`: enum + orderable scalar → BTREE, free-text String → FTS, Vector → vector); idempotent; lazy across branches. **Schema apply builds nothing** (records intent only); `load`/`mutate` build inline but **defer an untrainable Vector column** (no trainable vectors yet) as *pending* rather than aborting. `ensure_indices`/`optimize` is the reconciler that materializes declared-but-missing indexes and restores coverage of appended/rewritten fragments (`optimize_indices`), reporting still-pending columns (see Compaction row). |
| `merge_insert` upsert | ✅ | `LoadMode::Merge`, mutation `update`/`insert`/`delete` lowering |
| Vector search | ✅ | `nearest()` query op; embedding pipeline (Gemini / OpenAI clients); `@embed` in schema |
| Full-text search | ✅ | `search/fuzzy/match_text/bm25` query ops |
@@ -264,8 +265,8 @@ omnigraph policy explain --actor act-alice --action change --branch main
| Three-way row-level merge | — | `OrderedTableCursor` + `StagedTableWriter`, structured `MergeConflictKind` |
| Change feeds | — | `diff_between` / `diff_commits` with manifest fast path + ID streaming |
| Cedar policy | — | Per-graph actions plus server-scoped actions (see [docs/user/operations/policy.md](docs/user/operations/policy.md) for the current list), branch / target_branch / protected scopes, validate/test/explain CLI. **Engine-wide enforcement** (MR-722): every `_as` writer (`apply_schema_as`, `mutate_as`, `load_as` — the deprecated `ingest_as` shims route through it — `branch_create_as` / `branch_create_from_as`, `branch_delete_as`, `branch_merge_as`) calls `Omnigraph::enforce(action, scope, actor)` — HTTP, CLI, embedded SDK all hit the same gate. |
-| HTTP server | — | Axum, OpenAPI via utoipa, bearer auth (SHA-256, AWS Secrets Manager option), `authorize_request` at the HTTP boundary (resolves bearer→actor, applies admission control), NDJSON streaming export, **multi-graph mode (v0.6.0+) with cluster routes + read-only `GET /graphs` enumeration + per-graph + server-level Cedar policies. Multi-graph boots from a cluster directory (`--cluster`) or the legacy `omnigraph.yaml`; add/remove graphs via `cluster apply` (or by editing the legacy file) and restarting.** |
-| CLI with config | — | two-surface config (team `cluster.yaml` dir + per-operator `~/.omnigraph/config.yaml`; legacy `omnigraph.yaml` deprecated per RFC-008), aliases, multi-format output (json/jsonl/csv/kv/table) |
+| HTTP server | — | Axum, OpenAPI via utoipa, bearer auth (SHA-256, AWS Secrets Manager option), `authorize_request` at the HTTP boundary (resolves bearer→actor, applies admission control), NDJSON streaming export, **cluster-only boot (RFC-011): always `--cluster `, serving N graphs (N ≥ 1) under multi-graph routes + read-only `GET /graphs` enumeration + per-graph + server-level Cedar policies. Add/remove graphs via `cluster apply` and restart.** |
+| CLI with config | — | two-surface config (team `cluster.yaml` dir + per-operator `~/.omnigraph/config.yaml`), scope addressing (`--store`/`--server`/`--cluster`/`--profile`/defaults, RFC-011), aliases, multi-format output (json/jsonl/csv/kv/table) |
| Audit / actor tracking | — | `_as` write APIs + actor map in commit graph |
| Local RustFS bootstrap | — | `scripts/local-rustfs-bootstrap.sh` one-shot S3-backed dev environment |
diff --git a/README.md b/README.md
index a75a839..35513a6 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,8 @@
**Lakehouse native graph engine built for context assembly**
-Omnigraph acts as operational state & coordination layer for agents
+Omnigraph acts as operational state & coordination layer for agents.
+Hundreds of agents can enrich the graph on parallel isolated branches and changes can be reviewed and merged safely.
- Git-style versioning & branching
- Multimodal retrieval (graph+vector/fts+filters) optimized for context assembly
diff --git a/crates/omnigraph-api-types/src/lib.rs b/crates/omnigraph-api-types/src/lib.rs
index 910d86b..2814602 100644
--- a/crates/omnigraph-api-types/src/lib.rs
+++ b/crates/omnigraph-api-types/src/lib.rs
@@ -325,6 +325,13 @@ pub struct InvokeStoredQueryRequest {
/// mutation). Mutually exclusive with `branch`.
#[serde(default)]
pub snapshot: Option,
+ /// The kind the caller expects (RFC-011 Decision 3): `Some(false)` for
+ /// `omnigraph query `, `Some(true)` for `omnigraph mutate `.
+ /// When set and it disagrees with the stored query's actual kind, the
+ /// server rejects the call (400) so the verb asserts the kind. `None`
+ /// (the default) skips the check — preserving older clients and aliases.
+ #[serde(default)]
+ pub expect_mutation: Option,
}
/// Response for `POST /queries/{name}`: the read envelope for a stored
diff --git a/crates/omnigraph-cli/src/cli.rs b/crates/omnigraph-cli/src/cli.rs
index ec0da08..94bec5a 100644
--- a/crates/omnigraph-cli/src/cli.rs
+++ b/crates/omnigraph-cli/src/cli.rs
@@ -18,10 +18,10 @@ any — run against a graph, served (--server / --profile) or embedded (--store
URI): query, mutate, load, branch, snapshot, export, commit, schema show/apply.\n \
served — require a server: graphs.\n \
direct — direct storage access; reject --server (init, optimize, repair, cleanup, \
-schema plan, lint, queries validate).\n \
-control — manage a cluster via --config: cluster.\n \
-local — no graph; local config & tooling: policy, embed, login, logout, config, \
-version, queries list.\n\
+schema plan, lint).\n \
+control — manage or inspect a cluster (cluster via --config; policy & queries via \
+--cluster).\n \
+local — no explicit graph scope; local config & tooling: alias, embed, login, logout, profile, version.\n\
See the 'Command capabilities' section of the CLI reference for which flags apply where.")]
pub(crate) struct Cli {
/// Actor id for direct-engine writes; overrides `cli.actor`. No effect on
@@ -37,9 +37,11 @@ pub(crate) struct Cli {
#[arg(long, global = true, value_name = "NAME|URL")]
pub(crate) server: Option,
- /// Graph id on a multi-graph `--server` (appends `/graphs/` to
- /// the server url). Requires --server.
- #[arg(long, global = true, value_name = "GRAPH_ID", requires = "server")]
+ /// Select a graph within a multi-graph scope: on a `--server` it appends
+ /// `/graphs/` to the server url; on a `--cluster` it picks which
+ /// cluster graph to maintain. Rejected on a single-graph address (a
+ /// positional URI / `--store`).
+ #[arg(long, global = true, value_name = "GRAPH_ID")]
pub(crate) graph: Option,
/// Select a named scope bundle (RFC-011) from `profiles:` in
@@ -56,6 +58,26 @@ pub(crate) struct Cli {
#[arg(long, global = true, value_name = "URI")]
pub(crate) store: Option,
+ /// Address a cluster-managed graph's storage for maintenance (RFC-011):
+ /// a cluster directory or storage-root URI — named via `clusters:` in
+ /// ~/.omnigraph/config.yaml, or a literal `file://`/`s3://` root. Pair
+ /// with `--graph ` to select the graph. Used by optimize / repair /
+ /// cleanup; exclusive with a positional URI / `--store` / `--server`.
+ #[arg(long, global = true, value_name = "DIR|URI")]
+ pub(crate) cluster: Option,
+
+ /// Skip the confirmation prompt for a destructive write (`cleanup`,
+ /// overwrite `load`, `branch delete`) against a non-local scope (RFC-011
+ /// Decision 9). Without it, a non-local destructive write prompts on a TTY
+ /// and refuses (errors) when there is no TTY or `--json` is set.
+ #[arg(long, global = true)]
+ pub(crate) yes: bool,
+
+ /// Suppress the one-line resolved-write-target diagnostic that write
+ /// commands echo to stderr (RFC-011 Decision 9).
+ #[arg(long, global = true)]
+ pub(crate) quiet: bool,
+
#[command(subcommand)]
pub(crate) command: Command,
}
@@ -70,22 +92,16 @@ pub(crate) enum Command {
/// when used. Pairs with `omnigraph mutate` on the write side.
#[command(visible_alias = "read")]
Query {
- /// Graph URI
- #[arg(long)]
- uri: Option,
- #[arg(hide = true)]
- legacy_uri: Option,
- #[arg(long)]
- config: Option,
- #[arg(long, conflicts_with_all = ["query", "query_string"])]
- alias: Option,
- #[arg(long, conflicts_with_all = ["alias", "query_string"])]
- query: Option,
- /// Inline GQ source — alternative to `--query ` and `--alias `.
- #[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with_all = ["query", "alias"])]
- query_string: Option,
- #[arg(long)]
+ /// Query name. With no `--query`/`-e`, the stored query to invoke from
+ /// the catalog (served — addressed via --server/--profile). With
+ /// `--query`/`-e`, selects which query in that ad-hoc source to run.
name: Option,
+ /// Ad-hoc query file (a `.gq` you're authoring / break-glass).
+ #[arg(long, conflicts_with = "query_string")]
+ query: Option,
+ /// Inline ad-hoc GQ source — alternative to `--query `.
+ #[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with = "query")]
+ query_string: Option,
#[command(flatten)]
params: ParamsArgs,
#[arg(long, conflicts_with = "snapshot")]
@@ -96,8 +112,6 @@ pub(crate) enum Command {
format: Option,
#[arg(long, conflicts_with = "format")]
json: bool,
- #[arg()]
- alias_args: Vec,
},
/// Execute a graph mutation query against a branch.
///
@@ -106,38 +120,48 @@ pub(crate) enum Command {
/// warning when used. Pairs with `omnigraph query` on the read side.
#[command(visible_alias = "change")]
Mutate {
- /// Graph URI
- #[arg(long)]
- uri: Option,
- #[arg(hide = true)]
- legacy_uri: Option,
- #[arg(long)]
- config: Option,
- #[arg(long, conflicts_with_all = ["query", "query_string"])]
- alias: Option,
- #[arg(long, conflicts_with_all = ["alias", "query_string"])]
- query: Option,
- /// Inline GQ source — alternative to `--query ` and `--alias `.
- #[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with_all = ["query", "alias"])]
- query_string: Option,
- #[arg(long)]
+ /// Query name. With no `--query`/`-e`, the stored mutation to invoke
+ /// from the catalog (served — addressed via --server/--profile). With
+ /// `--query`/`-e`, selects which query in that ad-hoc source to run.
name: Option,
+ /// Ad-hoc mutation file (a `.gq` you're authoring / break-glass).
+ #[arg(long, conflicts_with = "query_string")]
+ query: Option,
+ /// Inline ad-hoc GQ source — alternative to `--query `.
+ #[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with = "query")]
+ query_string: Option,
#[command(flatten)]
params: ParamsArgs,
#[arg(long)]
branch: Option,
#[arg(long)]
json: bool,
- #[arg()]
- alias_args: Vec,
+ },
+ /// Invoke an operator alias (RFC-011 Decision 4).
+ ///
+ /// An alias is a personal binding under `aliases:` in
+ /// ~/.omnigraph/config.yaml — name → (server, graph, stored-query name,
+ /// default params). `omnigraph alias [args]` invokes the bound
+ /// stored query on its server. Living in its own namespace, an alias can
+ /// never shadow or be shadowed by a built-in verb. Replaces the removed
+ /// `--alias` flag on `query`/`mutate`.
+ Alias {
+ /// Alias name (a key under `aliases:` in ~/.omnigraph/config.yaml).
+ name: String,
+ /// Positional args bound to the alias's declared `args` params, in order.
+ args: Vec,
+ #[command(flatten)]
+ params: ParamsArgs,
+ #[arg(long, conflicts_with = "json")]
+ format: Option,
+ #[arg(long, conflicts_with = "format")]
+ json: bool,
},
/// Load data into a graph (local or remote)
Load {
/// Graph URI
uri: Option,
#[arg(long)]
- config: Option,
- #[arg(long)]
data: PathBuf,
/// Target branch (defaults to main). Without --from it must exist.
#[arg(long)]
@@ -159,8 +183,6 @@ pub(crate) enum Command {
/// Graph URI
uri: Option,
#[arg(long)]
- config: Option,
- #[arg(long)]
data: PathBuf,
#[arg(long)]
branch: Option,
@@ -181,8 +203,6 @@ pub(crate) enum Command {
/// Graph URI
uri: Option,
#[arg(long)]
- config: Option,
- #[arg(long)]
branch: Option,
#[arg(long)]
json: bool,
@@ -192,8 +212,6 @@ pub(crate) enum Command {
/// Graph URI
uri: Option,
#[arg(long)]
- config: Option,
- #[arg(long)]
branch: Option,
#[arg(long, hide = true)]
jsonl: bool,
@@ -238,30 +256,12 @@ pub(crate) enum Command {
/// Graph URI
uri: Option,
#[arg(long)]
- config: Option,
- /// Cluster directory or storage-root URI; with --cluster-graph, resolves
- /// the graph's storage URI from the served cluster state.
- #[arg(long, conflicts_with = "uri", requires = "cluster_graph")]
- cluster: Option,
- /// Graph id within --cluster.
- #[arg(long, requires = "cluster")]
- cluster_graph: Option,
- #[arg(long)]
json: bool,
},
/// Classify and explicitly repair manifest/head drift
Repair {
/// Graph URI
uri: Option,
- #[arg(long)]
- config: Option,
- /// Cluster directory or storage-root URI; with --cluster-graph, resolves
- /// the graph's storage URI from the served cluster state.
- #[arg(long, conflicts_with = "uri", requires = "cluster_graph")]
- cluster: Option,
- /// Graph id within --cluster.
- #[arg(long, requires = "cluster")]
- cluster_graph: Option,
/// Publish verified maintenance drift. Without this flag, repair only
/// previews what it would do.
#[arg(long)]
@@ -277,15 +277,6 @@ pub(crate) enum Command {
Cleanup {
/// Graph URI
uri: Option,
- #[arg(long)]
- config: Option,
- /// Cluster directory or storage-root URI; with --cluster-graph, resolves
- /// the graph's storage URI from the served cluster state.
- #[arg(long, conflicts_with = "uri", requires = "cluster_graph")]
- cluster: Option,
- /// Graph id within --cluster.
- #[arg(long, requires = "cluster")]
- cluster_graph: Option,
/// Number of recent versions to keep per table. Either `--keep` or
/// `--older-than` (or both) must be set.
#[arg(long)]
@@ -315,8 +306,6 @@ pub(crate) enum Command {
/// Graph URI
uri: Option,
#[arg(long)]
- config: Option,
- #[arg(long)]
query: PathBuf,
#[arg(long)]
schema: Option,
@@ -336,8 +325,7 @@ pub(crate) enum Command {
command: ClusterCommand,
},
- // ── Session / config ── no graph addressing; local tooling.
- /// Policy administration and diagnostics
+ /// Policy administration and diagnostics against a cluster's applied bundles
Policy {
#[command(subcommand)]
command: PolicyCommand,
@@ -363,16 +351,32 @@ pub(crate) enum Command {
#[arg(long)]
json: bool,
},
- /// Legacy-config tooling (RFC-008): split omnigraph.yaml into its
- /// two destinations.
- Config {
+ /// Inspect the scope profiles in ~/.omnigraph/config.yaml (read-only).
+ Profile {
#[command(subcommand)]
- command: ConfigCommand,
+ command: ProfileCommand,
},
/// Print the CLI version
Version,
}
+#[derive(Debug, Subcommand)]
+pub(crate) enum ProfileCommand {
+ /// List the profiles defined in ~/.omnigraph/config.yaml.
+ List {
+ #[arg(long)]
+ json: bool,
+ },
+ /// Show a profile's resolved scope. With no name, shows the active
+ /// (`$OMNIGRAPH_PROFILE`) profile, else the flat operator defaults.
+ Show {
+ /// Profile name (optional).
+ name: Option,
+ #[arg(long)]
+ json: bool,
+ },
+}
+
#[derive(Debug, Subcommand)]
pub(crate) enum ClusterCommand {
/// Validate cluster.yaml and referenced schemas, queries, and policy files.
@@ -469,8 +473,6 @@ pub(crate) enum GraphsCommand {
#[arg(long)]
uri: Option,
#[arg(long)]
- config: Option,
- #[arg(long)]
json: bool,
},
}
@@ -483,8 +485,6 @@ pub(crate) enum BranchCommand {
#[arg(long)]
uri: Option,
#[arg(long)]
- config: Option,
- #[arg(long)]
from: Option,
name: String,
#[arg(long)]
@@ -496,8 +496,6 @@ pub(crate) enum BranchCommand {
#[arg(long)]
uri: Option,
#[arg(long)]
- config: Option,
- #[arg(long)]
json: bool,
},
/// Delete a branch
@@ -505,8 +503,6 @@ pub(crate) enum BranchCommand {
/// Graph URI
#[arg(long)]
uri: Option,
- #[arg(long)]
- config: Option,
name: String,
#[arg(long)]
json: bool,
@@ -516,8 +512,6 @@ pub(crate) enum BranchCommand {
/// Graph URI
#[arg(long)]
uri: Option,
- #[arg(long)]
- config: Option,
source: String,
#[arg(long)]
into: Option,
@@ -533,8 +527,6 @@ pub(crate) enum SchemaCommand {
/// Graph URI
uri: Option,
#[arg(long)]
- config: Option,
- #[arg(long)]
schema: PathBuf,
#[arg(long)]
json: bool,
@@ -549,8 +541,6 @@ pub(crate) enum SchemaCommand {
/// Graph URI
uri: Option,
#[arg(long)]
- config: Option,
- #[arg(long)]
schema: PathBuf,
#[arg(long)]
json: bool,
@@ -572,8 +562,6 @@ pub(crate) enum SchemaCommand {
/// Graph URI
uri: Option,
#[arg(long)]
- config: Option,
- #[arg(long)]
json: bool,
},
}
@@ -586,8 +574,6 @@ pub(crate) enum CommitCommand {
/// Graph URI
uri: Option,
#[arg(long)]
- config: Option,
- #[arg(long)]
branch: Option,
#[arg(long)]
json: bool,
@@ -597,8 +583,6 @@ pub(crate) enum CommitCommand {
/// Graph URI
#[arg(long)]
uri: Option,
- #[arg(long)]
- config: Option,
commit_id: String,
#[arg(long)]
json: bool,
@@ -607,20 +591,24 @@ pub(crate) enum CommitCommand {
#[derive(Debug, Subcommand)]
pub(crate) enum PolicyCommand {
- /// Validate policy YAML and compiled Cedar policy state
- Validate {
- #[arg(long)]
- config: Option,
- },
- /// Run declarative policy tests from policy.tests.yaml
+ /// Compile and validate the Cedar policy bundle(s) applied in a cluster.
+ ///
+ /// Sources the bundle(s) from the cluster's applied policies
+ /// (`--cluster `); pass the global `--graph ` to pick one
+ /// graph's bundle when several apply.
+ Validate {},
+ /// Run declarative policy tests against a cluster's applied bundle.
+ ///
+ /// The cluster model has no per-bundle tests file, so the cases are
+ /// supplied explicitly with `--tests ` and checked against the
+ /// bundle selected by `--cluster` (+ optional `--graph`).
Test {
+ /// Path to a policy.tests.yaml file.
#[arg(long)]
- config: Option,
+ tests: PathBuf,
},
- /// Explain one policy decision locally
+ /// Explain one policy decision against a cluster's applied bundle.
Explain {
- #[arg(long)]
- config: Option,
#[arg(long)]
actor: String,
#[arg(long)]
@@ -634,24 +622,19 @@ pub(crate) enum PolicyCommand {
#[derive(Debug, Subcommand)]
pub(crate) enum QueriesCommand {
- /// Type-check the stored-query registry against the live schema.
+ /// Type-check a cluster's stored-query registry against its schemas.
///
- /// Distinct from `omnigraph lint` (which lints one `.gq` file):
- /// this validates the whole `queries:` registry — opening the graph
- /// to read its schema and confirming every stored query still
- /// type-checks. Exits non-zero on any breakage.
+ /// Distinct from `omnigraph lint` (which lints one `.gq` file): this
+ /// validates the whole `queries:` registry of a cluster (`--cluster
+ /// `, optional `--graph `) by reading each graph's applied
+ /// schema and confirming every stored query still type-checks. Exits
+ /// non-zero on any breakage.
Validate {
- /// Graph URI
- uri: Option,
- #[arg(long)]
- config: Option,
#[arg(long)]
json: bool,
},
- /// List the registered stored queries (name, MCP exposure, params).
+ /// List a cluster's registered stored queries (name, params).
List {
- #[arg(long)]
- config: Option,
#[arg(long)]
json: bool,
},
@@ -682,7 +665,6 @@ impl From for LoadMode {
}
}
}
-
impl CliLoadMode {
pub(crate) fn as_str(self) -> &'static str {
match self {
@@ -692,21 +674,3 @@ impl CliLoadMode {
}
}
}
-
-#[derive(Debug, Subcommand)]
-pub(crate) enum ConfigCommand {
- /// Propose (and with --write, apply) the RFC-008 split of a legacy
- /// omnigraph.yaml: team half -> a ready-to-review cluster.yaml,
- /// personal half -> ~/.omnigraph/config.yaml (key-level merge,
- /// existing entries always win). Touches nothing without --write.
- Migrate {
- /// Path to the legacy omnigraph.yaml (default: ./omnigraph.yaml)
- #[arg(long)]
- config: Option,
- /// Apply the split instead of only printing it
- #[arg(long)]
- write: bool,
- #[arg(long)]
- json: bool,
- },
-}
diff --git a/crates/omnigraph-cli/src/client.rs b/crates/omnigraph-cli/src/client.rs
index 5c427f2..7151f5e 100644
--- a/crates/omnigraph-cli/src/client.rs
+++ b/crates/omnigraph-cli/src/client.rs
@@ -29,7 +29,8 @@ use omnigraph::db::{Omnigraph, ReadTarget};
use omnigraph_api_types::{
BranchCreateOutput, BranchCreateRequest, BranchDeleteOutput, BranchListOutput,
BranchMergeOutput, BranchMergeRequest, ChangeOutput, CommitListOutput, CommitOutput,
- ErrorOutput, ExportRequest, GraphListResponse, IngestOutput, IngestRequest, ReadOutput,
+ ErrorOutput, ExportRequest, GraphListResponse, IngestOutput, IngestRequest,
+ InvokeStoredQueryRequest, ReadOutput,
ReadRequest, SchemaApplyOutput, SchemaApplyRequest, SchemaOutput, SnapshotOutput, commit_output,
ingest_output, read_output, schema_apply_output, snapshot_payload,
};
@@ -39,22 +40,20 @@ use serde_json::Value;
use crate::cli::CliLoadMode;
use crate::helpers::{
- ResolvedCliGraph, apply_bearer_token, apply_server_flag, build_http_client, is_remote_uri,
- legacy_change_request_body, open_local_db_with_policy, query_params_from_json,
+ apply_bearer_token, apply_server_flag, build_http_client, is_remote_uri,
+ legacy_change_request_body, query_params_from_json,
remote_json, remote_url, resolve_cli_actor, resolve_cli_graph, resolve_remote_bearer_token,
- select_named_query,
+ resolve_server_flag, select_named_query,
};
use crate::output::{LoadOutput, load_output_from_result, load_output_from_tables};
-use omnigraph_server::config::OmnigraphConfig;
pub(crate) enum GraphClient {
- /// Local engine at `uri`. Reads (`resolve()`) leave `graph`/`actor`
- /// empty and open without policy; writes (`resolve_with_policy()`)
- /// fill them, opening through `open_local_db_with_policy` and
- /// attributing the resolved actor.
+ /// Local engine at `uri`. Reads (`resolve()`) leave `actor` empty;
+ /// writes (`resolve_with_policy()`) attribute the resolved actor.
+ /// Direct-store access carries no Cedar policy (RFC-011: policy lives
+ /// in the cluster/server, not in per-operator addressing).
Embedded {
uri: String,
- graph: Option,
actor: Option,
},
/// Remote HTTP server. The actor is resolved server-side from the
@@ -66,6 +65,43 @@ pub(crate) enum GraphClient {
},
}
+/// RFC-011 Decision 7: a server scope that selects no graph (no `--graph`, no
+/// `default_graph`) must not silently fall through to the bare server URL when
+/// the server is multi-graph. Best-effort probe `GET /graphs`: a populated list
+/// forces `--graph` (listing the candidates); a single-graph/flat server (405),
+/// a policy-gated `/graphs`, or an unreachable server all proceed — the bare URL
+/// is then correct, or the real request surfaces the failure. Only fires on the
+/// no-graph path, so a `--graph`/`default_graph` happy path does no extra I/O.
+async fn require_graph_for_multi_graph_server(
+ scope: &crate::scope::ResolvedScope,
+) -> Result<()> {
+ let (Some(server), None) = (scope.server.as_deref(), scope.graph.as_deref()) else {
+ return Ok(());
+ };
+ let Some(base) = resolve_server_flag(Some(server), None)? else {
+ return Ok(());
+ };
+ let token = resolve_remote_bearer_token(Some(&base))?;
+ let probe = GraphClient::Remote {
+ http: build_http_client()?,
+ base_url: base,
+ token,
+ };
+ if let Ok(resp) = probe.list_graphs().await {
+ if !resp.graphs.is_empty() {
+ let ids: Vec<&str> = resp.graphs.iter().map(|g| g.graph_id.as_str()).collect();
+ bail!(
+ "server scope '{server}' has {} {}: [{}]; pass --graph to select one \
+ (or set `default_graph` in your operator config)",
+ ids.len(),
+ if ids.len() == 1 { "graph" } else { "graphs" },
+ ids.join(", ")
+ );
+ }
+ }
+ Ok(())
+}
+
/// A remote graph must be addressed with `--server` (RFC-011): a positional or
/// `--uri` `http(s)://` URL no longer auto-dispatches to a server. A remote URL
/// produced by a server scope (`via_server`) is fine.
@@ -86,8 +122,7 @@ impl GraphClient {
/// fork. Mirrors the read verbs' current preamble (`resolve_uri`
/// path, not the policy-bearing `resolve_cli_graph`). Used by reads
/// and `query` (which opens without policy, like the reads).
- pub(crate) fn resolve(
- config: &OmnigraphConfig,
+ pub(crate) async fn resolve(
server: Option<&str>,
graph: Option<&str>,
uri: Option,
@@ -100,8 +135,9 @@ impl GraphClient {
let scope = crate::scope::resolve_scope(
&crate::operator::load_operator_config()?,
crate::planes::Capability::Any,
- crate::scope::ScopeFlags { profile, store, server, graph, uri },
+ crate::scope::ScopeFlags { profile, store, server, cluster: None, graph, uri },
)?;
+ require_graph_for_multi_graph_server(&scope).await?;
let (server, graph, uri) = (
scope.server.as_deref(),
scope.graph.as_deref(),
@@ -109,8 +145,8 @@ impl GraphClient {
);
let via_server = server.is_some();
let uri = apply_server_flag(server, graph, uri)?;
- let token = resolve_remote_bearer_token(config, uri.as_deref())?;
- let uri = crate::helpers::resolve_uri(config, uri)?;
+ let token = resolve_remote_bearer_token(uri.as_deref())?;
+ let uri = crate::helpers::resolve_uri(uri)?;
reject_positional_remote(via_server, &uri)?;
if is_remote_uri(&uri) {
Ok(GraphClient::Remote {
@@ -119,11 +155,7 @@ impl GraphClient {
token,
})
} else {
- Ok(GraphClient::Embedded {
- uri,
- graph: None,
- actor: None,
- })
+ Ok(GraphClient::Embedded { uri, actor: None })
}
}
@@ -133,8 +165,7 @@ impl GraphClient {
/// resolved up front. The embedded arm then opens WITH policy. The
/// resolution order matches the write arms exactly: server flag →
/// bearer token → graph.
- pub(crate) fn resolve_with_policy(
- config: &OmnigraphConfig,
+ pub(crate) async fn resolve_with_policy(
server: Option<&str>,
graph: Option<&str>,
uri: Option,
@@ -147,8 +178,9 @@ impl GraphClient {
let scope = crate::scope::resolve_scope(
&crate::operator::load_operator_config()?,
crate::planes::Capability::Any,
- crate::scope::ScopeFlags { profile, store, server, graph, uri },
+ crate::scope::ScopeFlags { profile, store, server, cluster: None, graph, uri },
)?;
+ require_graph_for_multi_graph_server(&scope).await?;
let (server, graph, uri) = (
scope.server.as_deref(),
scope.graph.as_deref(),
@@ -156,8 +188,8 @@ impl GraphClient {
);
let via_server = server.is_some();
let uri = apply_server_flag(server, graph, uri)?;
- let token = resolve_remote_bearer_token(config, uri.as_deref())?;
- let resolved = resolve_cli_graph(config, uri)?;
+ let token = resolve_remote_bearer_token(uri.as_deref())?;
+ let resolved = resolve_cli_graph(uri)?;
reject_positional_remote(via_server, &resolved.uri)?;
if resolved.is_remote {
// A served write resolves the actor server-side from the bearer
@@ -175,10 +207,9 @@ impl GraphClient {
token,
})
} else {
- let actor = resolve_cli_actor(cli_as, config)?;
+ let actor = resolve_cli_actor(cli_as)?;
Ok(GraphClient::Embedded {
- uri: resolved.uri.clone(),
- graph: Some(resolved),
+ uri: resolved.uri,
actor,
})
}
@@ -192,28 +223,15 @@ impl GraphClient {
}
}
- /// The selected graph name, when a policy-bearing embedded client was
- /// resolved against a named graph. `None` for remote and for reads.
- pub(crate) fn selected(&self) -> Option<&str> {
- match self {
- GraphClient::Embedded { graph, .. } => graph.as_ref().and_then(ResolvedCliGraph::selected),
- GraphClient::Remote { .. } => None,
- }
- }
-
pub(crate) fn is_remote(&self) -> bool {
matches!(self, GraphClient::Remote { .. })
}
- /// Open the local engine the way the resolved client demands: with
- /// policy when a `graph` context is present (write path), bare
- /// otherwise (read/`query` path). Captures today's two open paths in
- /// one place so each verb stays a single match arm.
- async fn open_embedded(uri: &str, graph: &Option) -> Result {
- match graph {
- Some(graph) => open_local_db_with_policy(graph).await,
- None => Ok(Omnigraph::open(uri).await?),
- }
+ /// Open the local engine. Direct-store access carries no Cedar policy
+ /// (RFC-011), so both read and write paths open bare; the actor is still
+ /// attributed on the write via the `_as` engine APIs.
+ async fn open_embedded(uri: &str) -> Result {
+ Ok(Omnigraph::open(uri).await?)
}
pub(crate) async fn branch_list(&self) -> Result {
@@ -375,8 +393,8 @@ impl GraphClient {
.await?;
Ok(load_output_from_tables(base_url, branch, mode.as_str(), &output))
}
- GraphClient::Embedded { uri, graph, actor } => {
- let db = Self::open_embedded(uri, graph).await?;
+ GraphClient::Embedded { uri, actor } => {
+ let db = Self::open_embedded(uri).await?;
let result = db
.load_file_as(branch, from, data, mode.into(), actor.as_deref())
.await?;
@@ -418,8 +436,8 @@ impl GraphClient {
)
.await
}
- GraphClient::Embedded { uri, graph, actor } => {
- let db = Self::open_embedded(uri, graph).await?;
+ GraphClient::Embedded { uri, actor } => {
+ let db = Self::open_embedded(uri).await?;
let result = db
.load_file_as(branch, Some(from), data, mode.into(), actor.as_deref())
.await?;
@@ -457,10 +475,10 @@ impl GraphClient {
)
.await
}
- GraphClient::Embedded { uri, graph, actor } => {
+ GraphClient::Embedded { uri, actor } => {
let (selected_name, query_params) = select_named_query(query_source, query_name)?;
let params = query_params_from_json(&query_params, params_json)?;
- let db = Self::open_embedded(uri, graph).await?;
+ let db = Self::open_embedded(uri).await?;
let actor = actor.as_deref();
let result = db
.mutate_as(branch, query_source, &selected_name, ¶ms, actor)
@@ -511,10 +529,10 @@ impl GraphClient {
)
.await
}
- GraphClient::Embedded { uri, graph, .. } => {
+ GraphClient::Embedded { uri, .. } => {
let (selected_name, query_params) = select_named_query(query_source, query_name)?;
let params = query_params_from_json(&query_params, params_json)?;
- let db = Self::open_embedded(uri, graph).await?;
+ let db = Self::open_embedded(uri).await?;
let result = db
.query(target.clone(), query_source, &selected_name, ¶ms)
.await?;
@@ -523,6 +541,50 @@ impl GraphClient {
}
}
+ /// `invoke_named` — run a stored query **by catalog name** (RFC-011 D3).
+ /// Served-only: the catalog is server-owned, so a `--store` (embedded)
+ /// scope has nothing to resolve the name against. `expect_mutation` carries
+ /// the verb's asserted kind; the server rejects a mismatch (400) before
+ /// running, so the response is exactly the expected envelope — the caller
+ /// deserializes it as the concrete `T` (`ReadOutput` for `query`,
+ /// `ChangeOutput` for `mutate`), sidestepping the untagged wire enum.
+ pub(crate) async fn invoke_named(
+ &self,
+ name: &str,
+ expect_mutation: bool,
+ params_json: Option<&Value>,
+ branch: Option,
+ snapshot: Option,
+ ) -> Result {
+ match self {
+ GraphClient::Remote {
+ http,
+ base_url,
+ token,
+ } => {
+ let body = InvokeStoredQueryRequest {
+ params: params_json.cloned(),
+ branch,
+ snapshot,
+ expect_mutation: Some(expect_mutation),
+ };
+ remote_json(
+ http,
+ Method::POST,
+ remote_url(base_url, &["queries", name], &[])?,
+ Some(serde_json::to_value(body)?),
+ token.as_deref(),
+ )
+ .await
+ }
+ GraphClient::Embedded { .. } => bail!(
+ "by-name invocation needs a server (the stored-query catalog is \
+ server-owned); use -e '' or --query for an ad-hoc query \
+ against --store, or address a server with --server / --profile"
+ ),
+ }
+ }
+
pub(crate) async fn branch_create_from(
&self,
from: &str,
@@ -546,8 +608,8 @@ impl GraphClient {
)
.await
}
- GraphClient::Embedded { uri, graph, actor } => {
- let db = Self::open_embedded(uri, graph).await?;
+ GraphClient::Embedded { uri, actor } => {
+ let db = Self::open_embedded(uri).await?;
let actor = actor.as_deref();
db.branch_create_from_as(ReadTarget::branch(from), name, actor)
.await?;
@@ -577,8 +639,8 @@ impl GraphClient {
)
.await
}
- GraphClient::Embedded { uri, graph, actor } => {
- let db = Self::open_embedded(uri, graph).await?;
+ GraphClient::Embedded { uri, actor } => {
+ let db = Self::open_embedded(uri).await?;
let actor = actor.as_deref();
db.branch_delete_as(name, actor).await?;
Ok(BranchDeleteOutput {
@@ -609,8 +671,8 @@ impl GraphClient {
)
.await
}
- GraphClient::Embedded { uri, graph, actor } => {
- let db = Self::open_embedded(uri, graph).await?;
+ GraphClient::Embedded { uri, actor } => {
+ let db = Self::open_embedded(uri).await?;
let actor = actor.as_deref();
let outcome = db.branch_merge_as(source, into, actor).await?;
Ok(BranchMergeOutput {
@@ -660,8 +722,8 @@ impl GraphClient {
)
.await
}
- GraphClient::Embedded { uri, graph, actor } => {
- let db = Self::open_embedded(uri, graph).await?;
+ GraphClient::Embedded { uri, actor } => {
+ let db = Self::open_embedded(uri).await?;
let result = db
.apply_schema_as_with_catalog_check(
schema_source,
@@ -730,9 +792,9 @@ impl GraphClient {
/// `graphs list` — enumerate the graphs a remote multi-graph server
/// serves (`GET /graphs`). Remote-only by design: there is no local
- /// enumeration endpoint, so the Embedded arm fails loudly pointing the
- /// operator at `omnigraph.yaml`. Routing it through the enum still buys
- /// the shared `resolve()` addressing/token preamble.
+ /// enumeration endpoint, so the Embedded arm fails loudly. Routing it
+ /// through the enum still buys the shared `resolve()` addressing/token
+ /// preamble.
pub(crate) async fn list_graphs(&self) -> Result {
match self {
GraphClient::Remote {
@@ -750,9 +812,9 @@ impl GraphClient {
.await
}
GraphClient::Embedded { .. } => bail!(
- "`omnigraph graphs list` requires a remote multi-graph server URL \
- (http:// or https://). To enumerate local graphs, read `omnigraph.yaml` \
- directly."
+ "`omnigraph graphs list` requires a remote multi-graph server \
+ (--server ). To enumerate the graphs in a cluster, run \
+ `omnigraph cluster status --config `."
),
}
}
diff --git a/crates/omnigraph-cli/src/helpers.rs b/crates/omnigraph-cli/src/helpers.rs
index d49d17f..971ca30 100644
--- a/crates/omnigraph-cli/src/helpers.rs
+++ b/crates/omnigraph-cli/src/helpers.rs
@@ -2,6 +2,8 @@
//! remote HTTP, env/token handling, scaffolding (moved verbatim from
//! main.rs in the modularization).
+use std::io::IsTerminal;
+
use super::*;
use crate::operator;
@@ -16,6 +18,59 @@ pub(crate) fn is_remote_uri(uri: &str) -> bool {
uri.starts_with("http://") || uri.starts_with("https://")
}
+/// Whether a resolved write target is *local* for the purposes of the RFC-011
+/// Decision 9 destructive-confirm gate: a bare path or a `file://` URI. Anything
+/// else carrying a scheme — `http(s)://` (served), `s3://` / `gs://` / … (object
+/// store) — is non-local and a destructive write against it requires explicit
+/// consent. Generalizes `is_remote_uri` (which only catches http(s)).
+pub(crate) fn uri_is_local(uri: &str) -> bool {
+ !uri.contains("://") || uri.starts_with("file://")
+}
+
+/// Echo the resolved write target + access path to stderr (RFC-011 Decision 9),
+/// unless `--quiet`. One line, e.g. `omnigraph load → file://g.omni (direct,
+/// local)`. stderr so `--json` consumers reading stdout are unaffected; the line
+/// legitimately differs embedded-vs-served (that visibility is the point).
+pub(crate) fn echo_write_target(quiet: bool, label: &str, uri: &str, served: bool) {
+ if quiet {
+ return;
+ }
+ let access = if served {
+ "served"
+ } else if uri_is_local(uri) {
+ "direct, local"
+ } else {
+ "direct, remote"
+ };
+ eprintln!("omnigraph {label} → {uri} ({access})");
+}
+
+/// Gate a destructive write (`cleanup`, overwrite `load`, `branch delete`)
+/// against a non-local scope (RFC-011 Decision 9). A local target needs no
+/// confirmation; otherwise `--yes` consents, an interactive TTY is prompted, and
+/// a non-TTY / `--json` run refuses rather than silently proceeding.
+pub(crate) fn confirm_destructive(label: &str, uri: &str, yes: bool, json: bool) -> Result<()> {
+ if uri_is_local(uri) || yes {
+ return Ok(());
+ }
+ if json || !std::io::stdin().is_terminal() {
+ bail!(
+ "refusing destructive `{label}` against non-local target {uri} without confirmation; \
+ pass --yes to confirm (an interactive TTY would be prompted instead)"
+ );
+ }
+ eprint!(
+ "About to run a destructive `{label}` against {uri} (not local). Type 'yes' to continue: "
+ );
+ io::stderr().flush()?;
+ let mut answer = String::new();
+ io::stdin().read_line(&mut answer)?;
+ match answer.trim().to_ascii_lowercase().as_str() {
+ "yes" | "y" => Ok(()),
+ _ => bail!("aborted: destructive `{label}` not confirmed"),
+ }
+}
+
/// THE one way the CLI composes a remote request URL. Every remote call
/// routes through here so URL assembly has a single mechanism instead of
/// per-callsite string interpolation.
@@ -64,231 +119,174 @@ pub(crate) fn bearer_token_from_env(var_name: &str) -> Option {
normalize_bearer_token(std::env::var(var_name).ok())
}
-pub(crate) fn parse_env_assignment(line: &str) -> Option<(String, String)> {
- let line = line.trim();
- if line.is_empty() || line.starts_with('#') {
- return None;
- }
-
- let line = line.strip_prefix("export ").unwrap_or(line).trim();
- let (name, value) = line.split_once('=')?;
- let name = name.trim();
- if name.is_empty() {
- return None;
- }
-
- let value = value.trim();
- let value = if value.len() >= 2
- && ((value.starts_with('"') && value.ends_with('"'))
- || (value.starts_with('\'') && value.ends_with('\'')))
- {
- &value[1..value.len() - 1]
- } else {
- value
- };
-
- Some((name.to_string(), value.to_string()))
-}
-
-pub(crate) fn bearer_token_from_env_file(path: &Path, var_name: &str) -> Result