feat(cli): add read-only profile list / profile show (RFC-011 D8) (#255)

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: <reason>`, not a hard failure.
- `profile show [<name>] [--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 <noreply@anthropic.com>
This commit is contained in:
Andrew Altshuler 2026-06-15 23:33:01 +03:00 committed by GitHub
parent 6a2dfa7325
commit 7c092d3206
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 333 additions and 8 deletions

View file

@ -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<String>,
#[arg(long)]
json: bool,
},
}
#[derive(Debug, Subcommand)]
pub(crate) enum ClusterCommand {
/// Validate cluster.yaml and referenced schemas, queries, and policy files.

View file

@ -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<ProfileListItem> = 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"));
}

View file

@ -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<String>,
#[serde(flatten)]
unknown: serde_yaml::Mapping,

View file

@ -887,6 +887,75 @@ pub(crate) fn finish_logout(
Ok(())
}
#[derive(Debug, Serialize)]
pub(crate) struct ProfileListItem {
pub(crate) name: String,
/// `server: <n>` / `cluster: <n>` / `store: <uri>` / `invalid: <reason>`.
pub(crate) binding: String,
pub(crate) default_graph: Option<String>,
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<String>,
/// 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<String>,
pub(crate) default_graph: Option<String>,
pub(crate) output_format: Option<String>,
}
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();

View file

@ -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",