From 7c092d3206e2e16bd8d3bcf47e52ed786d9e440e Mon Sep 17 00:00:00 2001 From: Andrew Altshuler Date: Mon, 15 Jun 2026 23:33:01 +0300 Subject: [PATCH] feat(cli): add read-only `profile list` / `profile show` (RFC-011 D8) (#255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inspect the per-operator `~/.omnigraph/config.yaml` scope profiles without running anything: - `profile list [--json]` — every profile with its binding (server/cluster/store) and default graph; marks the `$OMNIGRAPH_PROFILE`-active one. A malformed (zero/two-scope) profile is reported as `invalid: `, not a hard failure. - `profile show [] [--json]` — one profile's resolved scope: binding kind + target, the resolved endpoint (a server's URL / a cluster's root / the store URI), default graph, and output format. With no name, shows the active (`$OMNIGRAPH_PROFILE`) profile, else the flat operator defaults. Both are `local` (Session plane) — they read operator config only, take no addressing flags. Display reads `OperatorProfile::binding()` + the same `servers`/`clusters` lookups the scope resolver uses (not `resolve_scope`, which is capability-gated and can't render all three binding kinds at once), so it is honest about what a profile binds. Also: RFC-011 bookkeeping (Status → Accepted; D8 shipped, D11 gated on RFC #219, D5 deferred) and drop the stale "legacy config actor (RFC-008 window)" comment in operator.rs (the legacy actor is gone). Co-authored-by: Claude Opus 4.8 --- crates/omnigraph-cli/src/cli.rs | 22 ++++ crates/omnigraph-cli/src/main.rs | 89 ++++++++++++++++ crates/omnigraph-cli/src/operator.rs | 3 +- crates/omnigraph-cli/src/output.rs | 69 ++++++++++++ crates/omnigraph-cli/src/planes.rs | 2 + crates/omnigraph-cli/tests/cli_data.rs | 140 +++++++++++++++++++++++++ docs/dev/rfc-011-cli-refactoring.md | 12 ++- docs/user/cli/reference.md | 4 +- 8 files changed, 333 insertions(+), 8 deletions(-) diff --git a/crates/omnigraph-cli/src/cli.rs b/crates/omnigraph-cli/src/cli.rs index 62e9608..ac3d7b4 100644 --- a/crates/omnigraph-cli/src/cli.rs +++ b/crates/omnigraph-cli/src/cli.rs @@ -352,10 +352,32 @@ pub(crate) enum Command { #[arg(long)] json: bool, }, + /// Inspect the scope profiles in ~/.omnigraph/config.yaml (read-only). + Profile { + #[command(subcommand)] + 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. diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index 1817aaa..744189f 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -94,6 +94,95 @@ async fn main() -> Result<()> { let path = crate::operator::remove_credential(&name)?; finish_logout(&name, &path, json)?; } + Command::Profile { command } => { + use crate::operator::ScopeBinding; + let op = crate::operator::load_operator_config()?; + let active = std::env::var(scope::PROFILE_ENV) + .ok() + .filter(|s| !s.is_empty()); + match command { + ProfileCommand::List { json } => { + let items: Vec = op + .profiles + .iter() + .map(|(name, profile)| { + let binding = match profile.binding(name) { + Ok(ScopeBinding::Server(s)) => format!("server: {s}"), + Ok(ScopeBinding::Cluster(c)) => format!("cluster: {c}"), + Ok(ScopeBinding::Store(u)) => format!("store: {u}"), + Err(e) => format!("invalid: {e}"), + }; + ProfileListItem { + name: name.clone(), + binding, + default_graph: profile.default_graph.clone(), + active: active.as_deref() == Some(name.as_str()), + } + }) + .collect(); + print_profile_list(&items, json)?; + } + ProfileCommand::Show { name, json } => { + let detail = match name.or(active) { + Some(name) => { + let profile = op.profile(&name).ok_or_else(|| { + color_eyre::eyre::eyre!( + "unknown profile '{name}' (not defined under `profiles:`)" + ) + })?; + let (kind, target, endpoint) = match profile.binding(&name)? { + ScopeBinding::Server(s) => { + let endpoint = op.servers.get(&s).map(|sv| sv.url.clone()); + ("server", Some(s), endpoint) + } + ScopeBinding::Cluster(c) => { + let endpoint = op.cluster_root(&c).map(str::to_string); + ("cluster", Some(c), endpoint) + } + ScopeBinding::Store(u) => ("store", Some(u.clone()), Some(u)), + }; + ProfileDetail { + name, + scope_kind: kind.to_string(), + target, + endpoint, + default_graph: profile + .default_graph + .clone() + .or_else(|| op.default_graph().map(str::to_string)), + output_format: op + .output() + .and_then(|f| f.to_possible_value()) + .map(|v| v.get_name().to_string()), + } + } + // No name and no active profile: the flat operator defaults. + None => { + let (kind, target, endpoint) = if let Some(s) = op.default_server() { + let endpoint = op.servers.get(s).map(|sv| sv.url.clone()); + ("server", Some(s.to_string()), endpoint) + } else if let Some(u) = op.default_store() { + ("store", Some(u.to_string()), Some(u.to_string())) + } else { + ("none", None, None) + }; + ProfileDetail { + name: "(defaults)".to_string(), + scope_kind: kind.to_string(), + target, + endpoint, + default_graph: op.default_graph().map(str::to_string), + output_format: op + .output() + .and_then(|f| f.to_possible_value()) + .map(|v| v.get_name().to_string()), + } + } + }; + print_profile_detail(&detail, json)?; + } + } + } Command::Version => { println!("omnigraph {}", env!("CARGO_PKG_VERSION")); } diff --git a/crates/omnigraph-cli/src/operator.rs b/crates/omnigraph-cli/src/operator.rs index 96b4dc1..dbfe781 100644 --- a/crates/omnigraph-cli/src/operator.rs +++ b/crates/omnigraph-cli/src/operator.rs @@ -91,8 +91,7 @@ pub(crate) struct OperatorServer { #[derive(Debug, Default, Deserialize)] pub(crate) struct OperatorIdentity { /// Default actor for every `--as` cascade (CLI direct-engine writes and - /// cluster commands alike): `--as` > legacy config actor (RFC-008 - /// window) > this > none. + /// cluster commands alike): `--as` > this > none. pub(crate) actor: Option, #[serde(flatten)] unknown: serde_yaml::Mapping, diff --git a/crates/omnigraph-cli/src/output.rs b/crates/omnigraph-cli/src/output.rs index a5f75e7..25d1cc8 100644 --- a/crates/omnigraph-cli/src/output.rs +++ b/crates/omnigraph-cli/src/output.rs @@ -887,6 +887,75 @@ pub(crate) fn finish_logout( Ok(()) } +#[derive(Debug, Serialize)] +pub(crate) struct ProfileListItem { + pub(crate) name: String, + /// `server: ` / `cluster: ` / `store: ` / `invalid: `. + pub(crate) binding: String, + pub(crate) default_graph: Option, + pub(crate) active: bool, +} + +#[derive(Debug, Serialize)] +pub(crate) struct ProfileDetail { + /// Profile name, or `(defaults)` for the no-name flat-defaults view. + pub(crate) name: String, + /// `server` | `cluster` | `store` | `none`. + pub(crate) scope_kind: String, + /// The bound server/cluster name, or the store URI. + pub(crate) target: Option, + /// Resolved endpoint: a server's URL / a cluster's root / the store URI; + /// `None` if a named server/cluster isn't defined in this config. + pub(crate) endpoint: Option, + pub(crate) default_graph: Option, + pub(crate) output_format: Option, +} + +pub(crate) fn print_profile_list(items: &[ProfileListItem], json: bool) -> Result<()> { + if json { + return print_json(&items); + } + if items.is_empty() { + println!("no profiles defined in the operator config"); + return Ok(()); + } + for item in items { + let active = if item.active { " (active)" } else { "" }; + let graph = item + .default_graph + .as_deref() + .map(|g| format!(" · graph: {g}")) + .unwrap_or_default(); + println!("{}{active} {}{graph}", item.name, item.binding); + } + Ok(()) +} + +pub(crate) fn print_profile_detail(detail: &ProfileDetail, json: bool) -> Result<()> { + if json { + return print_json(detail); + } + println!("profile: {}", detail.name); + let target = detail + .target + .as_deref() + .map(|t| format!(" {t}")) + .unwrap_or_default(); + println!(" scope: {}{target}", detail.scope_kind); + if let Some(endpoint) = &detail.endpoint { + println!(" endpoint: {endpoint}"); + } else if matches!(detail.scope_kind.as_str(), "server" | "cluster") { + println!(" endpoint: (undefined — name not in this config)"); + } + if let Some(graph) = &detail.default_graph { + println!(" default graph: {graph}"); + } + if let Some(format) = &detail.output_format { + println!(" output: {format}"); + } + Ok(()) +} + /// Table prefs cascade (RFC-011): operator defaults.table_* > built-in. pub(crate) fn resolve_table_render_options() -> ReadRenderOptions { let operator = crate::operator::load_operator_config().unwrap_or_default(); diff --git a/crates/omnigraph-cli/src/planes.rs b/crates/omnigraph-cli/src/planes.rs index 70b8dc5..4308b4c 100644 --- a/crates/omnigraph-cli/src/planes.rs +++ b/crates/omnigraph-cli/src/planes.rs @@ -132,6 +132,7 @@ pub(crate) fn command_plane(cmd: &Command) -> Plane { Command::Embed(_) | Command::Login { .. } | Command::Logout { .. } + | Command::Profile { .. } | Command::Version => Plane::Session, } } @@ -143,6 +144,7 @@ pub(crate) fn command_label(cmd: &Command) -> &'static str { Command::Version => "version", Command::Login { .. } => "login", Command::Logout { .. } => "logout", + Command::Profile { .. } => "profile", Command::Embed(_) => "embed", Command::Init { .. } => "init", Command::Load { .. } => "load", diff --git a/crates/omnigraph-cli/tests/cli_data.rs b/crates/omnigraph-cli/tests/cli_data.rs index cb80472..b75177c 100644 --- a/crates/omnigraph-cli/tests/cli_data.rs +++ b/crates/omnigraph-cli/tests/cli_data.rs @@ -2069,3 +2069,143 @@ fn cli_fails_for_invalid_merge_requests() { .contains("distinct source and target") ); } + +/// RFC-011 Decision 8: `profile list` / `profile show` inspect the operator +/// config's profiles read-only. Hermetic via OMNIGRAPH_HOME. +fn profile_home() -> tempfile::TempDir { + let home = tempdir().unwrap(); + std::fs::write( + home.path().join("config.yaml"), + "operator:\n actor: act-andrew\n\ + defaults:\n output: json\n server: prod\n default_graph: knowledge\n\ + servers:\n prod:\n url: https://graph.example.com\n\ + clusters:\n brain:\n root: s3://acme/clusters/brain\n\ + profiles:\n\ + \x20 staging:\n server: prod\n default_graph: kb\n\ + \x20 brain-admin:\n cluster: brain\n\ + \x20 localdev:\n store: file:///data/dev.omni\n\ + \x20 broken:\n server: a\n store: b\n", + ) + .unwrap(); + home +} + +#[test] +fn profile_list_names_each_profile_with_its_binding_and_marks_active() { + let home = profile_home(); + let out = output_success( + cli() + .env("OMNIGRAPH_HOME", home.path()) + .env("OMNIGRAPH_PROFILE", "staging") + .arg("profile") + .arg("list"), + ); + let stdout = stdout_string(&out); + assert!(stdout.contains("staging (active)"), "{stdout}"); + assert!(stdout.contains("server: prod"), "{stdout}"); + assert!(stdout.contains("cluster: brain"), "{stdout}"); + assert!(stdout.contains("store: file:///data/dev.omni"), "{stdout}"); + // A malformed (two-scope) profile is reported, not a hard failure. + assert!(stdout.contains("broken") && stdout.contains("invalid:"), "{stdout}"); +} + +#[test] +fn profile_list_json_shape() { + let home = profile_home(); + let out = output_success( + cli() + .env("OMNIGRAPH_HOME", home.path()) + .arg("profile") + .arg("list") + .arg("--json"), + ); + let items: Value = serde_json::from_slice(&out.stdout).unwrap(); + let brain = items + .as_array() + .unwrap() + .iter() + .find(|p| p["name"] == "brain-admin") + .unwrap(); + assert_eq!(brain["binding"], "cluster: brain"); + assert_eq!(brain["active"], false); +} + +#[test] +fn profile_show_resolves_named_scope_endpoints() { + let home = profile_home(); + // A cluster profile resolves its root. + let cluster = output_success( + cli() + .env("OMNIGRAPH_HOME", home.path()) + .arg("profile") + .arg("show") + .arg("brain-admin"), + ); + let cs = stdout_string(&cluster); + assert!(cs.contains("scope: cluster brain"), "{cs}"); + assert!(cs.contains("endpoint: s3://acme/clusters/brain"), "{cs}"); + + // A store profile shows its URI as the endpoint. + let store = output_success( + cli() + .env("OMNIGRAPH_HOME", home.path()) + .arg("profile") + .arg("show") + .arg("localdev") + .arg("--json"), + ); + let detail: Value = serde_json::from_slice(&store.stdout).unwrap(); + assert_eq!(detail["scope_kind"], "store"); + assert_eq!(detail["endpoint"], "file:///data/dev.omni"); +} + +#[test] +fn profile_show_without_name_falls_back_to_flat_defaults() { + let home = profile_home(); + let out = output_success( + cli() + .env("OMNIGRAPH_HOME", home.path()) + .arg("profile") + .arg("show") + .arg("--json"), + ); + let detail: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(detail["name"], "(defaults)"); + assert_eq!(detail["scope_kind"], "server"); + assert_eq!(detail["endpoint"], "https://graph.example.com"); + assert_eq!(detail["default_graph"], "knowledge"); +} + +#[test] +fn profile_show_without_name_uses_active_env_profile() { + let home = profile_home(); + let out = output_success( + cli() + .env("OMNIGRAPH_HOME", home.path()) + .env("OMNIGRAPH_PROFILE", "brain-admin") + .arg("profile") + .arg("show") + .arg("--json"), + ); + let detail: Value = serde_json::from_slice(&out.stdout).unwrap(); + // No name arg, but $OMNIGRAPH_PROFILE selects brain-admin (not the flat defaults). + assert_eq!(detail["name"], "brain-admin"); + assert_eq!(detail["scope_kind"], "cluster"); + assert_eq!(detail["endpoint"], "s3://acme/clusters/brain"); + // output_format renders as the canonical lowercase value name. + assert_eq!(detail["output_format"], "json"); +} + +#[test] +fn profile_show_unknown_name_errors() { + let home = profile_home(); + let out = output_failure( + cli() + .env("OMNIGRAPH_HOME", home.path()) + .arg("profile") + .arg("show") + .arg("nope"), + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("unknown profile 'nope'"), "{stderr}"); +} diff --git a/docs/dev/rfc-011-cli-refactoring.md b/docs/dev/rfc-011-cli-refactoring.md index 768509b..d26dd84 100644 --- a/docs/dev/rfc-011-cli-refactoring.md +++ b/docs/dev/rfc-011-cli-refactoring.md @@ -1,6 +1,9 @@ # RFC-011: CLI refactoring — one addressing & config model -**Status:** Proposed +**Status:** Accepted — implemented (the `omnigraph.yaml` excision landed as +#250/#251/#252; D1–D4, D6, D7, D9, D10 shipped). Two items remain: **D11** +(server-side maintenance jobs) is gated on the bulk-data-plane RFC #219; **D5** +(combined admin scope) stays deferred by design. **Date:** 2026-06-14 **Audience:** CLI/server maintainers **Builds on:** [rfc-007-operator-config.md](rfc-007-operator-config.md) @@ -526,10 +529,9 @@ Non-blocking; settle when convenient. server scope and maintain via `--cluster`. A `deployments: { … }` object (server + cluster validated coherent, referenced by a profile) is revisited only if admin ergonomics demand it — and Decision 11 largely removes the need. -- **D8 — the `profile` command surface.** `profile list` / `profile show` - (read-only inspection) are additive diagnostics, shippable anytime; they don't - touch the grammar or resolution. The *no sticky `profile use`* constraint holds - regardless — it is a design principle, not a command. +- **D8 — the `profile` command surface.** *Shipped:* `profile list` / `profile + show []` (read-only inspection). The *no sticky `profile use`* constraint + holds — it is a design principle, not a command. ## Safety diff --git a/docs/user/cli/reference.md b/docs/user/cli/reference.md index 2c628fb..0a3b7eb 100644 --- a/docs/user/cli/reference.md +++ b/docs/user/cli/reference.md @@ -26,6 +26,7 @@ Top-level command families and subcommands. Graph-targeting commands accept a po | `cleanup --keep N --older-than 7d --confirm` | destructive version GC (`--confirm` to execute; also needs `--yes` against a non-local `s3://` target — see *Write diagnostics & destructive confirmation*) | | `embed` | offline JSONL embedding pipeline | | `policy validate \| test \| explain` | Cedar tooling against a cluster's applied policies (`--cluster `; `--graph ` picks a graph's bundle when several apply). `test` takes `--tests `; `explain` takes `--actor`/`--action`/`--branch`/`--target-branch` | +| `profile list \| show []` | read-only inspection of `~/.omnigraph/config.yaml` profiles. `list` shows each profile's binding (server/cluster/store) + default graph and marks the `$OMNIGRAPH_PROFILE`-active one; `show` resolves one profile's scope (endpoint + default graph), defaulting to the active profile, else the flat operator defaults | | `version` / `-v` | print `omnigraph 0.3.x` | ## Command capabilities @@ -106,7 +107,8 @@ address (a positional URI, `--server`, or `--store `); a named *local* default — mutually exclusive with `defaults.server`). A **profile** binds exactly one of `server` / `cluster` / `store` plus an optional default graph — config data, not state: every command resolves its scope fresh, there is no -sticky "current" mode. +sticky "current" mode. Inspect what is defined with `omnigraph profile list` and +`omnigraph profile show []` (read-only). - `--store ` addresses a single graph's storage directly (ad-hoc / break-glass). - A `cluster`-bound profile reaches `optimize` / `repair` / `cleanup` for a managed